From 2e072aebe3a1354965b0018de620703133fec501 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 25 Sep 2024 08:55:45 -0400 Subject: [PATCH] [PM-8445] Allow for organization sales with no payment method for trials (#4800) * Allow for OrganizationSales with no payment method * Run dotnet format --- .../Billing/Models/Sales/CustomerSetup.cs | 6 +- .../Billing/Models/Sales/OrganizationSale.cs | 30 ++- .../Services/IOrganizationBillingService.cs | 4 +- .../OrganizationBillingService.cs | 173 +++++++++--------- 4 files changed, 112 insertions(+), 101 deletions(-) diff --git a/src/Core/Billing/Models/Sales/CustomerSetup.cs b/src/Core/Billing/Models/Sales/CustomerSetup.cs index 47fd5621d..bb4f2352e 100644 --- a/src/Core/Billing/Models/Sales/CustomerSetup.cs +++ b/src/Core/Billing/Models/Sales/CustomerSetup.cs @@ -4,7 +4,9 @@ public class CustomerSetup { - public required TokenizedPaymentSource TokenizedPaymentSource { get; set; } - public required TaxInformation TaxInformation { get; set; } + public TokenizedPaymentSource? TokenizedPaymentSource { get; set; } + public TaxInformation? TaxInformation { get; set; } public string? Coupon { get; set; } + + public bool IsBillable => TokenizedPaymentSource != null && TaxInformation != null; } diff --git a/src/Core/Billing/Models/Sales/OrganizationSale.cs b/src/Core/Billing/Models/Sales/OrganizationSale.cs index 4d471a84d..a19c278c6 100644 --- a/src/Core/Billing/Models/Sales/OrganizationSale.cs +++ b/src/Core/Billing/Models/Sales/OrganizationSale.cs @@ -41,18 +41,27 @@ public class OrganizationSale SubscriptionSetup = GetSubscriptionSetup(upgrade) }; - private static CustomerSetup? GetCustomerSetup(OrganizationSignup signup) + private static CustomerSetup GetCustomerSetup(OrganizationSignup signup) { + var customerSetup = new CustomerSetup + { + Coupon = signup.IsFromProvider + ? StripeConstants.CouponIDs.MSPDiscount35 + : signup.IsFromSecretsManagerTrial + ? StripeConstants.CouponIDs.SecretsManagerStandalone + : null + }; + if (!signup.PaymentMethodType.HasValue) { - return null; + return customerSetup; } - var tokenizedPaymentSource = new TokenizedPaymentSource( + customerSetup.TokenizedPaymentSource = new TokenizedPaymentSource( signup.PaymentMethodType!.Value, signup.PaymentToken); - var taxInformation = new TaxInformation( + customerSetup.TaxInformation = new TaxInformation( signup.TaxInfo.BillingAddressCountry, signup.TaxInfo.BillingAddressPostalCode, signup.TaxInfo.TaxIdNumber, @@ -61,18 +70,7 @@ public class OrganizationSale signup.TaxInfo.BillingAddressCity, signup.TaxInfo.BillingAddressState); - var coupon = signup.IsFromProvider - ? StripeConstants.CouponIDs.MSPDiscount35 - : signup.IsFromSecretsManagerTrial - ? StripeConstants.CouponIDs.SecretsManagerStandalone - : null; - - return new CustomerSetup - { - TokenizedPaymentSource = tokenizedPaymentSource, - TaxInformation = taxInformation, - Coupon = coupon - }; + return customerSetup; } private static SubscriptionSetup GetSubscriptionSetup(OrganizationUpgrade upgrade) diff --git a/src/Core/Billing/Services/IOrganizationBillingService.cs b/src/Core/Billing/Services/IOrganizationBillingService.cs index c4d02db7f..907860d96 100644 --- a/src/Core/Billing/Services/IOrganizationBillingService.cs +++ b/src/Core/Billing/Services/IOrganizationBillingService.cs @@ -4,6 +4,8 @@ using Bit.Core.Billing.Models.Sales; namespace Bit.Core.Billing.Services; +#nullable enable + public interface IOrganizationBillingService { /// @@ -29,7 +31,7 @@ public interface IOrganizationBillingService /// /// The ID of the organization to retrieve metadata for. /// An record. - Task GetMetadata(Guid organizationId); + Task GetMetadata(Guid organizationId); /// /// Updates the provided 's payment source and tax information. diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs index 9b76a04f1..0880c3678 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs @@ -19,6 +19,8 @@ using Subscription = Stripe.Subscription; namespace Bit.Core.Billing.Services.Implementations; +#nullable enable + public class OrganizationBillingService( IBraintreeGateway braintreeGateway, IGlobalSettings globalSettings, @@ -53,7 +55,7 @@ public class OrganizationBillingService( await organizationRepository.ReplaceAsync(organization); } - public async Task GetMetadata(Guid organizationId) + public async Task GetMetadata(Guid organizationId) { var organization = await organizationRepository.GetByIdAsync(organizationId); @@ -90,7 +92,7 @@ public class OrganizationBillingService( new CustomerSetup { TokenizedPaymentSource = tokenizedPaymentSource, - TaxInformation = taxInformation, + TaxInformation = taxInformation }); organization.Gateway = GatewayType.Stripe; @@ -110,37 +112,12 @@ public class OrganizationBillingService( private async Task CreateCustomerAsync( Organization organization, CustomerSetup customerSetup, - List expand = null) + List? expand = null) { - if (customerSetup.TokenizedPaymentSource is not - { - Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal, - Token: not null and not "" - }) - { - logger.LogError( - "Cannot create customer for organization ({OrganizationID}) without a valid payment source", - organization.Id); - - throw new BillingException(); - } - - if (customerSetup.TaxInformation is not { Country: not null and not "", PostalCode: not null and not "" }) - { - logger.LogError( - "Cannot create customer for organization ({OrganizationID}) without valid tax information", - organization.Id); - - throw new BillingException(); - } - - var (address, taxIdData) = customerSetup.TaxInformation.GetStripeOptions(); - var organizationDisplayName = organization.DisplayName(); var customerCreateOptions = new CustomerCreateOptions { - Address = address, Coupon = customerSetup.Coupon, Description = organization.DisplayBusinessName(), Email = organization.BillingEmail, @@ -159,58 +136,87 @@ public class OrganizationBillingService( Metadata = new Dictionary { { "region", globalSettings.BaseServiceUri.CloudRegion } - }, - Tax = new CustomerTaxOptions - { - ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately - }, - TaxIdData = taxIdData + } }; - var (type, token) = customerSetup.TokenizedPaymentSource; - var braintreeCustomerId = ""; - // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault - switch (type) + if (customerSetup.IsBillable) { - case PaymentMethodType.BankAccount: + if (customerSetup.TokenizedPaymentSource is not { - var setupIntent = - (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token })) - .FirstOrDefault(); + Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal, + Token: not null and not "" + }) + { + logger.LogError( + "Cannot create customer for organization ({OrganizationID}) without a valid payment source", + organization.Id); - if (setupIntent == null) + throw new BillingException(); + } + + if (customerSetup.TaxInformation is not { Country: not null and not "", PostalCode: not null and not "" }) + { + logger.LogError( + "Cannot create customer for organization ({OrganizationID}) without valid tax information", + organization.Id); + + throw new BillingException(); + } + + var (address, taxIdData) = customerSetup.TaxInformation.GetStripeOptions(); + + customerCreateOptions.Address = address; + customerCreateOptions.Tax = new CustomerTaxOptions + { + ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately + }; + customerCreateOptions.TaxIdData = taxIdData; + + var (type, token) = customerSetup.TokenizedPaymentSource; + + // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault + switch (type) + { + case PaymentMethodType.BankAccount: { - logger.LogError("Cannot create customer for organization ({OrganizationID}) without a setup intent for their bank account", organization.Id); + var setupIntent = + (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token })) + .FirstOrDefault(); + + if (setupIntent == null) + { + logger.LogError("Cannot create customer for organization ({OrganizationID}) without a setup intent for their bank account", organization.Id); + + throw new BillingException(); + } + + await setupIntentCache.Set(organization.Id, setupIntent.Id); + + break; + } + case PaymentMethodType.Card: + { + customerCreateOptions.PaymentMethod = token; + customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = token; + break; + } + case PaymentMethodType.PayPal: + { + braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(organization, token); + + customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId; + + break; + } + default: + { + logger.LogError("Cannot create customer for organization ({OrganizationID}) using payment method type ({PaymentMethodType}) as it is not supported", organization.Id, type.ToString()); throw new BillingException(); } - - await setupIntentCache.Set(organization.Id, setupIntent.Id); - - break; - } - case PaymentMethodType.Card: - { - customerCreateOptions.PaymentMethod = token; - customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = token; - break; - } - case PaymentMethodType.PayPal: - { - braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(organization, token); - - customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId; - - break; - } - default: - { - logger.LogError("Cannot create customer for organization ({OrganizationID}) using payment method type ({PaymentMethodType}) as it is not supported", organization.Id, type.ToString()); - - throw new BillingException(); - } + } } try @@ -241,19 +247,22 @@ public class OrganizationBillingService( async Task Revert() { - // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault - switch (type) + if (customerSetup.IsBillable) { - case PaymentMethodType.BankAccount: - { - await setupIntentCache.Remove(organization.Id); - break; - } - case PaymentMethodType.PayPal: - { - await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); - break; - } + // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault + switch (customerSetup.TokenizedPaymentSource!.Type) + { + case PaymentMethodType.BankAccount: + { + await setupIntentCache.Remove(organization.Id); + break; + } + case PaymentMethodType.PayPal: + { + await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); + break; + } + } } } } @@ -334,7 +343,7 @@ public class OrganizationBillingService( ["organizationId"] = organizationId.ToString() }, OffSession = true, - TrialPeriodDays = plan.TrialPeriodDays, + TrialPeriodDays = plan.TrialPeriodDays }; return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);