From 68b421fa2b61047031b7474fb584dfc4edc6db51 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 11 Sep 2024 09:04:15 -0400 Subject: [PATCH] [PM-11728] Upgrade free organizations without Stripe Sources API (#4757) * Refactor: Update metadata in OrganizationSignup and OrganizationUpgrade This commit moves the IsFromSecretsManagerTrial flag from the OrganizationUpgrade to the OrganizationSignup because it will only be passed in on organization creation. Additionally, it removes the nullable boolean 'provider' flag passed to OrganizationService.SignUpAsync and instead adds that boolean flag to the OrganizationSignup which seems more appropriate. * Introduce OrganizationSale While I'm trying to ingrain a singular model that can be used to purchase or upgrade organizations, I disliked my previously implemented OrganizationSubscriptionPurchase for being a little too wordy and specific. This sale class aligns more closely with the work we need to complete against Stripe and also uses a private constructor so that it can only be created and utilized via an Organiztion and either OrganizationSignup or OrganizationUpgrade object. * Use OrganizationSale in OrganizationBillingService This commit renames the OrganizationBillingService.PurchaseSubscription to Finalize and passes it the OrganizationSale object. It also updates the method so that, if the organization already has a customer, it retrieves that customer instead of automatically trying to create one which we'll need for upgraded free organizations. * Add functionality for free organization upgrade This commit adds an UpdatePaymentMethod to the OrganizationBillingService that will check if a customer exists for the organization and if not, creates one with the updated payment source and tax information. Then, in the UpgradeOrganizationPlanCommand, we can use the OrganizationUpgrade to get an OrganizationSale and finalize it, which will create a subscription using the customer created as part of the payment method update that takes place right before it on the client-side. Additionally, it adds some tax ID backfill logic to SubscriberService.UpdateTaxInformation * (No Logic) Re-order OrganizationBillingService methods alphabetically * (No Logic) Run dotnet format --- .../AdminConsole/Services/ProviderService.cs | 2 +- .../Services/ProviderServiceTests.cs | 4 +- .../ProviderOrganizationsController.cs | 1 + .../OrganizationBillingController.cs | 4 +- .../Controllers/ProviderClientsController.cs | 3 +- .../Services/IOrganizationService.cs | 2 +- .../Implementations/OrganizationService.cs | 19 +- .../OrganizationSubscriptionPurchase.cs | 27 --- .../Billing/Models/Sales/CustomerSetup.cs | 10 + .../Billing/Models/Sales/OrganizationSale.cs | 104 ++++++++++ .../Billing/Models/Sales/SubscriptionSetup.cs | 25 +++ src/Core/Billing/Models/StaticStore/Plan.cs | 3 + src/Core/Billing/Models/TaxInformation.cs | 23 ++- .../Services/IOrganizationBillingService.cs | 35 +++- .../OrganizationBillingService.cs | 190 ++++++++---------- .../Implementations/SubscriberService.cs | 20 +- .../Models/Business/OrganizationSignup.cs | 43 +--- .../Models/Business/OrganizationUpgrade.cs | 1 - .../UpgradeOrganizationPlanCommand.cs | 24 ++- .../Services/OrganizationServiceTests.cs | 3 +- 20 files changed, 340 insertions(+), 203 deletions(-) delete mode 100644 src/Core/Billing/Models/OrganizationSubscriptionPurchase.cs create mode 100644 src/Core/Billing/Models/Sales/CustomerSetup.cs create mode 100644 src/Core/Billing/Models/Sales/OrganizationSale.cs create mode 100644 src/Core/Billing/Models/Sales/SubscriptionSetup.cs diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index dd97aaca0..b6773f0bd 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -551,7 +551,7 @@ public class ProviderService : IProviderService var (organization, _, defaultCollection) = consolidatedBillingEnabled ? await _organizationService.SignupClientAsync(organizationSignup) - : await _organizationService.SignUpAsync(organizationSignup, true); + : await _organizationService.SignUpAsync(organizationSignup); var providerOrganization = new ProviderOrganization { diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index 4beda0060..4aac363b9 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -667,7 +667,7 @@ public class ProviderServiceTests sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); var providerOrganizationRepository = sutProvider.GetDependency(); - sutProvider.GetDependency().SignUpAsync(organizationSignup, true) + sutProvider.GetDependency().SignUpAsync(organizationSignup) .Returns((organization, null as OrganizationUser, new Collection())); var providerOrganization = @@ -775,7 +775,7 @@ public class ProviderServiceTests sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); var providerOrganizationRepository = sutProvider.GetDependency(); - sutProvider.GetDependency().SignUpAsync(organizationSignup, true) + sutProvider.GetDependency().SignUpAsync(organizationSignup) .Returns((organization, null as OrganizationUser, defaultCollection)); var providerOrganization = diff --git a/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs b/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs index 7cdab7348..12166c836 100644 --- a/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs @@ -84,6 +84,7 @@ public class ProviderOrganizationsController : Controller } var organizationSignup = model.OrganizationCreateRequest.ToOrganizationSignup(user); + organizationSignup.IsFromProvider = true; var result = await _providerService.CreateOrganizationAsync(providerId, organizationSignup, model.ClientOwnerEmail, user); return new ProviderOrganizationResponseModel(result); } diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index b1c7feb56..8d926ec9f 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -182,11 +182,9 @@ public class OrganizationBillingController( var tokenizedPaymentSource = requestBody.PaymentSource.ToDomain(); - await subscriberService.UpdatePaymentSource(organization, tokenizedPaymentSource); - var taxInformation = requestBody.TaxInformation.ToDomain(); - await subscriberService.UpdateTaxInformation(organization, taxInformation); + await organizationBillingService.UpdatePaymentMethod(organization, tokenizedPaymentSource, taxInformation); return TypedResults.Ok(); } diff --git a/src/Api/Billing/Controllers/ProviderClientsController.cs b/src/Api/Billing/Controllers/ProviderClientsController.cs index d69499976..23a6da459 100644 --- a/src/Api/Billing/Controllers/ProviderClientsController.cs +++ b/src/Api/Billing/Controllers/ProviderClientsController.cs @@ -52,7 +52,8 @@ public class ProviderClientsController( OwnerKey = requestBody.Key, PublicKey = requestBody.KeyPair.PublicKey, PrivateKey = requestBody.KeyPair.EncryptedPrivateKey, - CollectionName = requestBody.CollectionName + CollectionName = requestBody.CollectionName, + IsFromProvider = true }; var providerOrganization = await providerService.CreateOrganizationAsync( diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 0780afb33..aaa2f86c8 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -25,7 +25,7 @@ public interface IOrganizationService /// /// A tuple containing the new organization, the initial organizationUser (if any) and the default collection (if any) #nullable enable - Task<(Organization organization, OrganizationUser? organizationUser, Collection? defaultCollection)> SignUpAsync(OrganizationSignup organizationSignup, bool provider = false); + Task<(Organization organization, OrganizationUser? organizationUser, Collection? defaultCollection)> SignUpAsync(OrganizationSignup organizationSignup); Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignupClientAsync(OrganizationSignup signup); #nullable disable diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index e73217b7f..3bf69cc07 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -15,6 +15,7 @@ using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Entities; @@ -502,8 +503,7 @@ public class OrganizationService : IOrganizationService /// /// Create a new organization in a cloud environment /// - public async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignUpAsync(OrganizationSignup signup, - bool provider = false) + public async Task<(Organization organization, OrganizationUser organizationUser, Collection defaultCollection)> SignUpAsync(OrganizationSignup signup) { var plan = StaticStore.GetPlan(signup.Plan); @@ -511,7 +511,7 @@ public class OrganizationService : IOrganizationService if (signup.UseSecretsManager) { - if (provider) + if (signup.IsFromProvider) { throw new BadRequestException( "Organizations with a Managed Service Provider do not support Secrets Manager."); @@ -519,7 +519,7 @@ public class OrganizationService : IOrganizationService ValidateSecretsManagerPlan(plan, signup); } - if (!provider) + if (!signup.IsFromProvider) { await ValidateSignUpPoliciesAsync(signup.Owner.Id); } @@ -570,7 +570,7 @@ public class OrganizationService : IOrganizationService signup.AdditionalServiceAccounts.GetValueOrDefault(); } - if (plan.Type == PlanType.Free && !provider) + if (plan.Type == PlanType.Free && !signup.IsFromProvider) { var adminCount = await _organizationUserRepository.GetCountByFreeOrganizationAdminUserAsync(signup.Owner.Id); @@ -585,20 +585,19 @@ public class OrganizationService : IOrganizationService if (deprecateStripeSourcesAPI) { - var subscriptionPurchase = signup.ToSubscriptionPurchase(provider); - - await _organizationBillingService.PurchaseSubscription(organization, subscriptionPurchase); + var sale = OrganizationSale.From(organization, signup); + await _organizationBillingService.Finalize(sale); } else { await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value, signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats, - signup.PremiumAccessAddon, signup.TaxInfo, provider, signup.AdditionalSmSeats.GetValueOrDefault(), + signup.PremiumAccessAddon, signup.TaxInfo, signup.IsFromProvider, signup.AdditionalSmSeats.GetValueOrDefault(), signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial); } } - var ownerId = provider ? default : signup.Owner.Id; + var ownerId = signup.IsFromProvider ? default : signup.Owner.Id; var returnValue = await SignUpAsync(organization, ownerId, signup.OwnerKey, signup.CollectionName, true); await _referenceEventService.RaiseEventAsync( new ReferenceEvent(ReferenceEventType.Signup, organization, _currentContext) diff --git a/src/Core/Billing/Models/OrganizationSubscriptionPurchase.cs b/src/Core/Billing/Models/OrganizationSubscriptionPurchase.cs deleted file mode 100644 index 8a97b9dd5..000000000 --- a/src/Core/Billing/Models/OrganizationSubscriptionPurchase.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Bit.Core.Billing.Enums; - -namespace Bit.Core.Billing.Models; - -public record OrganizationSubscriptionPurchase( - OrganizationSubscriptionPurchaseMetadata Metadata, - OrganizationPasswordManagerSubscriptionPurchase PasswordManagerSubscription, - TokenizedPaymentSource PaymentSource, - PlanType PlanType, - OrganizationSecretsManagerSubscriptionPurchase SecretsManagerSubscription, - TaxInformation TaxInformation); - -public record OrganizationPasswordManagerSubscriptionPurchase( - int Storage, - bool PremiumAccess, - int Seats); - -public record OrganizationSecretsManagerSubscriptionPurchase( - int Seats, - int ServiceAccounts); - -public record OrganizationSubscriptionPurchaseMetadata( - bool FromProvider, - bool FromSecretsManagerStandalone) -{ - public static OrganizationSubscriptionPurchaseMetadata Default => new(false, false); -} diff --git a/src/Core/Billing/Models/Sales/CustomerSetup.cs b/src/Core/Billing/Models/Sales/CustomerSetup.cs new file mode 100644 index 000000000..47fd5621d --- /dev/null +++ b/src/Core/Billing/Models/Sales/CustomerSetup.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.Billing.Models.Sales; + +#nullable enable + +public class CustomerSetup +{ + public required TokenizedPaymentSource TokenizedPaymentSource { get; set; } + public required TaxInformation TaxInformation { get; set; } + public string? Coupon { get; set; } +} diff --git a/src/Core/Billing/Models/Sales/OrganizationSale.cs b/src/Core/Billing/Models/Sales/OrganizationSale.cs new file mode 100644 index 000000000..4d471a84d --- /dev/null +++ b/src/Core/Billing/Models/Sales/OrganizationSale.cs @@ -0,0 +1,104 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Constants; +using Bit.Core.Models.Business; + +namespace Bit.Core.Billing.Models.Sales; + +#nullable enable + +public class OrganizationSale +{ + private OrganizationSale() { } + + public void Deconstruct( + out Organization organization, + out CustomerSetup? customerSetup, + out SubscriptionSetup subscriptionSetup) + { + organization = Organization; + customerSetup = CustomerSetup; + subscriptionSetup = SubscriptionSetup; + } + + public required Organization Organization { get; init; } + public CustomerSetup? CustomerSetup { get; init; } + public required SubscriptionSetup SubscriptionSetup { get; init; } + + public static OrganizationSale From( + Organization organization, + OrganizationSignup signup) => new() + { + Organization = organization, + CustomerSetup = string.IsNullOrEmpty(organization.GatewayCustomerId) ? GetCustomerSetup(signup) : null, + SubscriptionSetup = GetSubscriptionSetup(signup) + }; + + public static OrganizationSale From( + Organization organization, + OrganizationUpgrade upgrade) => new() + { + Organization = organization, + SubscriptionSetup = GetSubscriptionSetup(upgrade) + }; + + private static CustomerSetup? GetCustomerSetup(OrganizationSignup signup) + { + if (!signup.PaymentMethodType.HasValue) + { + return null; + } + + var tokenizedPaymentSource = new TokenizedPaymentSource( + signup.PaymentMethodType!.Value, + signup.PaymentToken); + + var taxInformation = new TaxInformation( + signup.TaxInfo.BillingAddressCountry, + signup.TaxInfo.BillingAddressPostalCode, + signup.TaxInfo.TaxIdNumber, + signup.TaxInfo.BillingAddressLine1, + signup.TaxInfo.BillingAddressLine2, + 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 + }; + } + + private static SubscriptionSetup GetSubscriptionSetup(OrganizationUpgrade upgrade) + { + var plan = Core.Utilities.StaticStore.GetPlan(upgrade.Plan); + + var passwordManagerOptions = new SubscriptionSetup.PasswordManager + { + Seats = upgrade.AdditionalSeats, + Storage = upgrade.AdditionalStorageGb, + PremiumAccess = upgrade.PremiumAccessAddon + }; + + var secretsManagerOptions = upgrade.UseSecretsManager + ? new SubscriptionSetup.SecretsManager + { + Seats = upgrade.AdditionalSmSeats ?? 0, + ServiceAccounts = upgrade.AdditionalServiceAccounts + } + : null; + + return new SubscriptionSetup + { + Plan = plan, + PasswordManagerOptions = passwordManagerOptions, + SecretsManagerOptions = secretsManagerOptions + }; + } +} diff --git a/src/Core/Billing/Models/Sales/SubscriptionSetup.cs b/src/Core/Billing/Models/Sales/SubscriptionSetup.cs new file mode 100644 index 000000000..cd87b2bb1 --- /dev/null +++ b/src/Core/Billing/Models/Sales/SubscriptionSetup.cs @@ -0,0 +1,25 @@ +using Bit.Core.Models.StaticStore; + +namespace Bit.Core.Billing.Models.Sales; + +#nullable enable + +public class SubscriptionSetup +{ + public required Plan Plan { get; set; } + public required PasswordManager PasswordManagerOptions { get; set; } + public SecretsManager? SecretsManagerOptions { get; set; } + + public class PasswordManager + { + public required int Seats { get; set; } + public short? Storage { get; set; } + public bool? PremiumAccess { get; set; } + } + + public class SecretsManager + { + public required int Seats { get; set; } + public int? ServiceAccounts { get; set; } + } +} diff --git a/src/Core/Billing/Models/StaticStore/Plan.cs b/src/Core/Billing/Models/StaticStore/Plan.cs index 04488c206..15a618cca 100644 --- a/src/Core/Billing/Models/StaticStore/Plan.cs +++ b/src/Core/Billing/Models/StaticStore/Plan.cs @@ -34,6 +34,9 @@ public abstract record Plan public SecretsManagerPlanFeatures SecretsManager { get; protected init; } public bool SupportsSecretsManager => SecretsManager != null; + public bool HasNonSeatBasedPasswordManagerPlan() => + PasswordManager is { StripePlanId: not null and not "", StripeSeatPlanId: null or "" }; + public record SecretsManagerPlanFeatures { // Service accounts diff --git a/src/Core/Billing/Models/TaxInformation.cs b/src/Core/Billing/Models/TaxInformation.cs index a2e6e187f..7c03b90f6 100644 --- a/src/Core/Billing/Models/TaxInformation.cs +++ b/src/Core/Billing/Models/TaxInformation.cs @@ -1,4 +1,6 @@ -namespace Bit.Core.Billing.Models; +using Stripe; + +namespace Bit.Core.Billing.Models; public record TaxInformation( string Country, @@ -9,6 +11,25 @@ public record TaxInformation( string City, string State) { + public (AddressOptions, List) GetStripeOptions() + { + var address = new AddressOptions + { + Country = Country, + PostalCode = PostalCode, + Line1 = Line1, + Line2 = Line2, + City = City, + State = State + }; + + var customerTaxIdDataOptionsList = !string.IsNullOrEmpty(TaxId) + ? new List { new() { Type = GetTaxIdType(), Value = TaxId } } + : null; + + return (address, customerTaxIdDataOptionsList); + } + public string GetTaxIdType() { if (string.IsNullOrEmpty(Country) || string.IsNullOrEmpty(TaxId)) diff --git a/src/Core/Billing/Services/IOrganizationBillingService.cs b/src/Core/Billing/Services/IOrganizationBillingService.cs index 469d63835..c4d02db7f 100644 --- a/src/Core/Billing/Services/IOrganizationBillingService.cs +++ b/src/Core/Billing/Services/IOrganizationBillingService.cs @@ -1,10 +1,29 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Models.Sales; namespace Bit.Core.Billing.Services; public interface IOrganizationBillingService { + /// + /// Establishes the billing configuration for a Bitwarden using the provided . + /// + /// The method first checks to see if the + /// provided already has a Stripe using the . + /// If it doesn't, the method creates one using the 's . The method then creates a Stripe + /// for the created or existing customer using the provided . + /// + /// + /// The purchase details necessary to establish the Stripe entities responsible for billing the organization. + /// + /// + /// var sale = OrganizationSale.From(organization, organizationSignup); + /// await organizationBillingService.Finalize(sale); + /// + /// + Task Finalize(OrganizationSale sale); + /// /// Retrieve metadata about the organization represented bsy the provided . /// @@ -13,11 +32,15 @@ public interface IOrganizationBillingService Task GetMetadata(Guid organizationId); /// - /// Purchase a subscription for the provided using the provided . - /// If successful, a Stripe and will be created for the organization and the - /// organization will be enabled. + /// Updates the provided 's payment source and tax information. + /// If the does not have a Stripe , this method will create one using the provided + /// and . /// - /// The organization to purchase a subscription for. - /// The purchase information for the organization's subscription. - Task PurchaseSubscription(Organization organization, OrganizationSubscriptionPurchase organizationSubscriptionPurchase); + /// The to update the payment source and tax information for. + /// The tokenized payment source (ex. Credit Card) to attach to the . + /// The 's updated tax information. + Task UpdatePaymentMethod( + Organization organization, + TokenizedPaymentSource tokenizedPaymentSource, + TaxInformation taxInformation); } diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs index af65ce427..9b76a04f1 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs @@ -1,8 +1,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Models.Sales; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -28,6 +28,31 @@ public class OrganizationBillingService( IStripeAdapter stripeAdapter, ISubscriberService subscriberService) : IOrganizationBillingService { + public async Task Finalize(OrganizationSale sale) + { + var (organization, customerSetup, subscriptionSetup) = sale; + + List expand = ["tax"]; + + var customer = customerSetup != null + ? await CreateCustomerAsync(organization, customerSetup, expand) + : await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = expand }); + + var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup); + + if (subscription.Status is StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active) + { + organization.Enabled = true; + organization.ExpirationDate = subscription.CurrentPeriodEnd; + } + + organization.Gateway = GatewayType.Stripe; + organization.GatewayCustomerId = customer.Id; + organization.GatewaySubscriptionId = subscription.Id; + + await organizationRepository.ReplaceAsync(organization); + } + public async Task GetMetadata(Guid organizationId) { var organization = await organizationRepository.GetByIdAsync(organizationId); @@ -54,97 +79,72 @@ public class OrganizationBillingService( return new OrganizationMetadata(isOnSecretsManagerStandalone); } - public async Task PurchaseSubscription( + public async Task UpdatePaymentMethod( Organization organization, - OrganizationSubscriptionPurchase organizationSubscriptionPurchase) + TokenizedPaymentSource tokenizedPaymentSource, + TaxInformation taxInformation) { - ArgumentNullException.ThrowIfNull(organization); - ArgumentNullException.ThrowIfNull(organizationSubscriptionPurchase); + if (string.IsNullOrEmpty(organization.GatewayCustomerId)) + { + var customer = await CreateCustomerAsync(organization, + new CustomerSetup + { + TokenizedPaymentSource = tokenizedPaymentSource, + TaxInformation = taxInformation, + }); - var ( - metadata, - passwordManager, - paymentSource, - planType, - secretsManager, - taxInformation) = organizationSubscriptionPurchase; + organization.Gateway = GatewayType.Stripe; + organization.GatewayCustomerId = customer.Id; - var customer = await CreateCustomerAsync(organization, metadata, paymentSource, taxInformation); - - var subscription = - await CreateSubscriptionAsync(customer, organization.Id, passwordManager, planType, secretsManager); - - organization.Enabled = true; - organization.ExpirationDate = subscription.CurrentPeriodEnd; - organization.Gateway = GatewayType.Stripe; - organization.GatewayCustomerId = customer.Id; - organization.GatewaySubscriptionId = subscription.Id; - - await organizationRepository.ReplaceAsync(organization); + await organizationRepository.ReplaceAsync(organization); + } + else + { + await subscriberService.UpdatePaymentSource(organization, tokenizedPaymentSource); + await subscriberService.UpdateTaxInformation(organization, taxInformation); + } } #region Utilities private async Task CreateCustomerAsync( Organization organization, - OrganizationSubscriptionPurchaseMetadata metadata, - TokenizedPaymentSource paymentSource, - TaxInformation taxInformation) + CustomerSetup customerSetup, + List expand = null) { - if (paymentSource == 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 payment source", + "Cannot create customer for organization ({OrganizationID}) without a valid payment source", organization.Id); throw new BillingException(); } - if (taxInformation is not { Country: not null, PostalCode: not null }) + if (customerSetup.TaxInformation is not { Country: not null and not "", PostalCode: not null and not "" }) { logger.LogError( - "Cannot create customer for organization ({OrganizationID}) without both a country and postal code", + "Cannot create customer for organization ({OrganizationID}) without valid tax information", organization.Id); throw new BillingException(); } - var ( - country, - postalCode, - taxId, - line1, - line2, - city, - state) = taxInformation; - - var address = new AddressOptions - { - Country = country, - PostalCode = postalCode, - City = city, - Line1 = line1, - Line2 = line2, - State = state - }; - - var (fromProvider, fromSecretsManagerStandalone) = metadata ?? OrganizationSubscriptionPurchaseMetadata.Default; - - var coupon = fromProvider - ? StripeConstants.CouponIDs.MSPDiscount35 - : fromSecretsManagerStandalone - ? StripeConstants.CouponIDs.SecretsManagerStandalone - : null; + var (address, taxIdData) = customerSetup.TaxInformation.GetStripeOptions(); var organizationDisplayName = organization.DisplayName(); var customerCreateOptions = new CustomerCreateOptions { Address = address, - Coupon = coupon, + Coupon = customerSetup.Coupon, Description = organization.DisplayBusinessName(), Email = organization.BillingEmail, - Expand = ["tax"], + Expand = expand, InvoiceSettings = new CustomerInvoiceSettingsOptions { CustomFields = [ @@ -164,21 +164,10 @@ public class OrganizationBillingService( { ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately }, - TaxIdData = !string.IsNullOrEmpty(taxId) - ? [new CustomerTaxIdDataOptions { Type = taxInformation.GetTaxIdType(), Value = taxId }] - : null + TaxIdData = taxIdData }; - var (type, token) = paymentSource; - - if (string.IsNullOrEmpty(token)) - { - logger.LogError( - "Cannot create customer for organization ({OrganizationID}) without a payment source token", - organization.Id); - - throw new BillingException(); - } + var (type, token) = customerSetup.TokenizedPaymentSource; var braintreeCustomerId = ""; @@ -270,31 +259,30 @@ public class OrganizationBillingService( } private async Task CreateSubscriptionAsync( - Customer customer, Guid organizationId, - OrganizationPasswordManagerSubscriptionPurchase passwordManager, - PlanType planType, - OrganizationSecretsManagerSubscriptionPurchase secretsManager) + Customer customer, + SubscriptionSetup subscriptionSetup) { - var plan = StaticStore.GetPlan(planType); + var plan = subscriptionSetup.Plan; - if (passwordManager == null) - { - logger.LogError("Cannot create subscription for organization ({OrganizationID}) without password manager purchase information", organizationId); - - throw new BillingException(); - } + var passwordManagerOptions = subscriptionSetup.PasswordManagerOptions; var subscriptionItemOptionsList = new List { - new () - { - Price = plan.PasswordManager.StripeSeatPlanId, - Quantity = passwordManager.Seats - } + plan.HasNonSeatBasedPasswordManagerPlan() + ? new SubscriptionItemOptions + { + Price = plan.PasswordManager.StripePlanId, + Quantity = 1 + } + : new SubscriptionItemOptions + { + Price = plan.PasswordManager.StripeSeatPlanId, + Quantity = passwordManagerOptions.Seats + } }; - if (passwordManager.PremiumAccess) + if (passwordManagerOptions.PremiumAccess is true) { subscriptionItemOptionsList.Add(new SubscriptionItemOptions { @@ -303,29 +291,31 @@ public class OrganizationBillingService( }); } - if (passwordManager.Storage > 0) + if (passwordManagerOptions.Storage is > 0) { subscriptionItemOptionsList.Add(new SubscriptionItemOptions { Price = plan.PasswordManager.StripeStoragePlanId, - Quantity = passwordManager.Storage + Quantity = passwordManagerOptions.Storage }); } - if (secretsManager != null) + var secretsManagerOptions = subscriptionSetup.SecretsManagerOptions; + + if (secretsManagerOptions != null) { subscriptionItemOptionsList.Add(new SubscriptionItemOptions { Price = plan.SecretsManager.StripeSeatPlanId, - Quantity = secretsManager.Seats + Quantity = secretsManagerOptions.Seats }); - if (secretsManager.ServiceAccounts > 0) + if (secretsManagerOptions.ServiceAccounts is > 0) { subscriptionItemOptionsList.Add(new SubscriptionItemOptions { Price = plan.SecretsManager.StripeServiceAccountPlanId, - Quantity = secretsManager.ServiceAccounts + Quantity = secretsManagerOptions.ServiceAccounts }); } } @@ -347,15 +337,7 @@ public class OrganizationBillingService( TrialPeriodDays = plan.TrialPeriodDays, }; - try - { - return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); - } - catch - { - await stripeAdapter.CustomerDeleteAsync(customer.Id); - throw; - } + return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); } private static bool IsOnSecretsManagerStandalone( diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index c44bb3f3b..33eb8e7e8 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -1,4 +1,5 @@ using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; using Bit.Core.Entities; using Bit.Core.Enums; @@ -585,7 +586,7 @@ public class SubscriberService( var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions { - Expand = ["tax_ids"] + Expand = ["subscriptions", "tax", "tax_ids"] }); await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions @@ -622,6 +623,23 @@ public class SubscriberService( }); } } + + if (SubscriberIsEligibleForAutomaticTax(subscriber, customer)) + { + await stripeAdapter.SubscriptionUpdateAsync(subscriber.GatewaySubscriptionId, + new SubscriptionUpdateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }, + DefaultTaxRates = [] + }); + } + + return; + + bool SubscriberIsEligibleForAutomaticTax(ISubscriber localSubscriber, Customer localCustomer) + => !string.IsNullOrEmpty(localSubscriber.GatewaySubscriptionId) && + (localCustomer.Subscriptions?.Any(sub => sub.Id == localSubscriber.GatewaySubscriptionId && !sub.AutomaticTax.Enabled) ?? false) && + localCustomer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported; } public async Task VerifyBankAccount( diff --git a/src/Core/Models/Business/OrganizationSignup.cs b/src/Core/Models/Business/OrganizationSignup.cs index a5155da75..b5ac69e73 100644 --- a/src/Core/Models/Business/OrganizationSignup.cs +++ b/src/Core/Models/Business/OrganizationSignup.cs @@ -1,5 +1,4 @@ -using Bit.Core.Billing.Models; -using Bit.Core.Entities; +using Bit.Core.Entities; using Bit.Core.Enums; namespace Bit.Core.Models.Business; @@ -15,42 +14,6 @@ public class OrganizationSignup : OrganizationUpgrade public string PaymentToken { get; set; } public int? MaxAutoscaleSeats { get; set; } = null; public string InitiationPath { get; set; } - - public OrganizationSubscriptionPurchase ToSubscriptionPurchase(bool fromProvider = false) - { - if (!PaymentMethodType.HasValue) - { - return null; - } - - var metadata = new OrganizationSubscriptionPurchaseMetadata(fromProvider, IsFromSecretsManagerTrial); - - var passwordManager = new OrganizationPasswordManagerSubscriptionPurchase( - AdditionalStorageGb, - PremiumAccessAddon, - AdditionalSeats); - - var paymentSource = new TokenizedPaymentSource(PaymentMethodType.Value, PaymentToken); - - var secretsManager = new OrganizationSecretsManagerSubscriptionPurchase( - AdditionalSmSeats ?? 0, - AdditionalServiceAccounts ?? 0); - - var taxInformation = new TaxInformation( - TaxInfo.BillingAddressCountry, - TaxInfo.BillingAddressPostalCode, - TaxInfo.TaxIdNumber, - TaxInfo.BillingAddressLine1, - TaxInfo.BillingAddressLine2, - TaxInfo.BillingAddressCity, - TaxInfo.BillingAddressState); - - return new OrganizationSubscriptionPurchase( - metadata, - passwordManager, - paymentSource, - Plan, - UseSecretsManager ? secretsManager : null, - taxInformation); - } + public bool IsFromSecretsManagerTrial { get; set; } + public bool IsFromProvider { get; set; } } diff --git a/src/Core/Models/Business/OrganizationUpgrade.cs b/src/Core/Models/Business/OrganizationUpgrade.cs index 4928ecf65..1dd265079 100644 --- a/src/Core/Models/Business/OrganizationUpgrade.cs +++ b/src/Core/Models/Business/OrganizationUpgrade.cs @@ -15,5 +15,4 @@ public class OrganizationUpgrade public int? AdditionalSmSeats { get; set; } public int? AdditionalServiceAccounts { get; set; } public bool UseSecretsManager { get; set; } - public bool IsFromSecretsManagerTrial { get; set; } } diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs index cf234ef60..33dd38833 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs @@ -5,6 +5,8 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Repositories; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -34,6 +36,8 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand private readonly IServiceAccountRepository _serviceAccountRepository; private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationService _organizationService; + private readonly IFeatureService _featureService; + private readonly IOrganizationBillingService _organizationBillingService; public UpgradeOrganizationPlanCommand( IOrganizationUserRepository organizationUserRepository, @@ -47,7 +51,9 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand ICurrentContext currentContext, IServiceAccountRepository serviceAccountRepository, IOrganizationRepository organizationRepository, - IOrganizationService organizationService) + IOrganizationService organizationService, + IFeatureService featureService, + IOrganizationBillingService organizationBillingService) { _organizationUserRepository = organizationUserRepository; _collectionRepository = collectionRepository; @@ -61,6 +67,8 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand _serviceAccountRepository = serviceAccountRepository; _organizationRepository = organizationRepository; _organizationService = organizationService; + _featureService = featureService; + _organizationBillingService = organizationBillingService; } public async Task> UpgradePlanAsync(Guid organizationId, OrganizationUpgrade upgrade) @@ -216,9 +224,17 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) { - paymentIntentClientSecret = await _paymentService.UpgradeFreeOrganizationAsync(organization, - newPlan, upgrade); - success = string.IsNullOrWhiteSpace(paymentIntentClientSecret); + if (_featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI)) + { + var sale = OrganizationSale.From(organization, upgrade); + await _organizationBillingService.Finalize(sale); + } + else + { + paymentIntentClientSecret = await _paymentService.UpgradeFreeOrganizationAsync(organization, + newPlan, upgrade); + success = string.IsNullOrWhiteSpace(paymentIntentClientSecret); + } } else { diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index 5f0038a21..4bef18f55 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -354,8 +354,9 @@ public class OrganizationServiceTests signup.AdditionalServiceAccounts = 20; signup.PaymentMethodType = PaymentMethodType.Card; signup.PremiumAccessAddon = false; + signup.IsFromProvider = true; - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SignUpAsync(signup, true)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SignUpAsync(signup)); Assert.Contains("Organizations with a Managed Service Provider do not support Secrets Manager.", exception.Message); }