diff --git a/bitwarden_license/src/Scim/Startup.cs b/bitwarden_license/src/Scim/Startup.cs index e70b6a288..388ba5adc 100644 --- a/bitwarden_license/src/Scim/Startup.cs +++ b/bitwarden_license/src/Scim/Startup.cs @@ -1,4 +1,5 @@ using System.Globalization; +using Bit.Core.Billing.Extensions; using Bit.Core.Context; using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories.Noop; @@ -68,6 +69,7 @@ public class Startup // Services services.AddBaseServices(globalSettings); services.AddDefaultServices(globalSettings); + services.AddBillingOperations(); services.TryAddSingleton(); diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index 369f76a93..acb0f120e 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -1,6 +1,7 @@ using System.Globalization; using Bit.Billing.Services; using Bit.Billing.Services.Implementations; +using Bit.Core.Billing.Extensions; using Bit.Core.Context; using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories.Noop; @@ -74,6 +75,7 @@ public class Startup // Services services.AddBaseServices(globalSettings); services.AddDefaultServices(globalSettings); + services.AddBillingOperations(); services.TryAddSingleton(); diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 8c8dafa5e..e73217b7f 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.Services; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -70,6 +71,7 @@ public class OrganizationService : IOrganizationService private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IFeatureService _featureService; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; + private readonly IOrganizationBillingService _organizationBillingService; public OrganizationService( IOrganizationRepository organizationRepository, @@ -103,7 +105,8 @@ public class OrganizationService : IOrganizationService IDataProtectorTokenFactory orgDeleteTokenDataFactory, IProviderRepository providerRepository, IFeatureService featureService, - ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery) + ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, + IOrganizationBillingService organizationBillingService) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -137,6 +140,7 @@ public class OrganizationService : IOrganizationService _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _featureService = featureService; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; + _organizationBillingService = organizationBillingService; } public async Task ReplacePaymentMethodAsync(Guid organizationId, string paymentToken, @@ -577,10 +581,21 @@ public class OrganizationService : IOrganizationService } else if (plan.Type != PlanType.Free) { - await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value, - signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats, - signup.PremiumAccessAddon, signup.TaxInfo, provider, signup.AdditionalSmSeats.GetValueOrDefault(), - signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial); + var deprecateStripeSourcesAPI = _featureService.IsEnabled(FeatureFlagKeys.AC2476_DeprecateStripeSourcesAPI); + + if (deprecateStripeSourcesAPI) + { + var subscriptionPurchase = signup.ToSubscriptionPurchase(provider); + + await _organizationBillingService.PurchaseSubscription(organization, subscriptionPurchase); + } + else + { + await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value, + signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats, + signup.PremiumAccessAddon, signup.TaxInfo, provider, signup.AdditionalSmSeats.GetValueOrDefault(), + signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial); + } } var ownerId = provider ? default : signup.Owner.Id; diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 53e9baf06..9279a0854 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -18,6 +18,7 @@ public static class StripeConstants public static class CouponIDs { + public const string MSPDiscount35 = "msp-discount-35"; public const string SecretsManagerStandalone = "sm-standalone"; } @@ -51,4 +52,10 @@ public static class StripeConstants public const string Unpaid = "unpaid"; public const string Paused = "paused"; } + + public static class ValidateTaxLocationTiming + { + public const string Deferred = "deferred"; + public const string Immediately = "immediately"; + } } diff --git a/src/Core/Billing/Models/OrganizationSubscriptionPurchase.cs b/src/Core/Billing/Models/OrganizationSubscriptionPurchase.cs new file mode 100644 index 000000000..8a97b9dd5 --- /dev/null +++ b/src/Core/Billing/Models/OrganizationSubscriptionPurchase.cs @@ -0,0 +1,27 @@ +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/Services/IOrganizationBillingService.cs b/src/Core/Billing/Services/IOrganizationBillingService.cs index a4b522e2f..469d63835 100644 --- a/src/Core/Billing/Services/IOrganizationBillingService.cs +++ b/src/Core/Billing/Services/IOrganizationBillingService.cs @@ -1,8 +1,23 @@ -using Bit.Core.Billing.Models; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Models; namespace Bit.Core.Billing.Services; public interface IOrganizationBillingService { + /// + /// Retrieve metadata about the organization represented bsy the provided . + /// + /// The ID of the organization to retrieve metadata for. + /// An record. 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. + /// + /// The organization to purchase a subscription for. + /// The purchase information for the organization's subscription. + Task PurchaseSubscription(Organization organization, OrganizationSubscriptionPurchase organizationSubscriptionPurchase); } diff --git a/src/Core/Billing/Services/ISubscriberService.cs b/src/Core/Billing/Services/ISubscriberService.cs index ac2ac0ae7..e7decd1cb 100644 --- a/src/Core/Billing/Services/ISubscriberService.cs +++ b/src/Core/Billing/Services/ISubscriberService.cs @@ -22,6 +22,16 @@ public interface ISubscriberService OffboardingSurveyResponse offboardingSurveyResponse, bool cancelImmediately); + /// + /// Creates a Braintree for the provided while attaching the provided . + /// + /// The subscriber to create a Braintree customer for. + /// A nonce representing the PayPal payment method the customer will use for payments. + /// The of the created Braintree customer. + Task CreateBraintreeCustomer( + ISubscriber subscriber, + string paymentMethodNonce); + /// /// Retrieves a Stripe using the 's property. /// diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs index f5e6e7809..af65ce427 100644 --- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs +++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs @@ -1,14 +1,31 @@ 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.Enums; +using Bit.Core.Exceptions; using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; using Bit.Core.Utilities; +using Braintree; +using Microsoft.Extensions.Logging; using Stripe; +using static Bit.Core.Billing.Utilities; +using Customer = Stripe.Customer; +using Subscription = Stripe.Subscription; + namespace Bit.Core.Billing.Services.Implementations; public class OrganizationBillingService( + IBraintreeGateway braintreeGateway, + IGlobalSettings globalSettings, + ILogger logger, IOrganizationRepository organizationRepository, + ISetupIntentCache setupIntentCache, + IStripeAdapter stripeAdapter, ISubscriberService subscriberService) : IOrganizationBillingService { public async Task GetMetadata(Guid organizationId) @@ -37,6 +54,310 @@ public class OrganizationBillingService( return new OrganizationMetadata(isOnSecretsManagerStandalone); } + public async Task PurchaseSubscription( + Organization organization, + OrganizationSubscriptionPurchase organizationSubscriptionPurchase) + { + ArgumentNullException.ThrowIfNull(organization); + ArgumentNullException.ThrowIfNull(organizationSubscriptionPurchase); + + var ( + metadata, + passwordManager, + paymentSource, + planType, + secretsManager, + taxInformation) = organizationSubscriptionPurchase; + + 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); + } + + #region Utilities + + private async Task CreateCustomerAsync( + Organization organization, + OrganizationSubscriptionPurchaseMetadata metadata, + TokenizedPaymentSource paymentSource, + TaxInformation taxInformation) + { + if (paymentSource == null) + { + logger.LogError( + "Cannot create customer for organization ({OrganizationID}) without a payment source", + organization.Id); + + throw new BillingException(); + } + + if (taxInformation is not { Country: not null, PostalCode: not null }) + { + logger.LogError( + "Cannot create customer for organization ({OrganizationID}) without both a country and postal code", + 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 organizationDisplayName = organization.DisplayName(); + + var customerCreateOptions = new CustomerCreateOptions + { + Address = address, + Coupon = coupon, + Description = organization.DisplayBusinessName(), + Email = organization.BillingEmail, + Expand = ["tax"], + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + CustomFields = [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = organization.SubscriberType(), + Value = organizationDisplayName.Length <= 30 + ? organizationDisplayName + : organizationDisplayName[..30] + }] + }, + Metadata = new Dictionary + { + { "region", globalSettings.BaseServiceUri.CloudRegion } + }, + Tax = new CustomerTaxOptions + { + ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately + }, + TaxIdData = !string.IsNullOrEmpty(taxId) + ? [new CustomerTaxIdDataOptions { Type = taxInformation.GetTaxIdType(), Value = taxId }] + : null + }; + + 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 braintreeCustomerId = ""; + + // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault + switch (type) + { + case PaymentMethodType.BankAccount: + { + 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(); + } + } + + try + { + return await stripeAdapter.CustomerCreateAsync(customerCreateOptions); + } + catch (StripeException stripeException) when (stripeException.StripeError?.Code == + StripeConstants.ErrorCodes.CustomerTaxLocationInvalid) + { + await Revert(); + + throw new BadRequestException( + "Your location wasn't recognized. Please ensure your country and postal code are valid."); + } + catch (StripeException stripeException) when (stripeException.StripeError?.Code == + StripeConstants.ErrorCodes.TaxIdInvalid) + { + await Revert(); + + throw new BadRequestException( + "Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid."); + } + catch + { + await Revert(); + throw; + } + + async Task Revert() + { + // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault + switch (type) + { + case PaymentMethodType.BankAccount: + { + await setupIntentCache.Remove(organization.Id); + break; + } + case PaymentMethodType.PayPal: + { + await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); + break; + } + } + } + } + + private async Task CreateSubscriptionAsync( + Customer customer, + Guid organizationId, + OrganizationPasswordManagerSubscriptionPurchase passwordManager, + PlanType planType, + OrganizationSecretsManagerSubscriptionPurchase secretsManager) + { + var plan = StaticStore.GetPlan(planType); + + if (passwordManager == null) + { + logger.LogError("Cannot create subscription for organization ({OrganizationID}) without password manager purchase information", organizationId); + + throw new BillingException(); + } + + var subscriptionItemOptionsList = new List + { + new () + { + Price = plan.PasswordManager.StripeSeatPlanId, + Quantity = passwordManager.Seats + } + }; + + if (passwordManager.PremiumAccess) + { + subscriptionItemOptionsList.Add(new SubscriptionItemOptions + { + Price = plan.PasswordManager.StripePremiumAccessPlanId, + Quantity = 1 + }); + } + + if (passwordManager.Storage > 0) + { + subscriptionItemOptionsList.Add(new SubscriptionItemOptions + { + Price = plan.PasswordManager.StripeStoragePlanId, + Quantity = passwordManager.Storage + }); + } + + if (secretsManager != null) + { + subscriptionItemOptionsList.Add(new SubscriptionItemOptions + { + Price = plan.SecretsManager.StripeSeatPlanId, + Quantity = secretsManager.Seats + }); + + if (secretsManager.ServiceAccounts > 0) + { + subscriptionItemOptionsList.Add(new SubscriptionItemOptions + { + Price = plan.SecretsManager.StripeServiceAccountPlanId, + Quantity = secretsManager.ServiceAccounts + }); + } + } + + var subscriptionCreateOptions = new SubscriptionCreateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions + { + Enabled = customer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported + }, + CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, + Customer = customer.Id, + Items = subscriptionItemOptionsList, + Metadata = new Dictionary + { + ["organizationId"] = organizationId.ToString() + }, + OffSession = true, + TrialPeriodDays = plan.TrialPeriodDays, + }; + + try + { + return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); + } + catch + { + await stripeAdapter.CustomerDeleteAsync(customer.Id); + throw; + } + } + private static bool IsOnSecretsManagerStandalone( Organization organization, Customer customer, @@ -62,4 +383,6 @@ public class OrganizationBillingService( return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any(); } + + #endregion } diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index b133845b3..c44bb3f3b 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -102,6 +102,37 @@ public class SubscriberService( } } + public async Task CreateBraintreeCustomer( + ISubscriber subscriber, + string paymentMethodNonce) + { + var braintreeCustomerId = + subscriber.BraintreeCustomerIdPrefix() + + subscriber.Id.ToString("N").ToLower() + + CoreHelpers.RandomString(3, upper: false, numeric: false); + + var customerResult = await braintreeGateway.Customer.CreateAsync(new CustomerRequest + { + Id = braintreeCustomerId, + CustomFields = new Dictionary + { + [subscriber.BraintreeIdField()] = subscriber.Id.ToString(), + [subscriber.BraintreeCloudRegionField()] = globalSettings.BaseServiceUri.CloudRegion + }, + Email = subscriber.BillingEmailAddress(), + PaymentMethodNonce = paymentMethodNonce, + }); + + if (customerResult.IsSuccess()) + { + return customerResult.Target.Id; + } + + logger.LogError("Failed to create Braintree customer for subscriber ({ID})", subscriber.Id); + + throw new BillingException(); + } + public async Task GetCustomer( ISubscriber subscriber, CustomerGetOptions customerGetOptions = null) @@ -530,7 +561,7 @@ public class SubscriberService( } } - braintreeCustomerId = await CreateBraintreeCustomerAsync(subscriber, token); + braintreeCustomerId = await CreateBraintreeCustomer(subscriber, token); await AddBraintreeCustomerIdAsync(customer, braintreeCustomerId); @@ -648,37 +679,6 @@ public class SubscriberService( }); } - private async Task CreateBraintreeCustomerAsync( - ISubscriber subscriber, - string paymentMethodNonce) - { - var braintreeCustomerId = - subscriber.BraintreeCustomerIdPrefix() + - subscriber.Id.ToString("N").ToLower() + - CoreHelpers.RandomString(3, upper: false, numeric: false); - - var customerResult = await braintreeGateway.Customer.CreateAsync(new CustomerRequest - { - Id = braintreeCustomerId, - CustomFields = new Dictionary - { - [subscriber.BraintreeIdField()] = subscriber.Id.ToString(), - [subscriber.BraintreeCloudRegionField()] = globalSettings.BaseServiceUri.CloudRegion - }, - Email = subscriber.BillingEmailAddress(), - PaymentMethodNonce = paymentMethodNonce, - }); - - if (customerResult.IsSuccess()) - { - return customerResult.Target.Id; - } - - logger.LogError("Failed to create Braintree customer for subscriber ({ID})", subscriber.Id); - - throw new BillingException(); - } - private async Task GetPaymentSourceAsync( Guid subscriberId, Customer customer) diff --git a/src/Core/Models/Business/OrganizationSignup.cs b/src/Core/Models/Business/OrganizationSignup.cs index 89168b274..a5155da75 100644 --- a/src/Core/Models/Business/OrganizationSignup.cs +++ b/src/Core/Models/Business/OrganizationSignup.cs @@ -1,4 +1,5 @@ -using Bit.Core.Entities; +using Bit.Core.Billing.Models; +using Bit.Core.Entities; using Bit.Core.Enums; namespace Bit.Core.Models.Business; @@ -14,4 +15,42 @@ 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); + } } diff --git a/src/Identity/Startup.cs b/src/Identity/Startup.cs index 65c303e75..320c91b24 100644 --- a/src/Identity/Startup.cs +++ b/src/Identity/Startup.cs @@ -3,6 +3,7 @@ using System.IdentityModel.Tokens.Jwt; using AspNetCoreRateLimit; using Bit.Core; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Billing.Extensions; using Bit.Core.Context; using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories.Noop; @@ -145,6 +146,7 @@ public class Startup services.AddBaseServices(globalSettings); services.AddDefaultServices(globalSettings); services.AddCoreLocalizationServices(); + services.AddBillingOperations(); // TODO: Remove when OrganizationUser methods are moved out of OrganizationService, this noop dependency should // TODO: no longer be required - see PM-1880