1
0
mirror of https://github.com/bitwarden/server synced 2026-01-08 11:33:26 +00:00

Implement bank account hosted URL verification with webhook handling notification

This commit is contained in:
Alex Morask
2025-08-26 13:20:12 -05:00
parent 5dfed7623b
commit 5ac210f195
39 changed files with 993 additions and 616 deletions

View File

@@ -13,4 +13,5 @@ public static class HandledStripeWebhook
public const string PaymentMethodAttached = "payment_method.attached";
public const string CustomerUpdated = "customer.updated";
public const string InvoiceFinalized = "invoice.finalized";
public const string SetupIntentSucceeded = "setup_intent.succeeded";
}

View File

@@ -0,0 +1,11 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
namespace Bit.Billing.Services;
public interface IPushNotificationAdapter
{
Task NotifyBankAccountVerifiedAsync(Organization organization);
Task NotifyBankAccountVerifiedAsync(Provider provider);
Task NotifyEnabledChangedAsync(Organization organization);
}

View File

@@ -1,7 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Stripe;
using Stripe;
namespace Bit.Billing.Services;
@@ -13,12 +10,10 @@ public interface IStripeEventService
/// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param>
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the charge object from Stripe.</param>
/// <param name="fresh">Determines whether to retrieve a fresh copy of the charge object from Stripe.</param>
/// <param name="expand">Optionally provided to expand the fresh charge object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="Charge"/>.</returns>
/// <exception cref="Exception">Thrown when the Stripe event does not contain a charge object.</exception>
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null charge object.</exception>
Task<Charge> GetCharge(Event stripeEvent, bool fresh = false, List<string> expand = null);
Task<Charge> GetCharge(Event stripeEvent, bool fresh = false, List<string>? expand = null);
/// <summary>
/// Extracts the <see cref="Customer"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
@@ -26,12 +21,10 @@ public interface IStripeEventService
/// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param>
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the customer object from Stripe.</param>
/// <param name="fresh">Determines whether to retrieve a fresh copy of the customer object from Stripe.</param>
/// <param name="expand">Optionally provided to expand the fresh customer object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="Customer"/>.</returns>
/// <exception cref="Exception">Thrown when the Stripe event does not contain a customer object.</exception>
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null customer object.</exception>
Task<Customer> GetCustomer(Event stripeEvent, bool fresh = false, List<string> expand = null);
Task<Customer> GetCustomer(Event stripeEvent, bool fresh = false, List<string>? expand = null);
/// <summary>
/// Extracts the <see cref="Invoice"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
@@ -39,12 +32,10 @@ public interface IStripeEventService
/// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param>
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the invoice object from Stripe.</param>
/// <param name="fresh">Determines whether to retrieve a fresh copy of the invoice object from Stripe.</param>
/// <param name="expand">Optionally provided to expand the fresh invoice object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="Invoice"/>.</returns>
/// <exception cref="Exception">Thrown when the Stripe event does not contain an invoice object.</exception>
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null invoice object.</exception>
Task<Invoice> GetInvoice(Event stripeEvent, bool fresh = false, List<string> expand = null);
Task<Invoice> GetInvoice(Event stripeEvent, bool fresh = false, List<string>? expand = null);
/// <summary>
/// Extracts the <see cref="PaymentMethod"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
@@ -52,12 +43,21 @@ public interface IStripeEventService
/// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param>
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the payment method object from Stripe.</param>
/// <param name="fresh">Determines whether to retrieve a fresh copy of the payment method object from Stripe.</param>
/// <param name="expand">Optionally provided to expand the fresh payment method object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="PaymentMethod"/>.</returns>
/// <exception cref="Exception">Thrown when the Stripe event does not contain an payment method object.</exception>
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null payment method object.</exception>
Task<PaymentMethod> GetPaymentMethod(Event stripeEvent, bool fresh = false, List<string> expand = null);
Task<PaymentMethod> GetPaymentMethod(Event stripeEvent, bool fresh = false, List<string>? expand = null);
/// <summary>
/// Extracts the <see cref="SetupIntent"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
/// uses the setup intent ID extracted from the event to retrieve the most up-to-update setup intent from Stripe's API
/// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param>
/// <param name="fresh">Determines whether to retrieve a fresh copy of the setup intent object from Stripe.</param>
/// <param name="expand">Optionally provided to expand the fresh setup intent object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="SetupIntent"/>.</returns>
Task<SetupIntent> GetSetupIntent(Event stripeEvent, bool fresh = false, List<string>? expand = null);
/// <summary>
/// Extracts the <see cref="Subscription"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
@@ -65,12 +65,10 @@ public interface IStripeEventService
/// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param>
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the subscription object from Stripe.</param>
/// <param name="fresh">Determines whether to retrieve a fresh copy of the subscription object from Stripe.</param>
/// <param name="expand">Optionally provided to expand the fresh subscription object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="Subscription"/>.</returns>
/// <exception cref="Exception">Thrown when the Stripe event does not contain an subscription object.</exception>
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null subscription object.</exception>
Task<Subscription> GetSubscription(Event stripeEvent, bool fresh = false, List<string> expand = null);
Task<Subscription> GetSubscription(Event stripeEvent, bool fresh = false, List<string>? expand = null);
/// <summary>
/// Ensures that the customer associated with the Stripe <see cref="Event"/> is in the correct region for this server.

View File

@@ -38,6 +38,12 @@ public interface IStripeFacade
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<SetupIntent> GetSetupIntent(
string setupIntentId,
SetupIntentGetOptions setupIntentGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<StripeList<Invoice>> ListInvoices(
InvoiceListOptions options = null,
RequestOptions requestOptions = null,

View File

@@ -65,3 +65,5 @@ public interface ICustomerUpdatedHandler : IStripeWebhookHandler;
/// Defines the contract for handling Stripe Invoice Finalized events.
/// </summary>
public interface IInvoiceFinalizedHandler : IStripeWebhookHandler;
public interface ISetupIntentSucceededHandler : IStripeWebhookHandler;

View File

@@ -3,63 +3,38 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class PaymentSucceededHandler : IPaymentSucceededHandler
public class PaymentSucceededHandler(
ILogger<PaymentSucceededHandler> logger,
IStripeEventService stripeEventService,
IStripeFacade stripeFacade,
IProviderRepository providerRepository,
IOrganizationRepository organizationRepository,
IStripeEventUtilityService stripeEventUtilityService,
IUserService userService,
IOrganizationEnableCommand organizationEnableCommand,
IPricingClient pricingClient,
IPushNotificationAdapter pushNotificationAdapter)
: IPaymentSucceededHandler
{
private readonly ILogger<PaymentSucceededHandler> _logger;
private readonly IStripeEventService _stripeEventService;
private readonly IUserService _userService;
private readonly IStripeFacade _stripeFacade;
private readonly IProviderRepository _providerRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly IPushNotificationService _pushNotificationService;
private readonly IOrganizationEnableCommand _organizationEnableCommand;
private readonly IPricingClient _pricingClient;
public PaymentSucceededHandler(
ILogger<PaymentSucceededHandler> logger,
IStripeEventService stripeEventService,
IStripeFacade stripeFacade,
IProviderRepository providerRepository,
IOrganizationRepository organizationRepository,
IStripeEventUtilityService stripeEventUtilityService,
IUserService userService,
IPushNotificationService pushNotificationService,
IOrganizationEnableCommand organizationEnableCommand,
IPricingClient pricingClient)
{
_logger = logger;
_stripeEventService = stripeEventService;
_stripeFacade = stripeFacade;
_providerRepository = providerRepository;
_organizationRepository = organizationRepository;
_stripeEventUtilityService = stripeEventUtilityService;
_userService = userService;
_pushNotificationService = pushNotificationService;
_organizationEnableCommand = organizationEnableCommand;
_pricingClient = pricingClient;
}
/// <summary>
/// Handles the <see cref="HandledStripeWebhook.PaymentSucceeded"/> event type from Stripe.
/// </summary>
/// <param name="parsedEvent"></param>
public async Task HandleAsync(Event parsedEvent)
{
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
var invoice = await stripeEventService.GetInvoice(parsedEvent, true);
if (!invoice.Paid || invoice.BillingReason != "subscription_create")
{
return;
}
var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId);
var subscription = await stripeFacade.GetSubscription(invoice.SubscriptionId);
if (subscription?.Status != StripeSubscriptionStatus.Active)
{
return;
@@ -70,15 +45,15 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler
await Task.Delay(5000);
}
var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata);
if (providerId.HasValue)
{
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
var provider = await providerRepository.GetByIdAsync(providerId.Value);
if (provider == null)
{
_logger.LogError(
logger.LogError(
"Received invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) that does not exist",
parsedEvent.Id,
providerId.Value);
@@ -86,9 +61,9 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler
return;
}
var teamsMonthly = await _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly);
var teamsMonthly = await pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly);
var enterpriseMonthly = await _pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly);
var enterpriseMonthly = await pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly);
var teamsMonthlyLineItem =
subscription.Items.Data.FirstOrDefault(item =>
@@ -100,29 +75,30 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler
if (teamsMonthlyLineItem == null || enterpriseMonthlyLineItem == null)
{
_logger.LogError("invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) indicates missing subscription line items",
logger.LogError("invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) indicates missing subscription line items",
parsedEvent.Id,
provider.Id);
}
}
else if (organizationId.HasValue)
{
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
var organization = await organizationRepository.GetByIdAsync(organizationId.Value);
if (organization == null)
{
return;
}
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
if (subscription.Items.All(item => plan.PasswordManager.StripePlanId != item.Plan.Id))
{
return;
}
await _organizationEnableCommand.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
await _pushNotificationService.PushSyncOrganizationStatusAsync(organization);
await organizationEnableCommand.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd);
organization = await organizationRepository.GetByIdAsync(organization.Id);
await pushNotificationAdapter.NotifyEnabledChangedAsync(organization!);
}
else if (userId.HasValue)
{
@@ -131,7 +107,7 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler
return;
}
await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
await userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd);
}
}
}

View File

@@ -0,0 +1,70 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Models;
using Bit.Core.Platform.Push;
namespace Bit.Billing.Services.Implementations;
public class PushNotificationAdapter(
IProviderUserRepository providerUserRepository,
IPushNotificationService pushNotificationService) : IPushNotificationAdapter
{
public Task NotifyBankAccountVerifiedAsync(Organization organization) =>
pushNotificationService.PushAsync(new PushNotification<OrganizationBankAccountVerifiedPushNotification>
{
Type = PushType.OrganizationBankAccountVerified,
Target = NotificationTarget.Organization,
TargetId = organization.Id,
Payload = new OrganizationBankAccountVerifiedPushNotification
{
OrganizationId = organization.Id
},
ExcludeCurrentContext = false
});
public async Task NotifyBankAccountVerifiedAsync(Provider provider)
{
var providerUsers = await providerUserRepository.GetManyByProviderAsync(provider.Id);
var providerAdmins = providerUsers.Where(providerUser => providerUser is
{
Type: ProviderUserType.ProviderAdmin,
Status: ProviderUserStatusType.Confirmed,
UserId: not null
}).ToList();
if (providerAdmins.Count > 0)
{
var tasks = providerAdmins.Select(providerAdmin => pushNotificationService.PushAsync(
new PushNotification<ProviderBankAccountVerifiedPushNotification>
{
Type = PushType.ProviderBankAccountVerified,
Target = NotificationTarget.User,
TargetId = providerAdmin.UserId!.Value,
Payload = new ProviderBankAccountVerifiedPushNotification
{
ProviderId = provider.Id,
AdminId = providerAdmin.UserId!.Value
},
ExcludeCurrentContext = false
}));
await Task.WhenAll(tasks);
}
}
public Task NotifyEnabledChangedAsync(Organization organization) =>
pushNotificationService.PushAsync(new PushNotification<OrganizationStatusPushNotification>
{
Type = PushType.SyncOrganizationStatusChanged,
Target = NotificationTarget.Organization,
TargetId = organization.Id,
Payload = new OrganizationStatusPushNotification
{
OrganizationId = organization.Id, Enabled = organization.Enabled,
},
ExcludeCurrentContext = false,
});
}

View File

@@ -0,0 +1,83 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Repositories;
using Bit.Core.Services;
using OneOf;
using Stripe;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class SetupIntentSucceededHandler(
IOrganizationRepository organizationRepository,
IProviderRepository providerRepository,
IPushNotificationAdapter pushNotificationAdapter,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
IStripeEventService stripeEventService) : ISetupIntentSucceededHandler
{
public async Task HandleAsync(Event parsedEvent)
{
var setupIntent = await stripeEventService.GetSetupIntent(
parsedEvent,
true,
["payment_method"]);
if (setupIntent is not
{
PaymentMethod.UsBankAccount: not null
})
{
return;
}
var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id);
if (subscriberId == null)
{
return;
}
var organization = await organizationRepository.GetByIdAsync(subscriberId.Value);
var provider = await providerRepository.GetByIdAsync(subscriberId.Value);
if (organization != null)
{
await SetPaymentMethodAsync(organization, setupIntent.PaymentMethod);
}
else if (provider != null)
{
await SetPaymentMethodAsync(provider, setupIntent.PaymentMethod);
}
}
private async Task SetPaymentMethodAsync(
OneOf<Organization, Provider> subscriber,
PaymentMethod paymentMethod)
{
var customerId = subscriber.Match(
organization => organization.GatewayCustomerId,
provider => provider.GatewayCustomerId);
if (string.IsNullOrEmpty(customerId))
{
return;
}
await stripeAdapter.PaymentMethodAttachAsync(paymentMethod.Id,
new PaymentMethodAttachOptions { Customer = customerId });
await stripeAdapter.CustomerUpdateAsync(customerId, new CustomerUpdateOptions
{
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
DefaultPaymentMethod = paymentMethod.Id
}
});
await subscriber.Match(
async organization => await pushNotificationAdapter.NotifyBankAccountVerifiedAsync(organization),
async provider => await pushNotificationAdapter.NotifyBankAccountVerifiedAsync(provider));
}
}

View File

@@ -3,88 +3,64 @@ using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class StripeEventProcessor : IStripeEventProcessor
public class StripeEventProcessor(
ILogger<StripeEventProcessor> logger,
ISubscriptionDeletedHandler subscriptionDeletedHandler,
ISubscriptionUpdatedHandler subscriptionUpdatedHandler,
IUpcomingInvoiceHandler upcomingInvoiceHandler,
IChargeSucceededHandler chargeSucceededHandler,
IChargeRefundedHandler chargeRefundedHandler,
IPaymentSucceededHandler paymentSucceededHandler,
IPaymentFailedHandler paymentFailedHandler,
IInvoiceCreatedHandler invoiceCreatedHandler,
IPaymentMethodAttachedHandler paymentMethodAttachedHandler,
ICustomerUpdatedHandler customerUpdatedHandler,
IInvoiceFinalizedHandler invoiceFinalizedHandler,
ISetupIntentSucceededHandler setupIntentSucceededHandler)
: IStripeEventProcessor
{
private readonly ILogger<StripeEventProcessor> _logger;
private readonly ISubscriptionDeletedHandler _subscriptionDeletedHandler;
private readonly ISubscriptionUpdatedHandler _subscriptionUpdatedHandler;
private readonly IUpcomingInvoiceHandler _upcomingInvoiceHandler;
private readonly IChargeSucceededHandler _chargeSucceededHandler;
private readonly IChargeRefundedHandler _chargeRefundedHandler;
private readonly IPaymentSucceededHandler _paymentSucceededHandler;
private readonly IPaymentFailedHandler _paymentFailedHandler;
private readonly IInvoiceCreatedHandler _invoiceCreatedHandler;
private readonly IPaymentMethodAttachedHandler _paymentMethodAttachedHandler;
private readonly ICustomerUpdatedHandler _customerUpdatedHandler;
private readonly IInvoiceFinalizedHandler _invoiceFinalizedHandler;
public StripeEventProcessor(
ILogger<StripeEventProcessor> logger,
ISubscriptionDeletedHandler subscriptionDeletedHandler,
ISubscriptionUpdatedHandler subscriptionUpdatedHandler,
IUpcomingInvoiceHandler upcomingInvoiceHandler,
IChargeSucceededHandler chargeSucceededHandler,
IChargeRefundedHandler chargeRefundedHandler,
IPaymentSucceededHandler paymentSucceededHandler,
IPaymentFailedHandler paymentFailedHandler,
IInvoiceCreatedHandler invoiceCreatedHandler,
IPaymentMethodAttachedHandler paymentMethodAttachedHandler,
ICustomerUpdatedHandler customerUpdatedHandler,
IInvoiceFinalizedHandler invoiceFinalizedHandler)
{
_logger = logger;
_subscriptionDeletedHandler = subscriptionDeletedHandler;
_subscriptionUpdatedHandler = subscriptionUpdatedHandler;
_upcomingInvoiceHandler = upcomingInvoiceHandler;
_chargeSucceededHandler = chargeSucceededHandler;
_chargeRefundedHandler = chargeRefundedHandler;
_paymentSucceededHandler = paymentSucceededHandler;
_paymentFailedHandler = paymentFailedHandler;
_invoiceCreatedHandler = invoiceCreatedHandler;
_paymentMethodAttachedHandler = paymentMethodAttachedHandler;
_customerUpdatedHandler = customerUpdatedHandler;
_invoiceFinalizedHandler = invoiceFinalizedHandler;
}
public async Task ProcessEventAsync(Event parsedEvent)
{
switch (parsedEvent.Type)
{
case HandledStripeWebhook.SubscriptionDeleted:
await _subscriptionDeletedHandler.HandleAsync(parsedEvent);
await subscriptionDeletedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.SubscriptionUpdated:
await _subscriptionUpdatedHandler.HandleAsync(parsedEvent);
await subscriptionUpdatedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.UpcomingInvoice:
await _upcomingInvoiceHandler.HandleAsync(parsedEvent);
await upcomingInvoiceHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.ChargeSucceeded:
await _chargeSucceededHandler.HandleAsync(parsedEvent);
await chargeSucceededHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.ChargeRefunded:
await _chargeRefundedHandler.HandleAsync(parsedEvent);
await chargeRefundedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.PaymentSucceeded:
await _paymentSucceededHandler.HandleAsync(parsedEvent);
await paymentSucceededHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.PaymentFailed:
await _paymentFailedHandler.HandleAsync(parsedEvent);
await paymentFailedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.InvoiceCreated:
await _invoiceCreatedHandler.HandleAsync(parsedEvent);
await invoiceCreatedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.PaymentMethodAttached:
await _paymentMethodAttachedHandler.HandleAsync(parsedEvent);
await paymentMethodAttachedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.CustomerUpdated:
await _customerUpdatedHandler.HandleAsync(parsedEvent);
await customerUpdatedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.InvoiceFinalized:
await _invoiceFinalizedHandler.HandleAsync(parsedEvent);
await invoiceFinalizedHandler.HandleAsync(parsedEvent);
break;
case HandledStripeWebhook.SetupIntentSucceeded:
await setupIntentSucceededHandler.HandleAsync(parsedEvent);
break;
default:
_logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type);
logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type);
break;
}
}

View File

@@ -1,183 +1,122 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Billing.Constants;
using Bit.Billing.Constants;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Stripe;
namespace Bit.Billing.Services.Implementations;
public class StripeEventService : IStripeEventService
public class StripeEventService(
GlobalSettings globalSettings,
IOrganizationRepository organizationRepository,
IProviderRepository providerRepository,
ISetupIntentCache setupIntentCache,
IStripeFacade stripeFacade)
: IStripeEventService
{
private readonly GlobalSettings _globalSettings;
private readonly ILogger<StripeEventService> _logger;
private readonly IStripeFacade _stripeFacade;
public StripeEventService(
GlobalSettings globalSettings,
ILogger<StripeEventService> logger,
IStripeFacade stripeFacade)
public async Task<Charge> GetCharge(Event stripeEvent, bool fresh = false, List<string>? expand = null)
{
_globalSettings = globalSettings;
_logger = logger;
_stripeFacade = stripeFacade;
}
public async Task<Charge> GetCharge(Event stripeEvent, bool fresh = false, List<string> expand = null)
{
var eventCharge = Extract<Charge>(stripeEvent);
var charge = Extract<Charge>(stripeEvent);
if (!fresh)
{
return eventCharge;
return charge;
}
if (string.IsNullOrEmpty(eventCharge.Id))
{
_logger.LogWarning("Cannot retrieve up-to-date Charge for Event with ID '{eventId}' because no Charge ID was included in the Event.", stripeEvent.Id);
return eventCharge;
}
var charge = await _stripeFacade.GetCharge(eventCharge.Id, new ChargeGetOptions { Expand = expand });
if (charge == null)
{
throw new Exception(
$"Received null Charge from Stripe for ID '{eventCharge.Id}' while processing Event with ID '{stripeEvent.Id}'");
}
return charge;
return await stripeFacade.GetCharge(charge.Id, new ChargeGetOptions { Expand = expand });
}
public async Task<Customer> GetCustomer(Event stripeEvent, bool fresh = false, List<string> expand = null)
public async Task<Customer> GetCustomer(Event stripeEvent, bool fresh = false, List<string>? expand = null)
{
var eventCustomer = Extract<Customer>(stripeEvent);
var customer = Extract<Customer>(stripeEvent);
if (!fresh)
{
return eventCustomer;
return customer;
}
if (string.IsNullOrEmpty(eventCustomer.Id))
{
_logger.LogWarning("Cannot retrieve up-to-date Customer for Event with ID '{eventId}' because no Customer ID was included in the Event.", stripeEvent.Id);
return eventCustomer;
}
var customer = await _stripeFacade.GetCustomer(eventCustomer.Id, new CustomerGetOptions { Expand = expand });
if (customer == null)
{
throw new Exception(
$"Received null Customer from Stripe for ID '{eventCustomer.Id}' while processing Event with ID '{stripeEvent.Id}'");
}
return customer;
return await stripeFacade.GetCustomer(customer.Id, new CustomerGetOptions { Expand = expand });
}
public async Task<Invoice> GetInvoice(Event stripeEvent, bool fresh = false, List<string> expand = null)
public async Task<Invoice> GetInvoice(Event stripeEvent, bool fresh = false, List<string>? expand = null)
{
var eventInvoice = Extract<Invoice>(stripeEvent);
var invoice = Extract<Invoice>(stripeEvent);
if (!fresh)
{
return eventInvoice;
return invoice;
}
if (string.IsNullOrEmpty(eventInvoice.Id))
{
_logger.LogWarning("Cannot retrieve up-to-date Invoice for Event with ID '{eventId}' because no Invoice ID was included in the Event.", stripeEvent.Id);
return eventInvoice;
}
var invoice = await _stripeFacade.GetInvoice(eventInvoice.Id, new InvoiceGetOptions { Expand = expand });
if (invoice == null)
{
throw new Exception(
$"Received null Invoice from Stripe for ID '{eventInvoice.Id}' while processing Event with ID '{stripeEvent.Id}'");
}
return invoice;
return await stripeFacade.GetInvoice(invoice.Id, new InvoiceGetOptions { Expand = expand });
}
public async Task<PaymentMethod> GetPaymentMethod(Event stripeEvent, bool fresh = false, List<string> expand = null)
public async Task<PaymentMethod> GetPaymentMethod(Event stripeEvent, bool fresh = false,
List<string>? expand = null)
{
var eventPaymentMethod = Extract<PaymentMethod>(stripeEvent);
var paymentMethod = Extract<PaymentMethod>(stripeEvent);
if (!fresh)
{
return eventPaymentMethod;
return paymentMethod;
}
if (string.IsNullOrEmpty(eventPaymentMethod.Id))
{
_logger.LogWarning("Cannot retrieve up-to-date Payment Method for Event with ID '{eventId}' because no Payment Method ID was included in the Event.", stripeEvent.Id);
return eventPaymentMethod;
}
var paymentMethod = await _stripeFacade.GetPaymentMethod(eventPaymentMethod.Id, new PaymentMethodGetOptions { Expand = expand });
if (paymentMethod == null)
{
throw new Exception(
$"Received null Payment Method from Stripe for ID '{eventPaymentMethod.Id}' while processing Event with ID '{stripeEvent.Id}'");
}
return paymentMethod;
return await stripeFacade.GetPaymentMethod(paymentMethod.Id, new PaymentMethodGetOptions { Expand = expand });
}
public async Task<Subscription> GetSubscription(Event stripeEvent, bool fresh = false, List<string> expand = null)
public async Task<SetupIntent> GetSetupIntent(Event stripeEvent, bool fresh = false, List<string>? expand = null)
{
var eventSubscription = Extract<Subscription>(stripeEvent);
var setupIntent = Extract<SetupIntent>(stripeEvent);
if (!fresh)
{
return eventSubscription;
return setupIntent;
}
if (string.IsNullOrEmpty(eventSubscription.Id))
return await stripeFacade.GetSetupIntent(setupIntent.Id, new SetupIntentGetOptions { Expand = expand });
}
public async Task<Subscription> GetSubscription(Event stripeEvent, bool fresh = false, List<string>? expand = null)
{
var subscription = Extract<Subscription>(stripeEvent);
if (!fresh)
{
_logger.LogWarning("Cannot retrieve up-to-date Subscription for Event with ID '{eventId}' because no Subscription ID was included in the Event.", stripeEvent.Id);
return eventSubscription;
return subscription;
}
var subscription = await _stripeFacade.GetSubscription(eventSubscription.Id, new SubscriptionGetOptions { Expand = expand });
if (subscription == null)
{
throw new Exception(
$"Received null Subscription from Stripe for ID '{eventSubscription.Id}' while processing Event with ID '{stripeEvent.Id}'");
}
return subscription;
return await stripeFacade.GetSubscription(subscription.Id, new SubscriptionGetOptions { Expand = expand });
}
public async Task<bool> ValidateCloudRegion(Event stripeEvent)
{
var serverRegion = _globalSettings.BaseServiceUri.CloudRegion;
var serverRegion = globalSettings.BaseServiceUri.CloudRegion;
var customerExpansion = new List<string> { "customer" };
var customerMetadata = stripeEvent.Type switch
{
HandledStripeWebhook.SubscriptionDeleted or HandledStripeWebhook.SubscriptionUpdated =>
(await GetSubscription(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
(await GetSubscription(stripeEvent, true, customerExpansion)).Customer?.Metadata,
HandledStripeWebhook.ChargeSucceeded or HandledStripeWebhook.ChargeRefunded =>
(await GetCharge(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
(await GetCharge(stripeEvent, true, customerExpansion)).Customer?.Metadata,
HandledStripeWebhook.UpcomingInvoice =>
await GetCustomerMetadataFromUpcomingInvoiceEvent(stripeEvent),
HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed or HandledStripeWebhook.InvoiceCreated or HandledStripeWebhook.InvoiceFinalized =>
(await GetInvoice(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed
or HandledStripeWebhook.InvoiceCreated or HandledStripeWebhook.InvoiceFinalized =>
(await GetInvoice(stripeEvent, true, customerExpansion)).Customer?.Metadata,
HandledStripeWebhook.PaymentMethodAttached =>
(await GetPaymentMethod(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
(await GetPaymentMethod(stripeEvent, true, customerExpansion)).Customer?.Metadata,
HandledStripeWebhook.CustomerUpdated =>
(await GetCustomer(stripeEvent, true))?.Metadata,
(await GetCustomer(stripeEvent, true)).Metadata,
HandledStripeWebhook.SetupIntentSucceeded =>
await GetCustomerMetadataFromSetupIntentSucceededEvent(stripeEvent),
_ => null
};
@@ -194,51 +133,69 @@ public class StripeEventService : IStripeEventService
/* In Stripe, when we receive an invoice.upcoming event, the event does not include an Invoice ID because
the invoice hasn't been created yet. Therefore, rather than retrieving the fresh Invoice with a 'customer'
expansion, we need to use the Customer ID on the event to retrieve the metadata. */
async Task<Dictionary<string, string>> GetCustomerMetadataFromUpcomingInvoiceEvent(Event localStripeEvent)
async Task<Dictionary<string, string>?> GetCustomerMetadataFromUpcomingInvoiceEvent(Event localStripeEvent)
{
var invoice = await GetInvoice(localStripeEvent);
var customer = !string.IsNullOrEmpty(invoice.CustomerId)
? await _stripeFacade.GetCustomer(invoice.CustomerId)
? await stripeFacade.GetCustomer(invoice.CustomerId)
: null;
return customer?.Metadata;
}
async Task<Dictionary<string, string>?> GetCustomerMetadataFromSetupIntentSucceededEvent(Event localStripeEvent)
{
var setupIntent = await GetSetupIntent(localStripeEvent);
var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id);
if (subscriberId == null)
{
return null;
}
var organization = await organizationRepository.GetByIdAsync(subscriberId.Value);
if (organization is { GatewayCustomerId: not null })
{
var organizationCustomer = await stripeFacade.GetCustomer(organization.GatewayCustomerId);
return organizationCustomer.Metadata;
}
var provider = await providerRepository.GetByIdAsync(subscriberId.Value);
if (provider is not { GatewayCustomerId: not null })
{
return null;
}
var providerCustomer = await stripeFacade.GetCustomer(provider.GatewayCustomerId);
return providerCustomer.Metadata;
}
}
private static T Extract<T>(Event stripeEvent)
{
if (stripeEvent.Data.Object is not T type)
{
throw new Exception($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{typeof(T).Name}'");
}
return type;
}
=> stripeEvent.Data.Object is not T type
? throw new Exception(
$"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{typeof(T).Name}'")
: type;
private static string GetCustomerRegion(IDictionary<string, string> customerMetadata)
{
const string defaultRegion = "US";
if (customerMetadata is null)
{
return null;
}
if (customerMetadata.TryGetValue("region", out var value))
{
return value;
}
var miscasedRegionKey = customerMetadata.Keys
var incorrectlyCasedRegionKey = customerMetadata.Keys
.FirstOrDefault(key => key.Equals("region", StringComparison.OrdinalIgnoreCase));
if (miscasedRegionKey is null)
if (incorrectlyCasedRegionKey is null)
{
return defaultRegion;
}
_ = customerMetadata.TryGetValue(miscasedRegionKey, out var regionValue);
_ = customerMetadata.TryGetValue(incorrectlyCasedRegionKey, out var regionValue);
return !string.IsNullOrWhiteSpace(regionValue)
? regionValue

View File

@@ -16,6 +16,7 @@ public class StripeFacade : IStripeFacade
private readonly PaymentMethodService _paymentMethodService = new();
private readonly SubscriptionService _subscriptionService = new();
private readonly DiscountService _discountService = new();
private readonly SetupIntentService _setupIntentService = new();
private readonly TestClockService _testClockService = new();
public async Task<Charge> GetCharge(
@@ -53,6 +54,13 @@ public class StripeFacade : IStripeFacade
CancellationToken cancellationToken = default) =>
await _invoiceService.GetAsync(invoiceId, invoiceGetOptions, requestOptions, cancellationToken);
public async Task<SetupIntent> GetSetupIntent(
string setupIntentId,
SetupIntentGetOptions setupIntentGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default) =>
await _setupIntentService.GetAsync(setupIntentId, setupIntentGetOptions, requestOptions, cancellationToken);
public async Task<StripeList<Invoice>> ListInvoices(
InvoiceListOptions options = null,
RequestOptions requestOptions = null,

View File

@@ -7,7 +7,6 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Pricing;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Quartz;
@@ -25,7 +24,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
private readonly IStripeFacade _stripeFacade;
private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand;
private readonly IUserService _userService;
private readonly IPushNotificationService _pushNotificationService;
private readonly IOrganizationRepository _organizationRepository;
private readonly ISchedulerFactory _schedulerFactory;
private readonly IOrganizationEnableCommand _organizationEnableCommand;
@@ -35,6 +33,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
private readonly IProviderRepository _providerRepository;
private readonly IProviderService _providerService;
private readonly ILogger<SubscriptionUpdatedHandler> _logger;
private readonly IPushNotificationAdapter _pushNotificationAdapter;
public SubscriptionUpdatedHandler(
IStripeEventService stripeEventService,
@@ -43,7 +42,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
IStripeFacade stripeFacade,
IOrganizationSponsorshipRenewCommand organizationSponsorshipRenewCommand,
IUserService userService,
IPushNotificationService pushNotificationService,
IOrganizationRepository organizationRepository,
ISchedulerFactory schedulerFactory,
IOrganizationEnableCommand organizationEnableCommand,
@@ -52,7 +50,8 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
IFeatureService featureService,
IProviderRepository providerRepository,
IProviderService providerService,
ILogger<SubscriptionUpdatedHandler> logger)
ILogger<SubscriptionUpdatedHandler> logger,
IPushNotificationAdapter pushNotificationAdapter)
{
_stripeEventService = stripeEventService;
_stripeEventUtilityService = stripeEventUtilityService;
@@ -61,7 +60,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
_stripeFacade = stripeFacade;
_organizationSponsorshipRenewCommand = organizationSponsorshipRenewCommand;
_userService = userService;
_pushNotificationService = pushNotificationService;
_organizationRepository = organizationRepository;
_providerRepository = providerRepository;
_schedulerFactory = schedulerFactory;
@@ -72,6 +70,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
_providerRepository = providerRepository;
_providerService = providerService;
_logger = logger;
_pushNotificationAdapter = pushNotificationAdapter;
}
/// <summary>
@@ -125,7 +124,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
if (organization != null)
{
await _pushNotificationService.PushSyncOrganizationStatusAsync(organization);
await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization);
}
break;
}

View File

@@ -73,6 +73,7 @@ public class Startup
services.AddScoped<IPaymentMethodAttachedHandler, PaymentMethodAttachedHandler>();
services.AddScoped<IPaymentSucceededHandler, PaymentSucceededHandler>();
services.AddScoped<IInvoiceFinalizedHandler, InvoiceFinalizedHandler>();
services.AddScoped<ISetupIntentSucceededHandler, SetupIntentSucceededHandler>();
services.AddScoped<IStripeEventProcessor, StripeEventProcessor>();
// Identity
@@ -111,6 +112,7 @@ public class Startup
services.AddScoped<IStripeFacade, StripeFacade>();
services.AddScoped<IStripeEventService, StripeEventService>();
services.AddScoped<IProviderEventService, ProviderEventService>();
services.AddScoped<IPushNotificationAdapter, PushNotificationAdapter>();
// Add Quartz services first
services.AddQuartz(q =>

View File

@@ -2,9 +2,8 @@
public interface ISetupIntentCache
{
Task<string> Get(Guid subscriberId);
Task Remove(Guid subscriberId);
Task<string?> GetSetupIntentIdForSubscriber(Guid subscriberId);
Task<Guid?> GetSubscriberIdForSetupIntent(string setupIntentId);
Task RemoveSetupIntentForSubscriber(Guid subscriberId);
Task Set(Guid subscriberId, string setupIntentId);
}

View File

@@ -1,7 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.Billing.Caches.Implementations;
@@ -10,26 +7,41 @@ public class SetupIntentDistributedCache(
[FromKeyedServices("persistent")]
IDistributedCache distributedCache) : ISetupIntentCache
{
public async Task<string> Get(Guid subscriberId)
public async Task<string?> GetSetupIntentIdForSubscriber(Guid subscriberId)
{
var cacheKey = GetCacheKey(subscriberId);
var cacheKey = GetCacheKeyBySubscriberId(subscriberId);
return await distributedCache.GetStringAsync(cacheKey);
}
public async Task Remove(Guid subscriberId)
public async Task<Guid?> GetSubscriberIdForSetupIntent(string setupIntentId)
{
var cacheKey = GetCacheKey(subscriberId);
var cacheKey = GetCacheKeyBySetupIntentId(setupIntentId);
var value = await distributedCache.GetStringAsync(cacheKey);
if (string.IsNullOrEmpty(value) || !Guid.TryParse(value, out var subscriberId))
{
return null;
}
return subscriberId;
}
public async Task RemoveSetupIntentForSubscriber(Guid subscriberId)
{
var cacheKey = GetCacheKeyBySubscriberId(subscriberId);
await distributedCache.RemoveAsync(cacheKey);
}
public async Task Set(Guid subscriberId, string setupIntentId)
{
var cacheKey = GetCacheKey(subscriberId);
await distributedCache.SetStringAsync(cacheKey, setupIntentId);
var bySubscriberIdCacheKey = GetCacheKeyBySubscriberId(subscriberId);
var bySetupIntentIdCacheKey = GetCacheKeyBySetupIntentId(setupIntentId);
await Task.WhenAll(
distributedCache.SetStringAsync(bySubscriberIdCacheKey, setupIntentId),
distributedCache.SetStringAsync(bySetupIntentIdCacheKey, subscriberId.ToString()));
}
private static string GetCacheKey(Guid subscriberId) => $"pending_bank_account_{subscriberId}";
private static string GetCacheKeyBySetupIntentId(string setupIntentId) =>
$"subscriber_id_for_setup_intent_id_{setupIntentId}";
private static string GetCacheKeyBySubscriberId(Guid subscriberId) =>
$"setup_intent_id_for_subscriber_id_{subscriberId}";
}

View File

@@ -285,7 +285,7 @@ public class GetOrganizationWarningsQuery(
private async Task<bool> HasUnverifiedBankAccountAsync(
Organization organization)
{
var setupIntentId = await setupIntentCache.Get(organization.Id);
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id);
if (string.IsNullOrEmpty(setupIntentId))
{

View File

@@ -383,7 +383,7 @@ public class OrganizationBillingService(
{
case PaymentMethodType.BankAccount:
{
await setupIntentCache.Remove(organization.Id);
await setupIntentCache.RemoveSetupIntentForSubscriber(organization.Id);
break;
}
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):

View File

@@ -29,7 +29,7 @@ public class VerifyBankAccountCommand(
ISubscriber subscriber,
string descriptorCode) => HandleAsync<MaskedPaymentMethod>(async () =>
{
var setupIntentId = await setupIntentCache.Get(subscriber.Id);
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id);
if (string.IsNullOrEmpty(setupIntentId))
{

View File

@@ -10,7 +10,7 @@ public record MaskedBankAccount
{
public required string BankName { get; init; }
public required string Last4 { get; init; }
public required bool Verified { get; init; }
public string? HostedVerificationUrl { get; init; }
public string Type => "bankAccount";
}
@@ -39,8 +39,7 @@ public class MaskedPaymentMethod(OneOf<MaskedBankAccount, MaskedCard, MaskedPayP
public static MaskedPaymentMethod From(BankAccount bankAccount) => new MaskedBankAccount
{
BankName = bankAccount.BankName,
Last4 = bankAccount.Last4,
Verified = bankAccount.Status == "verified"
Last4 = bankAccount.Last4
};
public static MaskedPaymentMethod From(Card card) => new MaskedCard
@@ -61,7 +60,7 @@ public class MaskedPaymentMethod(OneOf<MaskedBankAccount, MaskedCard, MaskedPayP
{
BankName = setupIntent.PaymentMethod.UsBankAccount.BankName,
Last4 = setupIntent.PaymentMethod.UsBankAccount.Last4,
Verified = false
HostedVerificationUrl = setupIntent.NextAction?.VerifyWithMicrodeposits?.HostedVerificationUrl
};
public static MaskedPaymentMethod From(SourceCard sourceCard) => new MaskedCard
@@ -74,8 +73,7 @@ public class MaskedPaymentMethod(OneOf<MaskedBankAccount, MaskedCard, MaskedPayP
public static MaskedPaymentMethod From(PaymentMethodUsBankAccount bankAccount) => new MaskedBankAccount
{
BankName = bankAccount.BankName,
Last4 = bankAccount.Last4,
Verified = true
Last4 = bankAccount.Last4
};
public static MaskedPaymentMethod From(PayPalAccount payPalAccount) => new MaskedPayPalAccount { Email = payPalAccount.Email };

View File

@@ -77,7 +77,7 @@ public class GetPaymentMethodQuery(
}
}
var setupIntentId = await setupIntentCache.Get(subscriber.Id);
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id);
if (string.IsNullOrEmpty(setupIntentId))
{

View File

@@ -283,7 +283,7 @@ public class PremiumUserBillingService(
{
case PaymentMethodType.BankAccount:
{
await setupIntentCache.Remove(user.Id);
await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id);
break;
}
case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):

View File

@@ -860,7 +860,7 @@ public class SubscriberService(
ISubscriber subscriber,
string descriptorCode)
{
var setupIntentId = await setupIntentCache.Get(subscriber.Id);
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id);
if (string.IsNullOrEmpty(setupIntentId))
{
@@ -988,7 +988,7 @@ public class SubscriberService(
* attachedPaymentMethodDTO being null represents a case where we could be looking for the SetupIntent for an unverified "us_bank_account".
* We store the ID of this SetupIntent in the cache when we originally update the payment method.
*/
var setupIntentId = await setupIntentCache.Get(subscriberId);
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriberId);
if (string.IsNullOrEmpty(setupIntentId))
{

View File

@@ -86,3 +86,14 @@ public class OrganizationCollectionManagementPushNotification
public bool LimitCollectionDeletion { get; init; }
public bool LimitItemDeletion { get; init; }
}
public class OrganizationBankAccountVerifiedPushNotification
{
public Guid OrganizationId { get; set; }
}
public class ProviderBankAccountVerifiedPushNotification
{
public Guid ProviderId { get; set; }
public Guid AdminId { get; set; }
}

View File

@@ -399,20 +399,6 @@ public interface IPushNotificationService
ExcludeCurrentContext = true,
});
Task PushSyncOrganizationStatusAsync(Organization organization)
=> PushAsync(new PushNotification<OrganizationStatusPushNotification>
{
Type = PushType.SyncOrganizationStatusChanged,
Target = NotificationTarget.Organization,
TargetId = organization.Id,
Payload = new OrganizationStatusPushNotification
{
OrganizationId = organization.Id,
Enabled = organization.Enabled,
},
ExcludeCurrentContext = false,
});
Task PushSyncOrganizationCollectionManagementSettingsAsync(Organization organization)
=> PushAsync(new PushNotification<OrganizationCollectionManagementPushNotification>
{

View File

@@ -4,16 +4,16 @@
namespace Bit.Core.Enums;
/// <summary>
///
///
/// </summary>
/// <remarks>
/// <para>
/// When adding a new enum member you must annotate it with a <see cref="NotificationInfoAttribute"/>
/// When adding a new enum member you must annotate it with a <see cref="NotificationInfoAttribute"/>
/// this is enforced with a unit test. It is preferred that you do NOT add new usings for the type referenced
/// in <see cref="NotificationInfoAttribute"/>.
/// </para>
/// <para>
/// You may and are
/// You may and are
/// </para>
/// </remarks>
public enum PushType : byte
@@ -90,4 +90,10 @@ public enum PushType : byte
[NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.UserPushNotification))]
RefreshSecurityTasks = 22,
[NotificationInfo("@bitwarden/team-billing-dev", typeof(Models.ProviderBankAccountVerifiedPushNotification))]
OrganizationBankAccountVerified = 23,
[NotificationInfo("@bitwarden/team-billing-dev", typeof(Models.ProviderBankAccountVerifiedPushNotification))]
ProviderBankAccountVerified = 24
}

View File

@@ -106,6 +106,20 @@ public static class HubHelpers
await hubContext.Clients.Group(NotificationsHub.GetOrganizationGroup(organizationCollectionSettingsChangedNotification.Payload.OrganizationId))
.SendAsync(_receiveMessageMethod, organizationCollectionSettingsChangedNotification, cancellationToken);
break;
case PushType.OrganizationBankAccountVerified:
var organizationBankAccountVerifiedNotification =
JsonSerializer.Deserialize<PushNotificationData<OrganizationBankAccountVerifiedPushNotification>>(
notificationJson, _deserializerOptions);
await hubContext.Clients.Group(NotificationsHub.GetOrganizationGroup(organizationBankAccountVerifiedNotification.Payload.OrganizationId))
.SendAsync(_receiveMessageMethod, organizationBankAccountVerifiedNotification, cancellationToken);
break;
case PushType.ProviderBankAccountVerified:
var providerBankAccountVerifiedNotification =
JsonSerializer.Deserialize<PushNotificationData<ProviderBankAccountVerifiedPushNotification>>(
notificationJson, _deserializerOptions);
await hubContext.Clients.User(providerBankAccountVerifiedNotification.Payload.AdminId.ToString())
.SendAsync(_receiveMessageMethod, providerBankAccountVerifiedNotification, cancellationToken);
break;
case PushType.Notification:
case PushType.NotificationStatus:
var notificationData = JsonSerializer.Deserialize<PushNotificationData<NotificationPushNotification>>(
@@ -144,6 +158,7 @@ public static class HubHelpers
.SendAsync(_receiveMessageMethod, pendingTasksData, cancellationToken);
break;
default:
logger.LogWarning("Notification type '{NotificationType}' has not been registered in HubHelpers and will not be pushed as as result", notification.Type);
break;
}
}