diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs index 5169d6cfd1..398674c7b6 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs @@ -636,10 +636,10 @@ public class ProviderBillingService( { case PaymentMethodType.BankAccount: { - var setupIntentId = await setupIntentCache.Get(provider.Id); + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id); await stripeAdapter.SetupIntentCancel(setupIntentId, new SetupIntentCancelOptions { CancellationReason = "abandoned" }); - await setupIntentCache.Remove(provider.Id); + await setupIntentCache.RemoveSetupIntentForSubscriber(provider.Id); break; } case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): @@ -689,7 +689,7 @@ public class ProviderBillingService( }); } - var setupIntentId = await setupIntentCache.Get(provider.Id); + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id); var setupIntent = !string.IsNullOrEmpty(setupIntentId) ? await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs index 4e811017f9..54c0b82aa9 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs @@ -1003,7 +1003,7 @@ public class ProviderBillingServiceTests o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) .Throws(); - sutProvider.GetDependency().Get(provider.Id).Returns("setup_intent_id"); + sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns("setup_intent_id"); await Assert.ThrowsAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); @@ -1013,7 +1013,7 @@ public class ProviderBillingServiceTests await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_id", Arg.Is(options => options.CancellationReason == "abandoned")); - await sutProvider.GetDependency().Received(1).Remove(provider.Id); + await sutProvider.GetDependency().Received(1).RemoveSetupIntentForSubscriber(provider.Id); } [Theory, BitAutoData] @@ -1644,7 +1644,7 @@ public class ProviderBillingServiceTests const string setupIntentId = "seti_123"; - sutProvider.GetDependency().Get(provider.Id).Returns(setupIntentId); + sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntentId); sutProvider.GetDependency().SetupIntentGet(setupIntentId, Arg.Is(options => options.Expand.Contains("payment_method"))).Returns(new SetupIntent diff --git a/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs index a85dfe11e1..ee98031dbc 100644 --- a/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs @@ -25,8 +25,7 @@ public class OrganizationBillingVNextController( IGetOrganizationWarningsQuery getOrganizationWarningsQuery, IGetPaymentMethodQuery getPaymentMethodQuery, IUpdateBillingAddressCommand updateBillingAddressCommand, - IUpdatePaymentMethodCommand updatePaymentMethodCommand, - IVerifyBankAccountCommand verifyBankAccountCommand) : BaseBillingController + IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController { [Authorize] [HttpGet("address")] @@ -96,17 +95,6 @@ public class OrganizationBillingVNextController( return Handle(result); } - [Authorize] - [HttpPost("payment-method/verify-bank-account")] - [InjectOrganization] - public async Task VerifyBankAccountAsync( - [BindNever] Organization organization, - [FromBody] VerifyBankAccountRequest request) - { - var result = await verifyBankAccountCommand.Run(organization, request.DescriptorCode); - return Handle(result); - } - [Authorize] [HttpGet("warnings")] [InjectOrganization] diff --git a/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs index b0b39eaf4a..0ea9bad682 100644 --- a/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs @@ -23,8 +23,7 @@ public class ProviderBillingVNextController( IGetProviderWarningsQuery getProviderWarningsQuery, IProviderService providerService, IUpdateBillingAddressCommand updateBillingAddressCommand, - IUpdatePaymentMethodCommand updatePaymentMethodCommand, - IVerifyBankAccountCommand verifyBankAccountCommand) : BaseBillingController + IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController { [HttpGet("address")] [InjectProvider(ProviderUserType.ProviderAdmin)] @@ -97,16 +96,6 @@ public class ProviderBillingVNextController( return Handle(result); } - [HttpPost("payment-method/verify-bank-account")] - [InjectProvider(ProviderUserType.ProviderAdmin)] - public async Task VerifyBankAccountAsync( - [BindNever] Provider provider, - [FromBody] VerifyBankAccountRequest request) - { - var result = await verifyBankAccountCommand.Run(provider, request.DescriptorCode); - return Handle(result); - } - [HttpGet("warnings")] [InjectProvider(ProviderUserType.ServiceUser)] public async Task GetWarningsAsync( diff --git a/src/Billing/Constants/HandledStripeWebhook.cs b/src/Billing/Constants/HandledStripeWebhook.cs index cbcc2065c3..e9e0c5a16b 100644 --- a/src/Billing/Constants/HandledStripeWebhook.cs +++ b/src/Billing/Constants/HandledStripeWebhook.cs @@ -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"; } diff --git a/src/Billing/Services/IPushNotificationAdapter.cs b/src/Billing/Services/IPushNotificationAdapter.cs new file mode 100644 index 0000000000..2f74f35eec --- /dev/null +++ b/src/Billing/Services/IPushNotificationAdapter.cs @@ -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); +} diff --git a/src/Billing/Services/IStripeEventService.cs b/src/Billing/Services/IStripeEventService.cs index bf242905ee..567d404ba6 100644 --- a/src/Billing/Services/IStripeEventService.cs +++ b/src/Billing/Services/IStripeEventService.cs @@ -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 options. /// /// The Stripe webhook event. - /// Determines whether or not to retrieve a fresh copy of the charge object from Stripe. + /// Determines whether to retrieve a fresh copy of the charge object from Stripe. /// Optionally provided to expand the fresh charge object retrieved from Stripe. /// A Stripe . - /// Thrown when the Stripe event does not contain a charge object. - /// Thrown when is true and Stripe's API returns a null charge object. - Task GetCharge(Event stripeEvent, bool fresh = false, List expand = null); + Task GetCharge(Event stripeEvent, bool fresh = false, List? expand = null); /// /// Extracts the object from the Stripe . When is true, @@ -26,12 +21,10 @@ public interface IStripeEventService /// and optionally expands it with the provided options. /// /// The Stripe webhook event. - /// Determines whether or not to retrieve a fresh copy of the customer object from Stripe. + /// Determines whether to retrieve a fresh copy of the customer object from Stripe. /// Optionally provided to expand the fresh customer object retrieved from Stripe. /// A Stripe . - /// Thrown when the Stripe event does not contain a customer object. - /// Thrown when is true and Stripe's API returns a null customer object. - Task GetCustomer(Event stripeEvent, bool fresh = false, List expand = null); + Task GetCustomer(Event stripeEvent, bool fresh = false, List? expand = null); /// /// Extracts the object from the Stripe . When is true, @@ -39,12 +32,10 @@ public interface IStripeEventService /// and optionally expands it with the provided options. /// /// The Stripe webhook event. - /// Determines whether or not to retrieve a fresh copy of the invoice object from Stripe. + /// Determines whether to retrieve a fresh copy of the invoice object from Stripe. /// Optionally provided to expand the fresh invoice object retrieved from Stripe. /// A Stripe . - /// Thrown when the Stripe event does not contain an invoice object. - /// Thrown when is true and Stripe's API returns a null invoice object. - Task GetInvoice(Event stripeEvent, bool fresh = false, List expand = null); + Task GetInvoice(Event stripeEvent, bool fresh = false, List? expand = null); /// /// Extracts the object from the Stripe . When is true, @@ -52,12 +43,21 @@ public interface IStripeEventService /// and optionally expands it with the provided options. /// /// The Stripe webhook event. - /// Determines whether or not to retrieve a fresh copy of the payment method object from Stripe. + /// Determines whether to retrieve a fresh copy of the payment method object from Stripe. /// Optionally provided to expand the fresh payment method object retrieved from Stripe. /// A Stripe . - /// Thrown when the Stripe event does not contain an payment method object. - /// Thrown when is true and Stripe's API returns a null payment method object. - Task GetPaymentMethod(Event stripeEvent, bool fresh = false, List expand = null); + Task GetPaymentMethod(Event stripeEvent, bool fresh = false, List? expand = null); + + /// + /// Extracts the object from the Stripe . When 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 options. + /// + /// The Stripe webhook event. + /// Determines whether to retrieve a fresh copy of the setup intent object from Stripe. + /// Optionally provided to expand the fresh setup intent object retrieved from Stripe. + /// A Stripe . + Task GetSetupIntent(Event stripeEvent, bool fresh = false, List? expand = null); /// /// Extracts the object from the Stripe . When is true, @@ -65,12 +65,10 @@ public interface IStripeEventService /// and optionally expands it with the provided options. /// /// The Stripe webhook event. - /// Determines whether or not to retrieve a fresh copy of the subscription object from Stripe. + /// Determines whether to retrieve a fresh copy of the subscription object from Stripe. /// Optionally provided to expand the fresh subscription object retrieved from Stripe. /// A Stripe . - /// Thrown when the Stripe event does not contain an subscription object. - /// Thrown when is true and Stripe's API returns a null subscription object. - Task GetSubscription(Event stripeEvent, bool fresh = false, List expand = null); + Task GetSubscription(Event stripeEvent, bool fresh = false, List? expand = null); /// /// Ensures that the customer associated with the Stripe is in the correct region for this server. diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs index 37ba51cc61..280a3aca3c 100644 --- a/src/Billing/Services/IStripeFacade.cs +++ b/src/Billing/Services/IStripeFacade.cs @@ -38,6 +38,12 @@ public interface IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + Task GetSetupIntent( + string setupIntentId, + SetupIntentGetOptions setupIntentGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + Task> ListInvoices( InvoiceListOptions options = null, RequestOptions requestOptions = null, diff --git a/src/Billing/Services/IStripeWebhookHandler.cs b/src/Billing/Services/IStripeWebhookHandler.cs index 59be435489..2619b2f663 100644 --- a/src/Billing/Services/IStripeWebhookHandler.cs +++ b/src/Billing/Services/IStripeWebhookHandler.cs @@ -65,3 +65,5 @@ public interface ICustomerUpdatedHandler : IStripeWebhookHandler; /// Defines the contract for handling Stripe Invoice Finalized events. /// public interface IInvoiceFinalizedHandler : IStripeWebhookHandler; + +public interface ISetupIntentSucceededHandler : IStripeWebhookHandler; diff --git a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs index 4c256e3d85..a10fa4b3d6 100644 --- a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs +++ b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs @@ -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 logger, + IStripeEventService stripeEventService, + IStripeFacade stripeFacade, + IProviderRepository providerRepository, + IOrganizationRepository organizationRepository, + IStripeEventUtilityService stripeEventUtilityService, + IUserService userService, + IOrganizationEnableCommand organizationEnableCommand, + IPricingClient pricingClient, + IPushNotificationAdapter pushNotificationAdapter) + : IPaymentSucceededHandler { - private readonly ILogger _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 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; - } - /// /// Handles the event type from Stripe. /// /// 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); } } } diff --git a/src/Billing/Services/Implementations/PushNotificationAdapter.cs b/src/Billing/Services/Implementations/PushNotificationAdapter.cs new file mode 100644 index 0000000000..673ae1415e --- /dev/null +++ b/src/Billing/Services/Implementations/PushNotificationAdapter.cs @@ -0,0 +1,71 @@ +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 + { + 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 + { + 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 + { + Type = PushType.SyncOrganizationStatusChanged, + Target = NotificationTarget.Organization, + TargetId = organization.Id, + Payload = new OrganizationStatusPushNotification + { + OrganizationId = organization.Id, + Enabled = organization.Enabled, + }, + ExcludeCurrentContext = false, + }); +} diff --git a/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs b/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs new file mode 100644 index 0000000000..bc3fa1bd56 --- /dev/null +++ b/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs @@ -0,0 +1,77 @@ +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); + + OneOf entity = organization != null ? organization : provider!; + await SetPaymentMethodAsync(entity, setupIntent.PaymentMethod); + } + + private async Task SetPaymentMethodAsync( + OneOf 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)); + } +} diff --git a/src/Billing/Services/Implementations/StripeEventProcessor.cs b/src/Billing/Services/Implementations/StripeEventProcessor.cs index b0d9cf187d..6db813f70c 100644 --- a/src/Billing/Services/Implementations/StripeEventProcessor.cs +++ b/src/Billing/Services/Implementations/StripeEventProcessor.cs @@ -3,88 +3,64 @@ using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; -public class StripeEventProcessor : IStripeEventProcessor +public class StripeEventProcessor( + ILogger 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 _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 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; } } diff --git a/src/Billing/Services/Implementations/StripeEventService.cs b/src/Billing/Services/Implementations/StripeEventService.cs index 7eef357e14..03ca8eeb10 100644 --- a/src/Billing/Services/Implementations/StripeEventService.cs +++ b/src/Billing/Services/Implementations/StripeEventService.cs @@ -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 _logger; - private readonly IStripeFacade _stripeFacade; - - public StripeEventService( - GlobalSettings globalSettings, - ILogger logger, - IStripeFacade stripeFacade) + public async Task GetCharge(Event stripeEvent, bool fresh = false, List? expand = null) { - _globalSettings = globalSettings; - _logger = logger; - _stripeFacade = stripeFacade; - } - - public async Task GetCharge(Event stripeEvent, bool fresh = false, List expand = null) - { - var eventCharge = Extract(stripeEvent); + var charge = Extract(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 GetCustomer(Event stripeEvent, bool fresh = false, List expand = null) + public async Task GetCustomer(Event stripeEvent, bool fresh = false, List? expand = null) { - var eventCustomer = Extract(stripeEvent); + var customer = Extract(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 GetInvoice(Event stripeEvent, bool fresh = false, List expand = null) + public async Task GetInvoice(Event stripeEvent, bool fresh = false, List? expand = null) { - var eventInvoice = Extract(stripeEvent); + var invoice = Extract(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 GetPaymentMethod(Event stripeEvent, bool fresh = false, List expand = null) + public async Task GetPaymentMethod(Event stripeEvent, bool fresh = false, + List? expand = null) { - var eventPaymentMethod = Extract(stripeEvent); + var paymentMethod = Extract(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 GetSubscription(Event stripeEvent, bool fresh = false, List expand = null) + public async Task GetSetupIntent(Event stripeEvent, bool fresh = false, List? expand = null) { - var eventSubscription = Extract(stripeEvent); + var setupIntent = Extract(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 GetSubscription(Event stripeEvent, bool fresh = false, List? expand = null) + { + var subscription = Extract(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 ValidateCloudRegion(Event stripeEvent) { - var serverRegion = _globalSettings.BaseServiceUri.CloudRegion; + var serverRegion = globalSettings.BaseServiceUri.CloudRegion; var customerExpansion = new List { "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> GetCustomerMetadataFromUpcomingInvoiceEvent(Event localStripeEvent) + async Task?> 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?> 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(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 customerMetadata) { const string defaultRegion = Core.Constants.CountryAbbreviations.UnitedStates; - 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 diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs index 726a3e977c..eef7ce009e 100644 --- a/src/Billing/Services/Implementations/StripeFacade.cs +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -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 GetCharge( @@ -53,6 +54,13 @@ public class StripeFacade : IStripeFacade CancellationToken cancellationToken = default) => await _invoiceService.GetAsync(invoiceId, invoiceGetOptions, requestOptions, cancellationToken); + public async Task GetSetupIntent( + string setupIntentId, + SetupIntentGetOptions setupIntentGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _setupIntentService.GetAsync(setupIntentId, setupIntentGetOptions, requestOptions, cancellationToken); + public async Task> ListInvoices( InvoiceListOptions options = null, RequestOptions requestOptions = null, diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index d5fcfb20d4..10630f78f4 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -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 _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 logger) + ILogger 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; } /// @@ -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; } diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index cfbc90c36e..5b464d5ef6 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -73,6 +73,7 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); // Identity @@ -111,6 +112,7 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Add Quartz services first services.AddQuartz(q => diff --git a/src/Core/Billing/Caches/ISetupIntentCache.cs b/src/Core/Billing/Caches/ISetupIntentCache.cs index 0990266239..8e53e8fb09 100644 --- a/src/Core/Billing/Caches/ISetupIntentCache.cs +++ b/src/Core/Billing/Caches/ISetupIntentCache.cs @@ -2,9 +2,8 @@ public interface ISetupIntentCache { - Task Get(Guid subscriberId); - - Task Remove(Guid subscriberId); - + Task GetSetupIntentIdForSubscriber(Guid subscriberId); + Task GetSubscriberIdForSetupIntent(string setupIntentId); + Task RemoveSetupIntentForSubscriber(Guid subscriberId); Task Set(Guid subscriberId, string setupIntentId); } diff --git a/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs b/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs index 432a778762..8833c928fe 100644 --- a/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs +++ b/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs @@ -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 Get(Guid subscriberId) + public async Task 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 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}"; } diff --git a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs index 0b0cbd22c6..312623ffa5 100644 --- a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs @@ -285,7 +285,7 @@ public class GetOrganizationWarningsQuery( private async Task HasUnverifiedBankAccountAsync( Organization organization) { - var setupIntentId = await setupIntentCache.Get(organization.Id); + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id); if (string.IsNullOrEmpty(setupIntentId)) { diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index 446f9563f9..ce8a9a877b 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -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): diff --git a/src/Core/Billing/Payment/Commands/VerifyBankAccountCommand.cs b/src/Core/Billing/Payment/Commands/VerifyBankAccountCommand.cs deleted file mode 100644 index 4f3e38707c..0000000000 --- a/src/Core/Billing/Payment/Commands/VerifyBankAccountCommand.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Bit.Core.Billing.Caches; -using Bit.Core.Billing.Commands; -using Bit.Core.Billing.Payment.Models; -using Bit.Core.Entities; -using Bit.Core.Services; -using Microsoft.Extensions.Logging; -using Stripe; - -namespace Bit.Core.Billing.Payment.Commands; - -public interface IVerifyBankAccountCommand -{ - Task> Run( - ISubscriber subscriber, - string descriptorCode); -} - -public class VerifyBankAccountCommand( - ILogger logger, - ISetupIntentCache setupIntentCache, - IStripeAdapter stripeAdapter) : BaseBillingCommand(logger), IVerifyBankAccountCommand -{ - private readonly ILogger _logger = logger; - - protected override Conflict DefaultConflict - => new("We had a problem verifying your bank account. Please contact support for assistance."); - - public Task> Run( - ISubscriber subscriber, - string descriptorCode) => HandleAsync(async () => - { - var setupIntentId = await setupIntentCache.Get(subscriber.Id); - - if (string.IsNullOrEmpty(setupIntentId)) - { - _logger.LogError( - "{Command}: Could not find setup intent to verify subscriber's ({SubscriberID}) bank account", - CommandName, subscriber.Id); - return DefaultConflict; - } - - await stripeAdapter.SetupIntentVerifyMicroDeposit(setupIntentId, - new SetupIntentVerifyMicrodepositsOptions { DescriptorCode = descriptorCode }); - - var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, - new SetupIntentGetOptions { Expand = ["payment_method"] }); - - var paymentMethod = await stripeAdapter.PaymentMethodAttachAsync(setupIntent.PaymentMethodId, - new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId }); - - await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, - new CustomerUpdateOptions - { - InvoiceSettings = new CustomerInvoiceSettingsOptions - { - DefaultPaymentMethod = setupIntent.PaymentMethodId - } - }); - - return MaskedPaymentMethod.From(paymentMethod.UsBankAccount); - }); -} diff --git a/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs b/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs index d23ca75025..d30c27ee41 100644 --- a/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs +++ b/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs @@ -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 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 new MaskedCard @@ -74,8 +73,7 @@ public class MaskedPaymentMethod(OneOf new MaskedBankAccount { BankName = bankAccount.BankName, - Last4 = bankAccount.Last4, - Verified = true + Last4 = bankAccount.Last4 }; public static MaskedPaymentMethod From(PayPalAccount payPalAccount) => new MaskedPayPalAccount { Email = payPalAccount.Email }; diff --git a/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs b/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs index ce8f031a5d..9f9618571e 100644 --- a/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs +++ b/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs @@ -33,6 +33,7 @@ public class GetPaymentMethodQuery( return null; } + // First check for PayPal if (customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId)) { var braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId); @@ -47,6 +48,23 @@ public class GetPaymentMethodQuery( return null; } + // Then check for a bank account pending verification + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id); + + if (!string.IsNullOrEmpty(setupIntentId)) + { + var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions + { + Expand = ["payment_method"] + }); + + if (setupIntent.IsUnverifiedBankAccount()) + { + return MaskedPaymentMethod.From(setupIntent); + } + } + + // Then check the default payment method var paymentMethod = customer.InvoiceSettings.DefaultPaymentMethod != null ? customer.InvoiceSettings.DefaultPaymentMethod.Type switch { @@ -61,40 +79,12 @@ public class GetPaymentMethodQuery( return paymentMethod; } - if (customer.DefaultSource != null) + return customer.DefaultSource switch { - paymentMethod = customer.DefaultSource switch - { - Card card => MaskedPaymentMethod.From(card), - BankAccount bankAccount => MaskedPaymentMethod.From(bankAccount), - Source { Card: not null } source => MaskedPaymentMethod.From(source.Card), - _ => null - }; - - if (paymentMethod != null) - { - return paymentMethod; - } - } - - var setupIntentId = await setupIntentCache.Get(subscriber.Id); - - if (string.IsNullOrEmpty(setupIntentId)) - { - return null; - } - - var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions - { - Expand = ["payment_method"] - }); - - // ReSharper disable once ConvertIfStatementToReturnStatement - if (!setupIntent.IsUnverifiedBankAccount()) - { - return null; - } - - return MaskedPaymentMethod.From(setupIntent); + Card card => MaskedPaymentMethod.From(card), + BankAccount bankAccount => MaskedPaymentMethod.From(bankAccount), + Source { Card: not null } source => MaskedPaymentMethod.From(source.Card), + _ => null + }; } } diff --git a/src/Core/Billing/Payment/Registrations.cs b/src/Core/Billing/Payment/Registrations.cs index 1cc7914f10..478673d2fc 100644 --- a/src/Core/Billing/Payment/Registrations.cs +++ b/src/Core/Billing/Payment/Registrations.cs @@ -14,7 +14,6 @@ public static class Registrations services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); // Queries services.AddTransient(); diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index 5b1b717c20..986991ba0a 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -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): diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 378e84f15a..1206397d9e 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -858,7 +858,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)) { @@ -986,7 +986,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)) { diff --git a/src/Core/Models/PushNotification.cs b/src/Core/Models/PushNotification.cs index e235d05b13..c4ae1e2858 100644 --- a/src/Core/Models/PushNotification.cs +++ b/src/Core/Models/PushNotification.cs @@ -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; } +} diff --git a/src/Core/Platform/Push/IPushNotificationService.cs b/src/Core/Platform/Push/IPushNotificationService.cs index 339ce5a917..32a488b827 100644 --- a/src/Core/Platform/Push/IPushNotificationService.cs +++ b/src/Core/Platform/Push/IPushNotificationService.cs @@ -399,20 +399,6 @@ public interface IPushNotificationService ExcludeCurrentContext = true, }); - Task PushSyncOrganizationStatusAsync(Organization organization) - => PushAsync(new PushNotification - { - 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 { diff --git a/src/Core/Platform/Push/PushType.cs b/src/Core/Platform/Push/PushType.cs index 7fcb60b4ef..7765c1aa66 100644 --- a/src/Core/Platform/Push/PushType.cs +++ b/src/Core/Platform/Push/PushType.cs @@ -4,16 +4,16 @@ namespace Bit.Core.Enums; /// -/// +/// /// /// /// -/// When adding a new enum member you must annotate it with a +/// When adding a new enum member you must annotate it with a /// this is enforced with a unit test. It is preferred that you do NOT add new usings for the type referenced /// in . /// /// -/// You may and are +/// You may and are /// /// 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 } diff --git a/src/Notifications/HubHelpers.cs b/src/Notifications/HubHelpers.cs index f49ca96ea4..69d5bdc958 100644 --- a/src/Notifications/HubHelpers.cs +++ b/src/Notifications/HubHelpers.cs @@ -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>( + notificationJson, _deserializerOptions); + await hubContext.Clients.Group(NotificationsHub.GetOrganizationGroup(organizationBankAccountVerifiedNotification.Payload.OrganizationId)) + .SendAsync(_receiveMessageMethod, organizationBankAccountVerifiedNotification, cancellationToken); + break; + case PushType.ProviderBankAccountVerified: + var providerBankAccountVerifiedNotification = + JsonSerializer.Deserialize>( + 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>( @@ -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; } } diff --git a/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs b/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs new file mode 100644 index 0000000000..e9f0d9d0ed --- /dev/null +++ b/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs @@ -0,0 +1,242 @@ +using Bit.Billing.Services; +using Bit.Billing.Services.Implementations; +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 NSubstitute; +using Stripe; +using Xunit; +using Event = Stripe.Event; + +namespace Bit.Billing.Test.Services; + +public class SetupIntentSucceededHandlerTests +{ + private static readonly Event _mockEvent = new() { Id = "evt_test", Type = "setup_intent.succeeded" }; + private static readonly string[] _expand = ["payment_method"]; + + private readonly IOrganizationRepository _organizationRepository; + private readonly IProviderRepository _providerRepository; + private readonly IPushNotificationAdapter _pushNotificationAdapter; + private readonly ISetupIntentCache _setupIntentCache; + private readonly IStripeAdapter _stripeAdapter; + private readonly IStripeEventService _stripeEventService; + private readonly SetupIntentSucceededHandler _handler; + + public SetupIntentSucceededHandlerTests() + { + _organizationRepository = Substitute.For(); + _providerRepository = Substitute.For(); + _pushNotificationAdapter = Substitute.For(); + _setupIntentCache = Substitute.For(); + _stripeAdapter = Substitute.For(); + _stripeEventService = Substitute.For(); + + _handler = new SetupIntentSucceededHandler( + _organizationRepository, + _providerRepository, + _pushNotificationAdapter, + _setupIntentCache, + _stripeAdapter, + _stripeEventService); + } + + [Fact] + public async Task HandleAsync_PaymentMethodNotUSBankAccount_Returns() + { + // Arrange + var setupIntent = CreateSetupIntent(hasUSBankAccount: false); + + _stripeEventService.GetSetupIntent( + _mockEvent, + true, + Arg.Is>(options => options.SequenceEqual(_expand))) + .Returns(setupIntent); + + // Act + await _handler.HandleAsync(_mockEvent); + + // Assert + await _setupIntentCache.DidNotReceiveWithAnyArgs().GetSubscriberIdForSetupIntent(Arg.Any()); + await _stripeAdapter.DidNotReceiveWithAnyArgs().PaymentMethodAttachAsync( + Arg.Any(), Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_NoSubscriberIdInCache_Returns() + { + // Arrange + var setupIntent = CreateSetupIntent(); + + _stripeEventService.GetSetupIntent( + _mockEvent, + true, + Arg.Is>(options => options.SequenceEqual(_expand))) + .Returns(setupIntent); + + _setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id) + .Returns((Guid?)null); + + // Act + await _handler.HandleAsync(_mockEvent); + + // Assert + await _stripeAdapter.DidNotReceiveWithAnyArgs().PaymentMethodAttachAsync( + Arg.Any(), Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ValidOrganization_AttachesPaymentMethodAndSendsNotification() + { + // Arrange + var organizationId = Guid.NewGuid(); + var organization = new Organization { Id = organizationId, Name = "Test Org", GatewayCustomerId = "cus_test" }; + var setupIntent = CreateSetupIntent(); + + _stripeEventService.GetSetupIntent( + _mockEvent, + true, + Arg.Is>(options => options.SequenceEqual(_expand))) + .Returns(setupIntent); + + _setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id) + .Returns(organizationId); + + _organizationRepository.GetByIdAsync(organizationId) + .Returns(organization); + + // Act + await _handler.HandleAsync(_mockEvent); + + // Assert + await _stripeAdapter.Received(1).PaymentMethodAttachAsync( + "pm_test", + Arg.Is(o => o.Customer == organization.GatewayCustomerId)); + + await _pushNotificationAdapter.Received(1).NotifyBankAccountVerifiedAsync(organization); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ValidProvider_AttachesPaymentMethodAndSendsNotification() + { + // Arrange + var providerId = Guid.NewGuid(); + var provider = new Provider { Id = providerId, Name = "Test Provider", GatewayCustomerId = "cus_test" }; + var setupIntent = CreateSetupIntent(); + + _stripeEventService.GetSetupIntent( + _mockEvent, + true, + Arg.Is>(options => options.SequenceEqual(_expand))) + .Returns(setupIntent); + + _setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id) + .Returns(providerId); + + _organizationRepository.GetByIdAsync(providerId) + .Returns((Organization?)null); + + _providerRepository.GetByIdAsync(providerId) + .Returns(provider); + + // Act + await _handler.HandleAsync(_mockEvent); + + // Assert + await _stripeAdapter.Received(1).PaymentMethodAttachAsync( + "pm_test", + Arg.Is(o => o.Customer == provider.GatewayCustomerId)); + + await _pushNotificationAdapter.Received(1).NotifyBankAccountVerifiedAsync(provider); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_OrganizationWithoutGatewayCustomerId_DoesNotAttachPaymentMethod() + { + // Arrange + var organizationId = Guid.NewGuid(); + var organization = new Organization { Id = organizationId, Name = "Test Org", GatewayCustomerId = null }; + var setupIntent = CreateSetupIntent(); + + _stripeEventService.GetSetupIntent( + _mockEvent, + true, + Arg.Is>(options => options.SequenceEqual(_expand))) + .Returns(setupIntent); + + _setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id) + .Returns(organizationId); + + _organizationRepository.GetByIdAsync(organizationId) + .Returns(organization); + + // Act + await _handler.HandleAsync(_mockEvent); + + // Assert + await _stripeAdapter.DidNotReceiveWithAnyArgs().PaymentMethodAttachAsync( + Arg.Any(), Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ProviderWithoutGatewayCustomerId_DoesNotAttachPaymentMethod() + { + // Arrange + var providerId = Guid.NewGuid(); + var provider = new Provider { Id = providerId, Name = "Test Provider", GatewayCustomerId = null }; + var setupIntent = CreateSetupIntent(); + + _stripeEventService.GetSetupIntent( + _mockEvent, + true, + Arg.Is>(options => options.SequenceEqual(_expand))) + .Returns(setupIntent); + + _setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id) + .Returns(providerId); + + _organizationRepository.GetByIdAsync(providerId) + .Returns((Organization?)null); + + _providerRepository.GetByIdAsync(providerId) + .Returns(provider); + + // Act + await _handler.HandleAsync(_mockEvent); + + // Assert + await _stripeAdapter.DidNotReceiveWithAnyArgs().PaymentMethodAttachAsync( + Arg.Any(), Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + } + + private static SetupIntent CreateSetupIntent(bool hasUSBankAccount = true) + { + var paymentMethod = new PaymentMethod + { + Id = "pm_test", + Type = "us_bank_account", + UsBankAccount = hasUSBankAccount ? new PaymentMethodUsBankAccount() : null + }; + + var setupIntent = new SetupIntent + { + Id = "seti_test", + PaymentMethod = paymentMethod + }; + + return setupIntent; + } +} diff --git a/test/Billing.Test/Services/StripeEventServiceTests.cs b/test/Billing.Test/Services/StripeEventServiceTests.cs index b40e8b9408..68aeab2f44 100644 --- a/test/Billing.Test/Services/StripeEventServiceTests.cs +++ b/test/Billing.Test/Services/StripeEventServiceTests.cs @@ -1,8 +1,9 @@ using Bit.Billing.Services; using Bit.Billing.Services.Implementations; -using Bit.Billing.Test.Utilities; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Caches; +using Bit.Core.Repositories; using Bit.Core.Settings; -using Microsoft.Extensions.Logging; using NSubstitute; using Stripe; using Xunit; @@ -11,6 +12,9 @@ namespace Bit.Billing.Test.Services; public class StripeEventServiceTests { + private readonly IOrganizationRepository _organizationRepository; + private readonly IProviderRepository _providerRepository; + private readonly ISetupIntentCache _setupIntentCache; private readonly IStripeFacade _stripeFacade; private readonly StripeEventService _stripeEventService; @@ -20,8 +24,11 @@ public class StripeEventServiceTests var baseServiceUriSettings = new GlobalSettings.BaseServiceUriSettings(globalSettings) { CloudRegion = "US" }; globalSettings.BaseServiceUri = baseServiceUriSettings; + _organizationRepository = Substitute.For(); + _providerRepository = Substitute.For(); + _setupIntentCache = Substitute.For(); _stripeFacade = Substitute.For(); - _stripeEventService = new StripeEventService(globalSettings, Substitute.For>(), _stripeFacade); + _stripeEventService = new StripeEventService(globalSettings, _organizationRepository, _providerRepository, _setupIntentCache, _stripeFacade); } #region GetCharge @@ -29,50 +36,44 @@ public class StripeEventServiceTests public async Task GetCharge_EventNotChargeRelated_ThrowsException() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + var stripeEvent = CreateMockEvent("evt_test", "invoice.created", new Invoice { Id = "in_test" }); - // Act - var function = async () => await _stripeEventService.GetCharge(stripeEvent); - - // Assert - var exception = await Assert.ThrowsAsync(function); + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await _stripeEventService.GetCharge(stripeEvent)); Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Charge)}'", exception.Message); await _stripeFacade.DidNotReceiveWithAnyArgs().GetCharge( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetCharge_NotFresh_ReturnsEventCharge() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.ChargeSucceeded); + var mockCharge = new Charge { Id = "ch_test", Amount = 1000 }; + var stripeEvent = CreateMockEvent("evt_test", "charge.succeeded", mockCharge); // Act var charge = await _stripeEventService.GetCharge(stripeEvent); // Assert - Assert.Equivalent(stripeEvent.Data.Object as Charge, charge, true); + Assert.Equal(mockCharge.Id, charge.Id); + Assert.Equal(mockCharge.Amount, charge.Amount); await _stripeFacade.DidNotReceiveWithAnyArgs().GetCharge( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetCharge_Fresh_Expand_ReturnsAPICharge() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.ChargeSucceeded); + var eventCharge = new Charge { Id = "ch_test", Amount = 1000 }; + var stripeEvent = CreateMockEvent("evt_test", "charge.succeeded", eventCharge); - var eventCharge = stripeEvent.Data.Object as Charge; - - var apiCharge = Copy(eventCharge); + var apiCharge = new Charge { Id = "ch_test", Amount = 2000 }; var expand = new List { "customer" }; @@ -90,9 +91,7 @@ public class StripeEventServiceTests await _stripeFacade.Received().GetCharge( apiCharge.Id, - Arg.Is(options => options.Expand == expand), - Arg.Any(), - Arg.Any()); + Arg.Is(options => options.Expand == expand)); } #endregion @@ -101,50 +100,44 @@ public class StripeEventServiceTests public async Task GetCustomer_EventNotCustomerRelated_ThrowsException() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + var stripeEvent = CreateMockEvent("evt_test", "invoice.created", new Invoice { Id = "in_test" }); - // Act - var function = async () => await _stripeEventService.GetCustomer(stripeEvent); - - // Assert - var exception = await Assert.ThrowsAsync(function); + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await _stripeEventService.GetCustomer(stripeEvent)); Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Customer)}'", exception.Message); await _stripeFacade.DidNotReceiveWithAnyArgs().GetCustomer( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetCustomer_NotFresh_ReturnsEventCustomer() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + var mockCustomer = new Customer { Id = "cus_test", Email = "test@example.com" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", mockCustomer); // Act var customer = await _stripeEventService.GetCustomer(stripeEvent); // Assert - Assert.Equivalent(stripeEvent.Data.Object as Customer, customer, true); + Assert.Equal(mockCustomer.Id, customer.Id); + Assert.Equal(mockCustomer.Email, customer.Email); await _stripeFacade.DidNotReceiveWithAnyArgs().GetCustomer( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetCustomer_Fresh_Expand_ReturnsAPICustomer() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + var eventCustomer = new Customer { Id = "cus_test", Email = "test@example.com" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", eventCustomer); - var eventCustomer = stripeEvent.Data.Object as Customer; - - var apiCustomer = Copy(eventCustomer); + var apiCustomer = new Customer { Id = "cus_test", Email = "updated@example.com" }; var expand = new List { "subscriptions" }; @@ -162,9 +155,7 @@ public class StripeEventServiceTests await _stripeFacade.Received().GetCustomer( apiCustomer.Id, - Arg.Is(options => options.Expand == expand), - Arg.Any(), - Arg.Any()); + Arg.Is(options => options.Expand == expand)); } #endregion @@ -173,50 +164,44 @@ public class StripeEventServiceTests public async Task GetInvoice_EventNotInvoiceRelated_ThrowsException() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", new Customer { Id = "cus_test" }); - // Act - var function = async () => await _stripeEventService.GetInvoice(stripeEvent); - - // Assert - var exception = await Assert.ThrowsAsync(function); + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await _stripeEventService.GetInvoice(stripeEvent)); Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Invoice)}'", exception.Message); await _stripeFacade.DidNotReceiveWithAnyArgs().GetInvoice( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetInvoice_NotFresh_ReturnsEventInvoice() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + var mockInvoice = new Invoice { Id = "in_test", AmountDue = 1000 }; + var stripeEvent = CreateMockEvent("evt_test", "invoice.created", mockInvoice); // Act var invoice = await _stripeEventService.GetInvoice(stripeEvent); // Assert - Assert.Equivalent(stripeEvent.Data.Object as Invoice, invoice, true); + Assert.Equal(mockInvoice.Id, invoice.Id); + Assert.Equal(mockInvoice.AmountDue, invoice.AmountDue); await _stripeFacade.DidNotReceiveWithAnyArgs().GetInvoice( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetInvoice_Fresh_Expand_ReturnsAPIInvoice() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + var eventInvoice = new Invoice { Id = "in_test", AmountDue = 1000 }; + var stripeEvent = CreateMockEvent("evt_test", "invoice.created", eventInvoice); - var eventInvoice = stripeEvent.Data.Object as Invoice; - - var apiInvoice = Copy(eventInvoice); + var apiInvoice = new Invoice { Id = "in_test", AmountDue = 2000 }; var expand = new List { "customer" }; @@ -234,9 +219,7 @@ public class StripeEventServiceTests await _stripeFacade.Received().GetInvoice( apiInvoice.Id, - Arg.Is(options => options.Expand == expand), - Arg.Any(), - Arg.Any()); + Arg.Is(options => options.Expand == expand)); } #endregion @@ -245,50 +228,44 @@ public class StripeEventServiceTests public async Task GetPaymentMethod_EventNotPaymentMethodRelated_ThrowsException() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", new Customer { Id = "cus_test" }); - // Act - var function = async () => await _stripeEventService.GetPaymentMethod(stripeEvent); - - // Assert - var exception = await Assert.ThrowsAsync(function); + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await _stripeEventService.GetPaymentMethod(stripeEvent)); Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(PaymentMethod)}'", exception.Message); await _stripeFacade.DidNotReceiveWithAnyArgs().GetPaymentMethod( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetPaymentMethod_NotFresh_ReturnsEventPaymentMethod() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached); + var mockPaymentMethod = new PaymentMethod { Id = "pm_test", Type = "card" }; + var stripeEvent = CreateMockEvent("evt_test", "payment_method.attached", mockPaymentMethod); // Act var paymentMethod = await _stripeEventService.GetPaymentMethod(stripeEvent); // Assert - Assert.Equivalent(stripeEvent.Data.Object as PaymentMethod, paymentMethod, true); + Assert.Equal(mockPaymentMethod.Id, paymentMethod.Id); + Assert.Equal(mockPaymentMethod.Type, paymentMethod.Type); await _stripeFacade.DidNotReceiveWithAnyArgs().GetPaymentMethod( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetPaymentMethod_Fresh_Expand_ReturnsAPIPaymentMethod() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached); + var eventPaymentMethod = new PaymentMethod { Id = "pm_test", Type = "card" }; + var stripeEvent = CreateMockEvent("evt_test", "payment_method.attached", eventPaymentMethod); - var eventPaymentMethod = stripeEvent.Data.Object as PaymentMethod; - - var apiPaymentMethod = Copy(eventPaymentMethod); + var apiPaymentMethod = new PaymentMethod { Id = "pm_test", Type = "card" }; var expand = new List { "customer" }; @@ -306,9 +283,7 @@ public class StripeEventServiceTests await _stripeFacade.Received().GetPaymentMethod( apiPaymentMethod.Id, - Arg.Is(options => options.Expand == expand), - Arg.Any(), - Arg.Any()); + Arg.Is(options => options.Expand == expand)); } #endregion @@ -317,50 +292,44 @@ public class StripeEventServiceTests public async Task GetSubscription_EventNotSubscriptionRelated_ThrowsException() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", new Customer { Id = "cus_test" }); - // Act - var function = async () => await _stripeEventService.GetSubscription(stripeEvent); - - // Assert - var exception = await Assert.ThrowsAsync(function); + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await _stripeEventService.GetSubscription(stripeEvent)); Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Subscription)}'", exception.Message); await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetSubscription_NotFresh_ReturnsEventSubscription() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + var mockSubscription = new Subscription { Id = "sub_test", Status = "active" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", mockSubscription); // Act var subscription = await _stripeEventService.GetSubscription(stripeEvent); // Assert - Assert.Equivalent(stripeEvent.Data.Object as Subscription, subscription, true); + Assert.Equal(mockSubscription.Id, subscription.Id); + Assert.Equal(mockSubscription.Status, subscription.Status); await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetSubscription_Fresh_Expand_ReturnsAPISubscription() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + var eventSubscription = new Subscription { Id = "sub_test", Status = "active" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", eventSubscription); - var eventSubscription = stripeEvent.Data.Object as Subscription; - - var apiSubscription = Copy(eventSubscription); + var apiSubscription = new Subscription { Id = "sub_test", Status = "canceled" }; var expand = new List { "customer" }; @@ -378,9 +347,71 @@ public class StripeEventServiceTests await _stripeFacade.Received().GetSubscription( apiSubscription.Id, - Arg.Is(options => options.Expand == expand), - Arg.Any(), - Arg.Any()); + Arg.Is(options => options.Expand == expand)); + } + #endregion + + #region GetSetupIntent + [Fact] + public async Task GetSetupIntent_EventNotSetupIntentRelated_ThrowsException() + { + // Arrange + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", new Customer { Id = "cus_test" }); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await _stripeEventService.GetSetupIntent(stripeEvent)); + Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(SetupIntent)}'", exception.Message); + + await _stripeFacade.DidNotReceiveWithAnyArgs().GetSetupIntent( + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetSetupIntent_NotFresh_ReturnsEventSetupIntent() + { + // Arrange + var mockSetupIntent = new SetupIntent { Id = "seti_test", Status = "succeeded" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent); + + // Act + var setupIntent = await _stripeEventService.GetSetupIntent(stripeEvent); + + // Assert + Assert.Equal(mockSetupIntent.Id, setupIntent.Id); + Assert.Equal(mockSetupIntent.Status, setupIntent.Status); + + await _stripeFacade.DidNotReceiveWithAnyArgs().GetSetupIntent( + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetSetupIntent_Fresh_Expand_ReturnsAPISetupIntent() + { + // Arrange + var eventSetupIntent = new SetupIntent { Id = "seti_test", Status = "succeeded" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", eventSetupIntent); + + var apiSetupIntent = new SetupIntent { Id = "seti_test", Status = "requires_action" }; + + var expand = new List { "customer" }; + + _stripeFacade.GetSetupIntent( + apiSetupIntent.Id, + Arg.Is(options => options.Expand == expand)) + .Returns(apiSetupIntent); + + // Act + var setupIntent = await _stripeEventService.GetSetupIntent(stripeEvent, true, expand); + + // Assert + Assert.Equal(apiSetupIntent, setupIntent); + Assert.NotSame(eventSetupIntent, setupIntent); + + await _stripeFacade.Received().GetSetupIntent( + apiSetupIntent.Id, + Arg.Is(options => options.Expand == expand)); } #endregion @@ -389,18 +420,16 @@ public class StripeEventServiceTests public async Task ValidateCloudRegion_SubscriptionUpdated_Success() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + var mockSubscription = new Subscription { Id = "sub_test" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", mockSubscription); - var subscription = Copy(stripeEvent.Data.Object as Subscription); - - var customer = await GetCustomerAsync(); - - subscription.Customer = customer; + var customer = CreateMockCustomer(); + mockSubscription.Customer = customer; _stripeFacade.GetSubscription( - subscription.Id, + mockSubscription.Id, Arg.Any()) - .Returns(subscription); + .Returns(mockSubscription); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -409,28 +438,24 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetSubscription( - subscription.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockSubscription.Id, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_ChargeSucceeded_Success() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.ChargeSucceeded); + var mockCharge = new Charge { Id = "ch_test" }; + var stripeEvent = CreateMockEvent("evt_test", "charge.succeeded", mockCharge); - var charge = Copy(stripeEvent.Data.Object as Charge); - - var customer = await GetCustomerAsync(); - - charge.Customer = customer; + var customer = CreateMockCustomer(); + mockCharge.Customer = customer; _stripeFacade.GetCharge( - charge.Id, + mockCharge.Id, Arg.Any()) - .Returns(charge); + .Returns(mockCharge); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -439,24 +464,21 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetCharge( - charge.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockCharge.Id, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_UpcomingInvoice_Success() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceUpcoming); + var mockInvoice = new Invoice { Id = "in_test", CustomerId = "cus_test" }; + var stripeEvent = CreateMockEvent("evt_test", "invoice.upcoming", mockInvoice); - var invoice = Copy(stripeEvent.Data.Object as Invoice); - - var customer = await GetCustomerAsync(); + var customer = CreateMockCustomer(); _stripeFacade.GetCustomer( - invoice.CustomerId, + mockInvoice.CustomerId, Arg.Any()) .Returns(customer); @@ -467,28 +489,24 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetCustomer( - invoice.CustomerId, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockInvoice.CustomerId, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_InvoiceCreated_Success() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + var mockInvoice = new Invoice { Id = "in_test" }; + var stripeEvent = CreateMockEvent("evt_test", "invoice.created", mockInvoice); - var invoice = Copy(stripeEvent.Data.Object as Invoice); - - var customer = await GetCustomerAsync(); - - invoice.Customer = customer; + var customer = CreateMockCustomer(); + mockInvoice.Customer = customer; _stripeFacade.GetInvoice( - invoice.Id, + mockInvoice.Id, Arg.Any()) - .Returns(invoice); + .Returns(mockInvoice); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -497,28 +515,24 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetInvoice( - invoice.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockInvoice.Id, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_PaymentMethodAttached_Success() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached); + var mockPaymentMethod = new PaymentMethod { Id = "pm_test" }; + var stripeEvent = CreateMockEvent("evt_test", "payment_method.attached", mockPaymentMethod); - var paymentMethod = Copy(stripeEvent.Data.Object as PaymentMethod); - - var customer = await GetCustomerAsync(); - - paymentMethod.Customer = customer; + var customer = CreateMockCustomer(); + mockPaymentMethod.Customer = customer; _stripeFacade.GetPaymentMethod( - paymentMethod.Id, + mockPaymentMethod.Id, Arg.Any()) - .Returns(paymentMethod); + .Returns(mockPaymentMethod); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -527,24 +541,21 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetPaymentMethod( - paymentMethod.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockPaymentMethod.Id, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_CustomerUpdated_Success() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); - - var customer = Copy(stripeEvent.Data.Object as Customer); + var mockCustomer = CreateMockCustomer(); + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", mockCustomer); _stripeFacade.GetCustomer( - customer.Id, + mockCustomer.Id, Arg.Any()) - .Returns(customer); + .Returns(mockCustomer); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -553,29 +564,24 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetCustomer( - customer.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockCustomer.Id, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_MetadataNull_ReturnsFalse() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + var mockSubscription = new Subscription { Id = "sub_test" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", mockSubscription); - var subscription = Copy(stripeEvent.Data.Object as Subscription); - - var customer = await GetCustomerAsync(); - customer.Metadata = null; - - subscription.Customer = customer; + var customer = new Customer { Id = "cus_test", Metadata = null }; + mockSubscription.Customer = customer; _stripeFacade.GetSubscription( - subscription.Id, + mockSubscription.Id, Arg.Any()) - .Returns(subscription); + .Returns(mockSubscription); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -584,29 +590,24 @@ public class StripeEventServiceTests Assert.False(cloudRegionValid); await _stripeFacade.Received(1).GetSubscription( - subscription.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockSubscription.Id, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_MetadataNoRegion_DefaultUS_ReturnsTrue() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + var mockSubscription = new Subscription { Id = "sub_test" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", mockSubscription); - var subscription = Copy(stripeEvent.Data.Object as Subscription); - - var customer = await GetCustomerAsync(); - customer.Metadata = new Dictionary(); - - subscription.Customer = customer; + var customer = new Customer { Id = "cus_test", Metadata = new Dictionary() }; + mockSubscription.Customer = customer; _stripeFacade.GetSubscription( - subscription.Id, + mockSubscription.Id, Arg.Any()) - .Returns(subscription); + .Returns(mockSubscription); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -615,32 +616,28 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetSubscription( - subscription.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockSubscription.Id, + Arg.Any()); } [Fact] - public async Task ValidateCloudRegion_MetadataMiscasedRegion_ReturnsTrue() + public async Task ValidateCloudRegion_MetadataIncorrectlyCasedRegion_ReturnsTrue() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + var mockSubscription = new Subscription { Id = "sub_test" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", mockSubscription); - var subscription = Copy(stripeEvent.Data.Object as Subscription); - - var customer = await GetCustomerAsync(); - customer.Metadata = new Dictionary + var customer = new Customer { - { "Region", "US" } + Id = "cus_test", + Metadata = new Dictionary { { "Region", "US" } } }; - - subscription.Customer = customer; + mockSubscription.Customer = customer; _stripeFacade.GetSubscription( - subscription.Id, + mockSubscription.Id, Arg.Any()) - .Returns(subscription); + .Returns(mockSubscription); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -649,31 +646,209 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetSubscription( - subscription.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockSubscription.Id, + Arg.Any()); + } + + [Fact] + public async Task ValidateCloudRegion_SetupIntentSucceeded_OrganizationCustomer_Success() + { + // Arrange + var mockSetupIntent = new SetupIntent { Id = "seti_test" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent); + var organizationId = Guid.NewGuid(); + var organizationCustomerId = "cus_org_test"; + + var mockOrganization = new Core.AdminConsole.Entities.Organization + { + Id = organizationId, + GatewayCustomerId = organizationCustomerId + }; + var customer = CreateMockCustomer(); + + _setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id) + .Returns(organizationId); + + _organizationRepository.GetByIdAsync(organizationId) + .Returns(mockOrganization); + + _stripeFacade.GetCustomer(organizationCustomerId) + .Returns(customer); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + Assert.True(cloudRegionValid); + + await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id); + await _organizationRepository.Received(1).GetByIdAsync(organizationId); + await _stripeFacade.Received(1).GetCustomer(organizationCustomerId); + } + + [Fact] + public async Task ValidateCloudRegion_SetupIntentSucceeded_ProviderCustomer_Success() + { + // Arrange + var mockSetupIntent = new SetupIntent { Id = "seti_test" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent); + var providerId = Guid.NewGuid(); + var providerCustomerId = "cus_provider_test"; + + var mockProvider = new Core.AdminConsole.Entities.Provider.Provider + { + Id = providerId, + GatewayCustomerId = providerCustomerId + }; + var customer = CreateMockCustomer(); + + _setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id) + .Returns(providerId); + + _organizationRepository.GetByIdAsync(providerId) + .Returns((Core.AdminConsole.Entities.Organization?)null); + + _providerRepository.GetByIdAsync(providerId) + .Returns(mockProvider); + + _stripeFacade.GetCustomer(providerCustomerId) + .Returns(customer); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + Assert.True(cloudRegionValid); + + await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id); + await _organizationRepository.Received(1).GetByIdAsync(providerId); + await _providerRepository.Received(1).GetByIdAsync(providerId); + await _stripeFacade.Received(1).GetCustomer(providerCustomerId); + } + + [Fact] + public async Task ValidateCloudRegion_SetupIntentSucceeded_NoSubscriberIdInCache_ReturnsFalse() + { + // Arrange + var mockSetupIntent = new SetupIntent { Id = "seti_test" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent); + + _setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id) + .Returns((Guid?)null); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + Assert.False(cloudRegionValid); + + await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id); + await _organizationRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + await _providerRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + await _stripeFacade.DidNotReceive().GetCustomer(Arg.Any()); + } + + [Fact] + public async Task ValidateCloudRegion_SetupIntentSucceeded_OrganizationWithoutGatewayCustomerId_ChecksProvider() + { + // Arrange + var mockSetupIntent = new SetupIntent { Id = "seti_test" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent); + var subscriberId = Guid.NewGuid(); + var providerCustomerId = "cus_provider_test"; + + var mockOrganizationWithoutCustomerId = new Core.AdminConsole.Entities.Organization + { + Id = subscriberId, + GatewayCustomerId = null + }; + + var mockProvider = new Core.AdminConsole.Entities.Provider.Provider + { + Id = subscriberId, + GatewayCustomerId = providerCustomerId + }; + var customer = CreateMockCustomer(); + + _setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id) + .Returns(subscriberId); + + _organizationRepository.GetByIdAsync(subscriberId) + .Returns(mockOrganizationWithoutCustomerId); + + _providerRepository.GetByIdAsync(subscriberId) + .Returns(mockProvider); + + _stripeFacade.GetCustomer(providerCustomerId) + .Returns(customer); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + Assert.True(cloudRegionValid); + + await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id); + await _organizationRepository.Received(1).GetByIdAsync(subscriberId); + await _providerRepository.Received(1).GetByIdAsync(subscriberId); + await _stripeFacade.Received(1).GetCustomer(providerCustomerId); + } + + [Fact] + public async Task ValidateCloudRegion_SetupIntentSucceeded_ProviderWithoutGatewayCustomerId_ReturnsFalse() + { + // Arrange + var mockSetupIntent = new SetupIntent { Id = "seti_test" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent); + var subscriberId = Guid.NewGuid(); + + var mockProviderWithoutCustomerId = new Core.AdminConsole.Entities.Provider.Provider + { + Id = subscriberId, + GatewayCustomerId = null + }; + + _setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id) + .Returns(subscriberId); + + _organizationRepository.GetByIdAsync(subscriberId) + .Returns((Core.AdminConsole.Entities.Organization?)null); + + _providerRepository.GetByIdAsync(subscriberId) + .Returns(mockProviderWithoutCustomerId); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + Assert.False(cloudRegionValid); + + await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id); + await _organizationRepository.Received(1).GetByIdAsync(subscriberId); + await _providerRepository.Received(1).GetByIdAsync(subscriberId); + await _stripeFacade.DidNotReceive().GetCustomer(Arg.Any()); } #endregion - private static T Copy(T input) + private static Event CreateMockEvent(string id, string type, T dataObject) where T : IStripeEntity { - var copy = (T)Activator.CreateInstance(typeof(T)); - - var properties = input.GetType().GetProperties(); - - foreach (var property in properties) + return new Event { - var value = property.GetValue(input); - copy! - .GetType() - .GetProperty(property.Name)! - .SetValue(copy, value); - } - - return copy; + Id = id, + Type = type, + Data = new EventData + { + Object = (IHasObject)dataObject + } + }; } - private static async Task GetCustomerAsync() - => (await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated)).Data.Object as Customer; + private static Customer CreateMockCustomer() + { + return new Customer + { + Id = "cus_test", + Metadata = new Dictionary { { "region", "US" } } + }; + } } diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index 0d1f54ecfd..6a7cd7704b 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -11,7 +11,6 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.StaticStore.Plans; 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 Microsoft.Extensions.Logging; @@ -33,7 +32,6 @@ public class SubscriptionUpdatedHandlerTests private readonly IStripeFacade _stripeFacade; private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand; private readonly IUserService _userService; - private readonly IPushNotificationService _pushNotificationService; private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationEnableCommand _organizationEnableCommand; private readonly IOrganizationDisableCommand _organizationDisableCommand; @@ -42,6 +40,7 @@ public class SubscriptionUpdatedHandlerTests private readonly IProviderRepository _providerRepository; private readonly IProviderService _providerService; private readonly IScheduler _scheduler; + private readonly IPushNotificationAdapter _pushNotificationAdapter; private readonly SubscriptionUpdatedHandler _sut; public SubscriptionUpdatedHandlerTests() @@ -53,7 +52,6 @@ public class SubscriptionUpdatedHandlerTests _organizationSponsorshipRenewCommand = Substitute.For(); _userService = Substitute.For(); _providerService = Substitute.For(); - _pushNotificationService = Substitute.For(); _organizationRepository = Substitute.For(); var schedulerFactory = Substitute.For(); _organizationEnableCommand = Substitute.For(); @@ -64,6 +62,7 @@ public class SubscriptionUpdatedHandlerTests _providerService = Substitute.For(); var logger = Substitute.For>(); _scheduler = Substitute.For(); + _pushNotificationAdapter = Substitute.For(); schedulerFactory.GetScheduler().Returns(_scheduler); @@ -74,7 +73,6 @@ public class SubscriptionUpdatedHandlerTests _stripeFacade, _organizationSponsorshipRenewCommand, _userService, - _pushNotificationService, _organizationRepository, schedulerFactory, _organizationEnableCommand, @@ -83,7 +81,8 @@ public class SubscriptionUpdatedHandlerTests _featureService, _providerRepository, _providerService, - logger); + logger, + _pushNotificationAdapter); } [Fact] @@ -540,8 +539,8 @@ public class SubscriptionUpdatedHandlerTests .EnableAsync(organizationId); await _organizationService.Received(1) .UpdateExpirationDateAsync(organizationId, currentPeriodEnd); - await _pushNotificationService.Received(1) - .PushSyncOrganizationStatusAsync(organization); + await _pushNotificationAdapter.Received(1) + .NotifyEnabledChangedAsync(organization); } [Fact] diff --git a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs index c22cc239d8..eefda06149 100644 --- a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs +++ b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs @@ -71,7 +71,7 @@ public class GetOrganizationWarningsQueryTests }); sutProvider.GetDependency().EditSubscription(organization.Id).Returns(true); - sutProvider.GetDependency().Get(organization.Id).Returns((string?)null); + sutProvider.GetDependency().GetSetupIntentIdForSubscriber(organization.Id).Returns((string?)null); var response = await sutProvider.Sut.Run(organization); @@ -109,7 +109,7 @@ public class GetOrganizationWarningsQueryTests }); sutProvider.GetDependency().EditSubscription(organization.Id).Returns(true); - sutProvider.GetDependency().Get(organization.Id).Returns(setupIntentId); + sutProvider.GetDependency().GetSetupIntentIdForSubscriber(organization.Id).Returns(setupIntentId); sutProvider.GetDependency().SetupIntentGet(setupIntentId, Arg.Is( options => options.Expand.Contains("payment_method"))).Returns(new SetupIntent { diff --git a/test/Core.Test/Billing/Payment/Commands/UpdatePaymentMethodCommandTests.cs b/test/Core.Test/Billing/Payment/Commands/UpdatePaymentMethodCommandTests.cs index 8b1f915658..72280c4c77 100644 --- a/test/Core.Test/Billing/Payment/Commands/UpdatePaymentMethodCommandTests.cs +++ b/test/Core.Test/Billing/Payment/Commands/UpdatePaymentMethodCommandTests.cs @@ -74,7 +74,10 @@ public class UpdatePaymentMethodCommandTests }, NextAction = new SetupIntentNextAction { - VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() + VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits + { + HostedVerificationUrl = "https://example.com" + } }, Status = "requires_action" }; @@ -95,7 +98,7 @@ public class UpdatePaymentMethodCommandTests var maskedBankAccount = maskedPaymentMethod.AsT0; Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("9999", maskedBankAccount.Last4); - Assert.False(maskedBankAccount.Verified); + Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl); await _setupIntentCache.Received(1).Set(organization.Id, setupIntent.Id); } @@ -133,7 +136,10 @@ public class UpdatePaymentMethodCommandTests }, NextAction = new SetupIntentNextAction { - VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() + VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits + { + HostedVerificationUrl = "https://example.com" + } }, Status = "requires_action" }; @@ -154,7 +160,7 @@ public class UpdatePaymentMethodCommandTests var maskedBankAccount = maskedPaymentMethod.AsT0; Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("9999", maskedBankAccount.Last4); - Assert.False(maskedBankAccount.Verified); + Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl); await _subscriberService.Received(1).CreateStripeCustomer(organization); @@ -199,7 +205,10 @@ public class UpdatePaymentMethodCommandTests }, NextAction = new SetupIntentNextAction { - VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() + VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits + { + HostedVerificationUrl = "https://example.com" + } }, Status = "requires_action" }; @@ -220,7 +229,7 @@ public class UpdatePaymentMethodCommandTests var maskedBankAccount = maskedPaymentMethod.AsT0; Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("9999", maskedBankAccount.Last4); - Assert.False(maskedBankAccount.Verified); + Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl); await _setupIntentCache.Received(1).Set(organization.Id, setupIntent.Id); await _stripeAdapter.Received(1).CustomerUpdateAsync(customer.Id, Arg.Is(options => diff --git a/test/Core.Test/Billing/Payment/Commands/VerifyBankAccountCommandTests.cs b/test/Core.Test/Billing/Payment/Commands/VerifyBankAccountCommandTests.cs deleted file mode 100644 index 4be5539cc8..0000000000 --- a/test/Core.Test/Billing/Payment/Commands/VerifyBankAccountCommandTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Billing.Caches; -using Bit.Core.Billing.Payment.Commands; -using Bit.Core.Services; -using Bit.Core.Test.Billing.Extensions; -using Microsoft.Extensions.Logging; -using NSubstitute; -using Stripe; -using Xunit; - -namespace Bit.Core.Test.Billing.Payment.Commands; - -public class VerifyBankAccountCommandTests -{ - private readonly ISetupIntentCache _setupIntentCache = Substitute.For(); - private readonly IStripeAdapter _stripeAdapter = Substitute.For(); - private readonly VerifyBankAccountCommand _command; - - public VerifyBankAccountCommandTests() - { - _command = new VerifyBankAccountCommand( - Substitute.For>(), - _setupIntentCache, - _stripeAdapter); - } - - [Fact] - public async Task Run_MakesCorrectInvocations_ReturnsMaskedBankAccount() - { - var organization = new Organization - { - Id = Guid.NewGuid(), - GatewayCustomerId = "cus_123" - }; - - const string setupIntentId = "seti_123"; - - _setupIntentCache.Get(organization.Id).Returns(setupIntentId); - - var setupIntent = new SetupIntent - { - Id = setupIntentId, - PaymentMethodId = "pm_123", - PaymentMethod = - new PaymentMethod - { - Id = "pm_123", - Type = "us_bank_account", - UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" } - }, - NextAction = new SetupIntentNextAction - { - VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() - }, - Status = "requires_action" - }; - - _stripeAdapter.SetupIntentGet(setupIntentId, - Arg.Is(options => options.HasExpansions("payment_method"))).Returns(setupIntent); - - _stripeAdapter.PaymentMethodAttachAsync(setupIntent.PaymentMethodId, - Arg.Is(options => options.Customer == organization.GatewayCustomerId)) - .Returns(setupIntent.PaymentMethod); - - var result = await _command.Run(organization, "DESCRIPTOR_CODE"); - - Assert.True(result.IsT0); - var maskedPaymentMethod = result.AsT0; - Assert.True(maskedPaymentMethod.IsT0); - var maskedBankAccount = maskedPaymentMethod.AsT0; - Assert.Equal("Chase", maskedBankAccount.BankName); - Assert.Equal("9999", maskedBankAccount.Last4); - Assert.True(maskedBankAccount.Verified); - - await _stripeAdapter.Received(1).SetupIntentVerifyMicroDeposit(setupIntent.Id, - Arg.Is(options => options.DescriptorCode == "DESCRIPTOR_CODE")); - - await _stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is( - options => options.InvoiceSettings.DefaultPaymentMethod == setupIntent.PaymentMethodId)); - } -} diff --git a/test/Core.Test/Billing/Payment/Models/MaskedPaymentMethodTests.cs b/test/Core.Test/Billing/Payment/Models/MaskedPaymentMethodTests.cs index 39753857d5..21d47f7615 100644 --- a/test/Core.Test/Billing/Payment/Models/MaskedPaymentMethodTests.cs +++ b/test/Core.Test/Billing/Payment/Models/MaskedPaymentMethodTests.cs @@ -13,7 +13,7 @@ public class MaskedPaymentMethodTests { BankName = "Chase", Last4 = "9999", - Verified = true + HostedVerificationUrl = "https://example.com" }; var json = JsonSerializer.Serialize(input); @@ -32,7 +32,7 @@ public class MaskedPaymentMethodTests { BankName = "Chase", Last4 = "9999", - Verified = true + HostedVerificationUrl = "https://example.com" }; var jsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; diff --git a/test/Core.Test/Billing/Payment/Queries/GetPaymentMethodQueryTests.cs b/test/Core.Test/Billing/Payment/Queries/GetPaymentMethodQueryTests.cs index 8a4475268d..b6b0d596b3 100644 --- a/test/Core.Test/Billing/Payment/Queries/GetPaymentMethodQueryTests.cs +++ b/test/Core.Test/Billing/Payment/Queries/GetPaymentMethodQueryTests.cs @@ -108,7 +108,7 @@ public class GetPaymentMethodQueryTests var maskedBankAccount = maskedPaymentMethod.AsT0; Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("9999", maskedBankAccount.Last4); - Assert.True(maskedBankAccount.Verified); + Assert.Null(maskedBankAccount.HostedVerificationUrl); } [Fact] @@ -142,7 +142,7 @@ public class GetPaymentMethodQueryTests var maskedBankAccount = maskedPaymentMethod.AsT0; Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("9999", maskedBankAccount.Last4); - Assert.True(maskedBankAccount.Verified); + Assert.Null(maskedBankAccount.HostedVerificationUrl); } [Fact] @@ -163,7 +163,7 @@ public class GetPaymentMethodQueryTests Arg.Is(options => options.HasExpansions("default_source", "invoice_settings.default_payment_method"))).Returns(customer); - _setupIntentCache.Get(organization.Id).Returns("seti_123"); + _setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123"); _stripeAdapter .SetupIntentGet("seti_123", @@ -177,7 +177,10 @@ public class GetPaymentMethodQueryTests }, NextAction = new SetupIntentNextAction { - VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() + VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits + { + HostedVerificationUrl = "https://example.com" + } }, Status = "requires_action" }); @@ -189,7 +192,7 @@ public class GetPaymentMethodQueryTests var maskedBankAccount = maskedPaymentMethod.AsT0; Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("9999", maskedBankAccount.Last4); - Assert.False(maskedBankAccount.Verified); + Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl); } [Fact] diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index 600f9d9be2..de8c6aae19 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -670,7 +670,7 @@ public class SubscriberServiceTests } }; - sutProvider.GetDependency().Get(provider.Id).Returns(setupIntent.Id); + sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntent.Id); sutProvider.GetDependency().SetupIntentGet(setupIntent.Id, Arg.Is(options => options.Expand.Contains("payment_method"))).Returns(setupIntent); @@ -1876,7 +1876,7 @@ public class SubscriberServiceTests PaymentMethodId = "payment_method_id" }; - sutProvider.GetDependency().Get(provider.Id).Returns(setupIntent.Id); + sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntent.Id); var stripeAdapter = sutProvider.GetDependency(); diff --git a/test/Core.Test/Platform/Push/Engines/AzureQueuePushEngineTests.cs b/test/Core.Test/Platform/Push/Engines/AzureQueuePushEngineTests.cs index 961d7cd770..9c46211517 100644 --- a/test/Core.Test/Platform/Push/Engines/AzureQueuePushEngineTests.cs +++ b/test/Core.Test/Platform/Push/Engines/AzureQueuePushEngineTests.cs @@ -651,31 +651,6 @@ public class AzureQueuePushEngineTests ); } - [Fact] - public async Task PushSyncOrganizationStatusAsync_SendsExpectedResponse() - { - var organization = new Organization - { - Id = Guid.NewGuid(), - Enabled = true, - }; - - var expectedPayload = new JsonObject - { - ["Type"] = 18, - ["Payload"] = new JsonObject - { - ["OrganizationId"] = organization.Id, - ["Enabled"] = organization.Enabled, - }, - }; - - await VerifyNotificationAsync( - async sut => await sut.PushSyncOrganizationStatusAsync(organization), - expectedPayload - ); - } - [Fact] public async Task PushSyncOrganizationCollectionManagementSettingsAsync_SendsExpectedResponse() { diff --git a/test/Core.Test/Platform/Push/Engines/PushTestBase.cs b/test/Core.Test/Platform/Push/Engines/PushTestBase.cs index 9097028370..e0eeeda97d 100644 --- a/test/Core.Test/Platform/Push/Engines/PushTestBase.cs +++ b/test/Core.Test/Platform/Push/Engines/PushTestBase.cs @@ -413,21 +413,6 @@ public abstract class PushTestBase ); } - [Fact] - public async Task PushSyncOrganizationStatusAsync_SendsExpectedResponse() - { - var organization = new Organization - { - Id = Guid.NewGuid(), - Enabled = true, - }; - - await VerifyNotificationAsync( - async sut => await sut.PushSyncOrganizationStatusAsync(organization), - GetPushSyncOrganizationStatusResponsePayload(organization) - ); - } - [Fact] public async Task PushSyncOrganizationCollectionManagementSettingsAsync_SendsExpectedResponse() {