using Bit.Core.Billing.Caches; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Platform.Push; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using Braintree; using Microsoft.Extensions.Logging; using OneOf.Types; using Stripe; using Customer = Stripe.Customer; using PaymentMethod = Bit.Core.Billing.Payment.Models.PaymentMethod; using Subscription = Stripe.Subscription; namespace Bit.Core.Billing.Premium.Commands; using static StripeConstants; using static Utilities; /// /// Creates a premium subscription for a cloud-hosted user with Stripe payment processing. /// Handles customer creation, payment method setup, and subscription creation. /// public interface ICreatePremiumCloudHostedSubscriptionCommand { /// /// Creates a premium cloud-hosted subscription for the specified user. /// /// The user to create the premium subscription for. Must not yet be a premium user. /// The tokenized payment method containing the payment type and token for billing. /// The billing address information required for tax calculation and customer creation. /// Additional storage in GB beyond the base 1GB included with premium (must be >= 0). /// A billing command result indicating success or failure with appropriate error details. Task> Run( User user, PaymentMethod paymentMethod, BillingAddress billingAddress, short additionalStorageGb); } public class CreatePremiumCloudHostedSubscriptionCommand( IBraintreeGateway braintreeGateway, IGlobalSettings globalSettings, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, ISubscriberService subscriberService, IUserService userService, IPushNotificationService pushNotificationService, ILogger logger, IPricingClient pricingClient, IHasPaymentMethodQuery hasPaymentMethodQuery, IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingCommand(logger), ICreatePremiumCloudHostedSubscriptionCommand { private static readonly List _expand = ["tax"]; private readonly ILogger _logger = logger; public Task> Run( User user, PaymentMethod paymentMethod, BillingAddress billingAddress, short additionalStorageGb) => HandleAsync(async () => { if (user.Premium) { return new BadRequest("Already a premium user."); } if (additionalStorageGb < 0) { return new BadRequest("Additional storage must be greater than 0."); } Customer? customer; /* * For a new customer purchasing a new subscription, we attach the payment method while creating the customer. */ if (string.IsNullOrEmpty(user.GatewayCustomerId)) { customer = await CreateCustomerAsync(user, paymentMethod, billingAddress); } /* * An existing customer without a payment method starting a new subscription indicates a user who previously * purchased account credit but chose to use a tokenizable payment method to pay for the subscription. In this case, * we need to add the payment method to their customer first. If the incoming payment method is account credit, * we can just go straight to fetching the customer since there's no payment method to apply. */ else if (paymentMethod.IsTokenized && !await hasPaymentMethodQuery.Run(user)) { await updatePaymentMethodCommand.Run(user, paymentMethod.AsTokenized, billingAddress); customer = await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand }); } else { customer = await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand }); } customer = await ReconcileBillingLocationAsync(customer, billingAddress); var subscription = await CreateSubscriptionAsync(user.Id, customer, additionalStorageGb > 0 ? additionalStorageGb : null); paymentMethod.Switch( tokenized => { // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault switch (tokenized) { case { Type: TokenizablePaymentMethodType.PayPal } when subscription.Status == SubscriptionStatus.Incomplete: case { Type: not TokenizablePaymentMethodType.PayPal } when subscription.Status == SubscriptionStatus.Active: { user.Premium = true; user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd(); break; } } }, _ => { if (subscription.Status != SubscriptionStatus.Active) { return; } user.Premium = true; user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd(); }); user.Gateway = GatewayType.Stripe; user.GatewayCustomerId = customer.Id; user.GatewaySubscriptionId = subscription.Id; user.MaxStorageGb = (short)(1 + additionalStorageGb); user.LicenseKey = CoreHelpers.SecureRandomString(20); user.RevisionDate = DateTime.UtcNow; await userService.SaveUserAsync(user); await pushNotificationService.PushSyncVaultAsync(user.Id); return new None(); }); private async Task CreateCustomerAsync(User user, PaymentMethod paymentMethod, BillingAddress billingAddress) { if (paymentMethod.IsNonTokenized) { _logger.LogError("Cannot create customer for user ({UserID}) using non-tokenized payment method. The customer should already exist", user.Id); throw new BillingException(); } var subscriberName = user.SubscriberName(); var customerCreateOptions = new CustomerCreateOptions { Address = new AddressOptions { Line1 = billingAddress.Line1, Line2 = billingAddress.Line2, City = billingAddress.City, PostalCode = billingAddress.PostalCode, State = billingAddress.State, Country = billingAddress.Country }, Description = user.Name, Email = user.Email, Expand = _expand, InvoiceSettings = new CustomerInvoiceSettingsOptions { CustomFields = [ new CustomerInvoiceSettingsCustomFieldOptions { Name = user.SubscriberType(), Value = subscriberName.Length <= 30 ? subscriberName : subscriberName[..30] } ] }, Metadata = new Dictionary { [MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion, [MetadataKeys.UserId] = user.Id.ToString() }, Tax = new CustomerTaxOptions { ValidateLocation = ValidateTaxLocationTiming.Immediately } }; var braintreeCustomerId = ""; // We have checked that the payment method is tokenized, so we can safely cast it. var tokenizedPaymentMethod = paymentMethod.AsTokenized; switch (tokenizedPaymentMethod.Type) { case TokenizablePaymentMethodType.BankAccount: { var setupIntent = (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = tokenizedPaymentMethod.Token })) .FirstOrDefault(); if (setupIntent == null) { _logger.LogError("Cannot create customer for user ({UserID}) without a setup intent for their bank account", user.Id); throw new BillingException(); } await setupIntentCache.Set(user.Id, setupIntent.Id); break; } case TokenizablePaymentMethodType.Card: { customerCreateOptions.PaymentMethod = tokenizedPaymentMethod.Token; customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = tokenizedPaymentMethod.Token; break; } case TokenizablePaymentMethodType.PayPal: { braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, tokenizedPaymentMethod.Token); customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId; break; } default: { _logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, tokenizedPaymentMethod.Type.ToString()); throw new BillingException(); } } try { return await stripeAdapter.CustomerCreateAsync(customerCreateOptions); } catch { await Revert(); throw; } async Task Revert() { // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault switch (tokenizedPaymentMethod.Type) { case TokenizablePaymentMethodType.BankAccount: { await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id); break; } case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): { await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); break; } } } } private async Task ReconcileBillingLocationAsync( Customer customer, BillingAddress billingAddress) { /* * If the customer was previously set up with credit, which does not require a billing location, * we need to update the customer on the fly before we start the subscription. */ if (customer is { Address: { Country: not null and not "", PostalCode: not null and not "" } }) { return customer; } var options = new CustomerUpdateOptions { Address = new AddressOptions { Line1 = billingAddress.Line1, Line2 = billingAddress.Line2, City = billingAddress.City, PostalCode = billingAddress.PostalCode, State = billingAddress.State, Country = billingAddress.Country }, Expand = _expand, Tax = new CustomerTaxOptions { ValidateLocation = ValidateTaxLocationTiming.Immediately } }; return await stripeAdapter.CustomerUpdateAsync(customer.Id, options); } private async Task CreateSubscriptionAsync( Guid userId, Customer customer, int? storage) { var premiumPlan = await pricingClient.GetAvailablePremiumPlan(); var subscriptionItemOptionsList = new List { new () { Price = premiumPlan.Seat.StripePriceId, Quantity = 1 } }; if (storage is > 0) { subscriptionItemOptionsList.Add(new SubscriptionItemOptions { Price = premiumPlan.Storage.StripePriceId, Quantity = storage }); } var usingPayPal = customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) ?? false; var subscriptionCreateOptions = new SubscriptionCreateOptions { AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }, CollectionMethod = CollectionMethod.ChargeAutomatically, Customer = customer.Id, Items = subscriptionItemOptionsList, Metadata = new Dictionary { [MetadataKeys.UserId] = userId.ToString() }, PaymentBehavior = usingPayPal ? PaymentBehavior.DefaultIncomplete : null, OffSession = true }; var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); if (usingPayPal) { await stripeAdapter.InvoiceUpdateAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions { AutoAdvance = false }); } return subscription; } }