diff --git a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs index f33814f1cf..887a6badf5 100644 --- a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs @@ -2,11 +2,11 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Services; @@ -30,8 +30,8 @@ public interface IGetOrganizationWarningsQuery public class GetOrganizationWarningsQuery( ICurrentContext currentContext, + IHasPaymentMethodQuery hasPaymentMethodQuery, IProviderRepository providerRepository, - ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, ISubscriberService subscriberService) : IGetOrganizationWarningsQuery { @@ -81,15 +81,7 @@ public class GetOrganizationWarningsQuery( return null; } - var customer = subscription.Customer; - - var hasUnverifiedBankAccount = await HasUnverifiedBankAccountAsync(organization); - - var hasPaymentMethod = - !string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) || - !string.IsNullOrEmpty(customer.DefaultSourceId) || - hasUnverifiedBankAccount || - customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId); + var hasPaymentMethod = await hasPaymentMethodQuery.Run(organization); if (hasPaymentMethod) { @@ -287,22 +279,4 @@ public class GetOrganizationWarningsQuery( _ => null }; } - - private async Task HasUnverifiedBankAccountAsync( - Organization organization) - { - var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id); - - if (string.IsNullOrEmpty(setupIntentId)) - { - return false; - } - - var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions - { - Expand = ["payment_method"] - }); - - return setupIntent.IsUnverifiedBankAccount(); - } } diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index ce8a9a877b..36a618f799 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -6,6 +6,7 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Models; @@ -27,6 +28,7 @@ namespace Bit.Core.Billing.Organizations.Services; public class OrganizationBillingService( IBraintreeGateway braintreeGateway, IGlobalSettings globalSettings, + IHasPaymentMethodQuery hasPaymentMethodQuery, ILogger logger, IOrganizationRepository organizationRepository, IPricingClient pricingClient, @@ -43,7 +45,7 @@ public class OrganizationBillingService( ? await CreateCustomerAsync(organization, customerSetup, subscriptionSetup.PlanType) : await GetCustomerWhileEnsuringCorrectTaxExemptionAsync(organization, subscriptionSetup); - var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup); + var subscription = await CreateSubscriptionAsync(organization, customer, subscriptionSetup); if (subscription.Status is StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active) { @@ -120,8 +122,7 @@ public class OrganizationBillingService( orgOccupiedSeats.Total); } - public async Task - UpdatePaymentMethod( + public async Task UpdatePaymentMethod( Organization organization, TokenizedPaymentSource tokenizedPaymentSource, TaxInformation taxInformation) @@ -397,7 +398,7 @@ public class OrganizationBillingService( } private async Task CreateSubscriptionAsync( - Guid organizationId, + Organization organization, Customer customer, SubscriptionSetup subscriptionSetup) { @@ -465,7 +466,7 @@ public class OrganizationBillingService( Items = subscriptionItemOptionsList, Metadata = new Dictionary { - ["organizationId"] = organizationId.ToString(), + ["organizationId"] = organization.Id.ToString(), ["trialInitiationPath"] = !string.IsNullOrEmpty(subscriptionSetup.InitiationPath) && subscriptionSetup.InitiationPath.Contains("trial from marketing website") ? "marketing-initiated" @@ -475,9 +476,10 @@ public class OrganizationBillingService( TrialPeriodDays = subscriptionSetup.SkipTrial ? 0 : plan.TrialPeriodDays }; + var hasPaymentMethod = await hasPaymentMethodQuery.Run(organization); + // Only set trial_settings.end_behavior.missing_payment_method to "cancel" if there is no payment method - if (string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) && - !customer.Metadata.ContainsKey(BraintreeCustomerIdKey)) + if (!hasPaymentMethod) { subscriptionCreateOptions.TrialSettings = new SubscriptionTrialSettingsOptions { diff --git a/src/Core/Billing/Payment/Queries/HasPaymentMethodQuery.cs b/src/Core/Billing/Payment/Queries/HasPaymentMethodQuery.cs new file mode 100644 index 0000000000..ec77ee0712 --- /dev/null +++ b/src/Core/Billing/Payment/Queries/HasPaymentMethodQuery.cs @@ -0,0 +1,58 @@ +using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Services; +using Stripe; + +namespace Bit.Core.Billing.Payment.Queries; + +using static StripeConstants; + +public interface IHasPaymentMethodQuery +{ + Task Run(ISubscriber subscriber); +} + +public class HasPaymentMethodQuery( + ISetupIntentCache setupIntentCache, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService) : IHasPaymentMethodQuery +{ + public async Task Run(ISubscriber subscriber) + { + var hasUnverifiedBankAccount = await HasUnverifiedBankAccountAsync(subscriber); + + var customer = await subscriberService.GetCustomer(subscriber); + + if (customer == null) + { + return hasUnverifiedBankAccount; + } + + return + !string.IsNullOrEmpty(customer.InvoiceSettings.DefaultPaymentMethodId) || + !string.IsNullOrEmpty(customer.DefaultSourceId) || + hasUnverifiedBankAccount || + customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId); + } + + private async Task HasUnverifiedBankAccountAsync( + ISubscriber subscriber) + { + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id); + + if (string.IsNullOrEmpty(setupIntentId)) + { + return false; + } + + var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions + { + Expand = ["payment_method"] + }); + + return setupIntent.IsUnverifiedBankAccount(); + } +} diff --git a/src/Core/Billing/Payment/Registrations.cs b/src/Core/Billing/Payment/Registrations.cs index 478673d2fc..89d3778ccd 100644 --- a/src/Core/Billing/Payment/Registrations.cs +++ b/src/Core/Billing/Payment/Registrations.cs @@ -19,5 +19,6 @@ public static class Registrations services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); } } diff --git a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs index 5234d500d1..96f9c1496e 100644 --- a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs +++ b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs @@ -2,10 +2,10 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.Queries; +using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Services; @@ -75,7 +75,7 @@ public class GetOrganizationWarningsQueryTests }); sutProvider.GetDependency().EditSubscription(organization.Id).Returns(true); - sutProvider.GetDependency().GetSetupIntentIdForSubscriber(organization.Id).Returns((string?)null); + sutProvider.GetDependency().Run(organization).Returns(false); var response = await sutProvider.Sut.Run(organization); @@ -86,12 +86,11 @@ public class GetOrganizationWarningsQueryTests } [Theory, BitAutoData] - public async Task Run_Has_FreeTrialWarning_WithUnverifiedBankAccount_NoWarning( + public async Task Run_Has_FreeTrialWarning_WithPaymentMethod_NoWarning( Organization organization, SutProvider sutProvider) { var now = DateTime.UtcNow; - const string setupIntentId = "setup_intent_id"; sutProvider.GetDependency() .GetSubscription(organization, Arg.Is(options => @@ -113,20 +112,7 @@ public class GetOrganizationWarningsQueryTests }); sutProvider.GetDependency().EditSubscription(organization.Id).Returns(true); - sutProvider.GetDependency().GetSetupIntentIdForSubscriber(organization.Id).Returns(setupIntentId); - sutProvider.GetDependency().SetupIntentGet(setupIntentId, Arg.Is( - options => options.Expand.Contains("payment_method"))).Returns(new SetupIntent - { - Status = "requires_action", - NextAction = new SetupIntentNextAction - { - VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() - }, - PaymentMethod = new PaymentMethod - { - UsBankAccount = new PaymentMethodUsBankAccount() - } - }); + sutProvider.GetDependency().Run(organization).Returns(true); var response = await sutProvider.Sut.Run(organization); diff --git a/test/Core.Test/Billing/Payment/Queries/HasPaymentMethodQueryTests.cs b/test/Core.Test/Billing/Payment/Queries/HasPaymentMethodQueryTests.cs new file mode 100644 index 0000000000..c7ab0c17ff --- /dev/null +++ b/test/Core.Test/Billing/Payment/Queries/HasPaymentMethodQueryTests.cs @@ -0,0 +1,264 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Payment.Queries; +using Bit.Core.Billing.Services; +using Bit.Core.Services; +using Bit.Core.Test.Billing.Extensions; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Billing.Payment.Queries; + +using static StripeConstants; + +public class HasPaymentMethodQueryTests +{ + private readonly ISetupIntentCache _setupIntentCache = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly ISubscriberService _subscriberService = Substitute.For(); + private readonly HasPaymentMethodQuery _query; + + public HasPaymentMethodQueryTests() + { + _query = new HasPaymentMethodQuery( + _setupIntentCache, + _stripeAdapter, + _subscriberService); + } + + [Fact] + public async Task Run_NoCustomer_ReturnsFalse() + { + var organization = new Organization + { + Id = Guid.NewGuid() + }; + + _subscriberService.GetCustomer(organization).ReturnsNull(); + _setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns((string)null); + + var hasPaymentMethod = await _query.Run(organization); + + Assert.False(hasPaymentMethod); + } + + [Fact] + public async Task Run_NoCustomer_WithUnverifiedBankAccount_ReturnsTrue() + { + var organization = new Organization + { + Id = Guid.NewGuid() + }; + + _subscriberService.GetCustomer(organization).ReturnsNull(); + _setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123"); + + _stripeAdapter + .SetupIntentGet("seti_123", + Arg.Is(options => options.HasExpansions("payment_method"))) + .Returns(new SetupIntent + { + Status = "requires_action", + NextAction = new SetupIntentNextAction + { + VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() + }, + PaymentMethod = new PaymentMethod + { + UsBankAccount = new PaymentMethodUsBankAccount() + } + }); + + var hasPaymentMethod = await _query.Run(organization); + + Assert.True(hasPaymentMethod); + } + + [Fact] + public async Task Run_NoPaymentMethod_ReturnsFalse() + { + var organization = new Organization + { + Id = Guid.NewGuid() + }; + + var customer = new Customer + { + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + }; + + _subscriberService.GetCustomer(organization).Returns(customer); + + var hasPaymentMethod = await _query.Run(organization); + + Assert.False(hasPaymentMethod); + } + + [Fact] + public async Task Run_HasDefaultPaymentMethodId_ReturnsTrue() + { + var organization = new Organization + { + Id = Guid.NewGuid() + }; + + var customer = new Customer + { + InvoiceSettings = new CustomerInvoiceSettings + { + DefaultPaymentMethodId = "pm_123" + }, + Metadata = new Dictionary() + }; + + _subscriberService.GetCustomer(organization).Returns(customer); + + var hasPaymentMethod = await _query.Run(organization); + + Assert.True(hasPaymentMethod); + } + + [Fact] + public async Task Run_HasDefaultSourceId_ReturnsTrue() + { + var organization = new Organization + { + Id = Guid.NewGuid() + }; + + var customer = new Customer + { + DefaultSourceId = "card_123", + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + }; + + _subscriberService.GetCustomer(organization).Returns(customer); + + var hasPaymentMethod = await _query.Run(organization); + + Assert.True(hasPaymentMethod); + } + + [Fact] + public async Task Run_HasUnverifiedBankAccount_ReturnsTrue() + { + var organization = new Organization + { + Id = Guid.NewGuid() + }; + + var customer = new Customer + { + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + }; + + _subscriberService.GetCustomer(organization).Returns(customer); + _setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123"); + + _stripeAdapter + .SetupIntentGet("seti_123", + Arg.Is(options => options.HasExpansions("payment_method"))) + .Returns(new SetupIntent + { + Status = "requires_action", + NextAction = new SetupIntentNextAction + { + VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() + }, + PaymentMethod = new PaymentMethod + { + UsBankAccount = new PaymentMethodUsBankAccount() + } + }); + + var hasPaymentMethod = await _query.Run(organization); + + Assert.True(hasPaymentMethod); + } + + [Fact] + public async Task Run_HasBraintreeCustomerId_ReturnsTrue() + { + var organization = new Organization + { + Id = Guid.NewGuid() + }; + + var customer = new Customer + { + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary + { + [MetadataKeys.BraintreeCustomerId] = "braintree_customer_id" + } + }; + + _subscriberService.GetCustomer(organization).Returns(customer); + + var hasPaymentMethod = await _query.Run(organization); + + Assert.True(hasPaymentMethod); + } + + [Fact] + public async Task Run_NoSetupIntentId_ReturnsFalse() + { + var organization = new Organization + { + Id = Guid.NewGuid() + }; + + var customer = new Customer + { + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + }; + + _subscriberService.GetCustomer(organization).Returns(customer); + _setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns((string)null); + + var hasPaymentMethod = await _query.Run(organization); + + Assert.False(hasPaymentMethod); + } + + [Fact] + public async Task Run_SetupIntentNotBankAccount_ReturnsFalse() + { + var organization = new Organization + { + Id = Guid.NewGuid() + }; + + var customer = new Customer + { + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + }; + + _subscriberService.GetCustomer(organization).Returns(customer); + _setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123"); + + _stripeAdapter + .SetupIntentGet("seti_123", + Arg.Is(options => options.HasExpansions("payment_method"))) + .Returns(new SetupIntent + { + PaymentMethod = new PaymentMethod + { + Type = "card" + }, + Status = "succeeded" + }); + + var hasPaymentMethod = await _query.Run(organization); + + Assert.False(hasPaymentMethod); + } +}