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:
@@ -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";
|
||||
}
|
||||
|
||||
11
src/Billing/Services/IPushNotificationAdapter.cs
Normal file
11
src/Billing/Services/IPushNotificationAdapter.cs
Normal 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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user