using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Api.Requests.Accounts; using Bit.Core.Billing.Models.Api.Requests.Organizations; using Bit.Core.Billing.Models.Api.Responses; using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Contracts; using Bit.Core.Billing.Services.Implementations.AutomaticTax; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.BitStripe; using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Settings; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Stripe; using PaymentMethod = Stripe.PaymentMethod; using StaticStore = Bit.Core.Models.StaticStore; namespace Bit.Core.Services; public class StripePaymentService : IPaymentService { private const string SecretsManagerStandaloneDiscountId = "sm-standalone"; private readonly ITransactionRepository _transactionRepository; private readonly ILogger _logger; private readonly Braintree.IBraintreeGateway _btGateway; private readonly IStripeAdapter _stripeAdapter; private readonly IGlobalSettings _globalSettings; private readonly IFeatureService _featureService; private readonly ITaxService _taxService; private readonly ISubscriberService _subscriberService; private readonly IPricingClient _pricingClient; private readonly IAutomaticTaxFactory _automaticTaxFactory; private readonly IAutomaticTaxStrategy _personalUseTaxStrategy; public StripePaymentService( ITransactionRepository transactionRepository, ILogger logger, IStripeAdapter stripeAdapter, Braintree.IBraintreeGateway braintreeGateway, IGlobalSettings globalSettings, IFeatureService featureService, ITaxService taxService, ISubscriberService subscriberService, IPricingClient pricingClient, IAutomaticTaxFactory automaticTaxFactory, [FromKeyedServices(AutomaticTaxFactory.PersonalUse)] IAutomaticTaxStrategy personalUseTaxStrategy) { _transactionRepository = transactionRepository; _logger = logger; _stripeAdapter = stripeAdapter; _btGateway = braintreeGateway; _globalSettings = globalSettings; _featureService = featureService; _taxService = taxService; _subscriberService = subscriberService; _pricingClient = pricingClient; _automaticTaxFactory = automaticTaxFactory; _personalUseTaxStrategy = personalUseTaxStrategy; } private async Task ChangeOrganizationSponsorship( Organization org, OrganizationSponsorship sponsorship, bool applySponsorship) { var existingPlan = await _pricingClient.GetPlanOrThrow(org.PlanType); var sponsoredPlan = sponsorship?.PlanSponsorshipType != null ? Utilities.StaticStore.GetSponsoredPlan(sponsorship.PlanSponsorshipType.Value) : null; var subscriptionUpdate = new SponsorOrganizationSubscriptionUpdate(existingPlan, sponsoredPlan, applySponsorship); await FinalizeSubscriptionChangeAsync(org, subscriptionUpdate, true); var sub = await _stripeAdapter.SubscriptionGetAsync(org.GatewaySubscriptionId); org.ExpirationDate = sub.CurrentPeriodEnd; if (sponsorship is not null) { sponsorship.ValidUntil = sub.CurrentPeriodEnd; } } public Task SponsorOrganizationAsync(Organization org, OrganizationSponsorship sponsorship) => ChangeOrganizationSponsorship(org, sponsorship, true); public Task RemoveOrganizationSponsorshipAsync(Organization org, OrganizationSponsorship sponsorship) => ChangeOrganizationSponsorship(org, sponsorship, false); private async Task FinalizeSubscriptionChangeAsync(ISubscriber subscriber, SubscriptionUpdate subscriptionUpdate, bool invoiceNow = false) { // remember, when in doubt, throw var subGetOptions = new SubscriptionGetOptions { Expand = ["customer.tax", "customer.tax_ids"] }; var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subGetOptions); if (sub == null) { throw new GatewayException("Subscription not found."); } if (sub.Status == SubscriptionStatuses.Canceled) { throw new BadRequestException("You do not have an active subscription. Reinstate your subscription to make changes."); } var collectionMethod = sub.CollectionMethod; var daysUntilDue = sub.DaysUntilDue; var chargeNow = collectionMethod == "charge_automatically"; var updatedItemOptions = subscriptionUpdate.UpgradeItemsOptions(sub); var isAnnualPlan = sub?.Items?.Data.FirstOrDefault()?.Plan?.Interval == "year"; var subUpdateOptions = new SubscriptionUpdateOptions { Items = updatedItemOptions, ProrationBehavior = invoiceNow ? Constants.AlwaysInvoice : Constants.CreateProrations, DaysUntilDue = daysUntilDue ?? 1, CollectionMethod = "send_invoice" }; if (!invoiceNow && isAnnualPlan && sub.Status.Trim() != "trialing") { subUpdateOptions.PendingInvoiceItemInterval = new SubscriptionPendingInvoiceItemIntervalOptions { Interval = "month" }; } if (subscriptionUpdate is CompleteSubscriptionUpdate) { if (_featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) { var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriber, updatedItemOptions.Select(x => x.Plan ?? x.Price)); var automaticTaxStrategy = await _automaticTaxFactory.CreateAsync(automaticTaxParameters); automaticTaxStrategy.SetUpdateOptions(subUpdateOptions, sub); } else { subUpdateOptions.EnableAutomaticTax(sub.Customer, sub); } } if (!subscriptionUpdate.UpdateNeeded(sub)) { // No need to update subscription, quantity matches return null; } string paymentIntentClientSecret = null; try { var subResponse = await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, subUpdateOptions); var invoice = await _stripeAdapter.InvoiceGetAsync(subResponse?.LatestInvoiceId, new InvoiceGetOptions()); if (invoice == null) { throw new BadRequestException("Unable to locate draft invoice for subscription update."); } if (invoice.AmountDue > 0 && updatedItemOptions.Any(i => i.Quantity > 0)) { try { if (invoiceNow) { if (chargeNow) { paymentIntentClientSecret = await PayInvoiceAfterSubscriptionChangeAsync(subscriber, invoice); } else { invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(subResponse.LatestInvoiceId, new InvoiceFinalizeOptions { AutoAdvance = false, }); await _stripeAdapter.InvoiceSendInvoiceAsync(invoice.Id, new InvoiceSendOptions()); paymentIntentClientSecret = null; } } } catch { // Need to revert the subscription await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new SubscriptionUpdateOptions { Items = subscriptionUpdate.RevertItemsOptions(sub), // This proration behavior prevents a false "credit" from // being applied forward to the next month's invoice ProrationBehavior = "none", CollectionMethod = collectionMethod, DaysUntilDue = daysUntilDue, }); throw; } } else if (!invoice.Paid) { // Pay invoice with no charge to customer this completes the invoice immediately without waiting the scheduled 1h invoice = await _stripeAdapter.InvoicePayAsync(subResponse.LatestInvoiceId); paymentIntentClientSecret = null; } } finally { // Change back the subscription collection method and/or days until due if (collectionMethod != "send_invoice" || daysUntilDue == null) { await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new SubscriptionUpdateOptions { CollectionMethod = collectionMethod, DaysUntilDue = daysUntilDue, }); } } return paymentIntentClientSecret; } public async Task AdjustSubscription( Organization organization, StaticStore.Plan updatedPlan, int newlyPurchasedPasswordManagerSeats, bool subscribedToSecretsManager, int? newlyPurchasedSecretsManagerSeats, int? newlyPurchasedAdditionalSecretsManagerServiceAccounts, int newlyPurchasedAdditionalStorage) { var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); return await FinalizeSubscriptionChangeAsync( organization, new CompleteSubscriptionUpdate( organization, plan, new SubscriptionData { Plan = updatedPlan, PurchasedPasswordManagerSeats = newlyPurchasedPasswordManagerSeats, SubscribedToSecretsManager = subscribedToSecretsManager, PurchasedSecretsManagerSeats = newlyPurchasedSecretsManagerSeats, PurchasedAdditionalSecretsManagerServiceAccounts = newlyPurchasedAdditionalSecretsManagerServiceAccounts, PurchasedAdditionalStorage = newlyPurchasedAdditionalStorage }), true); } public Task AdjustSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats) => FinalizeSubscriptionChangeAsync(organization, new SeatSubscriptionUpdate(organization, plan, additionalSeats)); public Task AdjustSmSeatsAsync(Organization organization, StaticStore.Plan plan, int additionalSeats) => FinalizeSubscriptionChangeAsync( organization, new SmSeatSubscriptionUpdate(organization, plan, additionalSeats)); public Task AdjustServiceAccountsAsync( Organization organization, StaticStore.Plan plan, int additionalServiceAccounts) => FinalizeSubscriptionChangeAsync( organization, new ServiceAccountSubscriptionUpdate(organization, plan, additionalServiceAccounts)); public Task AdjustStorageAsync( IStorableSubscriber storableSubscriber, int additionalStorage, string storagePlanId) { return FinalizeSubscriptionChangeAsync( storableSubscriber, new StorageSubscriptionUpdate(storagePlanId, additionalStorage)); } public async Task CancelAndRecoverChargesAsync(ISubscriber subscriber) { if (!string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) { await _stripeAdapter.SubscriptionCancelAsync(subscriber.GatewaySubscriptionId, new SubscriptionCancelOptions()); } if (string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) { return; } var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId); if (customer == null) { return; } if (customer.Metadata.ContainsKey("btCustomerId")) { var transactionRequest = new Braintree.TransactionSearchRequest() .CustomerId.Is(customer.Metadata["btCustomerId"]); var transactions = _btGateway.Transaction.Search(transactionRequest); if ((transactions?.MaximumCount ?? 0) > 0) { var txs = transactions.Cast().Where(c => c.RefundedTransactionId == null); foreach (var transaction in txs) { await _btGateway.Transaction.RefundAsync(transaction.Id); } } await _btGateway.Customer.DeleteAsync(customer.Metadata["btCustomerId"]); } else { var charges = await _stripeAdapter.ChargeListAsync(new ChargeListOptions { Customer = subscriber.GatewayCustomerId }); if (charges?.Data != null) { foreach (var charge in charges.Data.Where(c => c.Captured && !c.Refunded)) { await _stripeAdapter.RefundCreateAsync(new RefundCreateOptions { Charge = charge.Id }); } } } await _stripeAdapter.CustomerDeleteAsync(subscriber.GatewayCustomerId); } public async Task PayInvoiceAfterSubscriptionChangeAsync(ISubscriber subscriber, Invoice invoice) { var customerOptions = new CustomerGetOptions(); customerOptions.AddExpand("default_source"); customerOptions.AddExpand("invoice_settings.default_payment_method"); var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerOptions); string paymentIntentClientSecret = null; // Invoice them and pay now instead of waiting until Stripe does this automatically. string cardPaymentMethodId = null; if (!customer.Metadata.ContainsKey("btCustomerId")) { var hasDefaultCardPaymentMethod = customer.InvoiceSettings?.DefaultPaymentMethod?.Type == "card"; var hasDefaultValidSource = customer.DefaultSource != null && (customer.DefaultSource is Card || customer.DefaultSource is BankAccount); if (!hasDefaultCardPaymentMethod && !hasDefaultValidSource) { cardPaymentMethodId = GetLatestCardPaymentMethod(customer.Id)?.Id; if (cardPaymentMethodId == null) { // We're going to delete this draft invoice, it can't be paid try { await _stripeAdapter.InvoiceDeleteAsync(invoice.Id); } catch { await _stripeAdapter.InvoiceFinalizeInvoiceAsync(invoice.Id, new InvoiceFinalizeOptions { AutoAdvance = false }); await _stripeAdapter.InvoiceVoidInvoiceAsync(invoice.Id); } throw new BadRequestException("No payment method is available."); } } } Braintree.Transaction braintreeTransaction = null; try { // Finalize the invoice (from Draft) w/o auto-advance so we // can attempt payment manually. invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(invoice.Id, new InvoiceFinalizeOptions { AutoAdvance = false, }); var invoicePayOptions = new InvoicePayOptions { PaymentMethod = cardPaymentMethodId, }; if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false) { invoicePayOptions.PaidOutOfBand = true; var btInvoiceAmount = (invoice.AmountDue / 100M); var transactionResult = await _btGateway.Transaction.SaleAsync( new Braintree.TransactionRequest { Amount = btInvoiceAmount, CustomerId = customer.Metadata["btCustomerId"], Options = new Braintree.TransactionOptionsRequest { SubmitForSettlement = true, PayPal = new Braintree.TransactionOptionsPayPalRequest { CustomField = $"{subscriber.BraintreeIdField()}:{subscriber.Id},{subscriber.BraintreeCloudRegionField()}:{_globalSettings.BaseServiceUri.CloudRegion}" } }, CustomFields = new Dictionary { [subscriber.BraintreeIdField()] = subscriber.Id.ToString(), [subscriber.BraintreeCloudRegionField()] = _globalSettings.BaseServiceUri.CloudRegion } }); if (!transactionResult.IsSuccess()) { throw new GatewayException("Failed to charge PayPal customer."); } braintreeTransaction = transactionResult.Target; invoice = await _stripeAdapter.InvoiceUpdateAsync(invoice.Id, new InvoiceUpdateOptions { Metadata = new Dictionary { ["btTransactionId"] = braintreeTransaction.Id, ["btPayPalTransactionId"] = braintreeTransaction.PayPalDetails.AuthorizationId }, }); invoicePayOptions.PaidOutOfBand = true; } try { invoice = await _stripeAdapter.InvoicePayAsync(invoice.Id, invoicePayOptions); } catch (StripeException e) { if (e.HttpStatusCode == System.Net.HttpStatusCode.PaymentRequired && e.StripeError?.Code == "invoice_payment_intent_requires_action") { // SCA required, get intent client secret var invoiceGetOptions = new InvoiceGetOptions(); invoiceGetOptions.AddExpand("payment_intent"); invoice = await _stripeAdapter.InvoiceGetAsync(invoice.Id, invoiceGetOptions); paymentIntentClientSecret = invoice?.PaymentIntent?.ClientSecret; } else { throw new GatewayException("Unable to pay invoice."); } } } catch (Exception e) { if (braintreeTransaction != null) { await _btGateway.Transaction.RefundAsync(braintreeTransaction.Id); } if (invoice != null) { if (invoice.Status == "paid") { // It's apparently paid, so we need to return w/o throwing an exception return paymentIntentClientSecret; } invoice = await _stripeAdapter.InvoiceVoidInvoiceAsync(invoice.Id, new InvoiceVoidOptions()); // HACK: Workaround for customer balance credit if (invoice.StartingBalance < 0) { // Customer had a balance applied to this invoice. Since we can't fully trust Stripe to // credit it back to the customer (even though their docs claim they will), we need to // check that balance against the current customer balance and determine if it needs to be re-applied customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerOptions); // Assumption: Customer balance should now be $0, otherwise payment would not have failed. if (customer.Balance == 0) { await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { Balance = invoice.StartingBalance }); } } } if (e is StripeException strEx && (strEx.StripeError?.Message?.Contains("cannot be used because it is not verified") ?? false)) { throw new GatewayException("Bank account is not yet verified."); } // Let the caller perform any subscription change cleanup throw; } return paymentIntentClientSecret; } public async Task CancelSubscriptionAsync(ISubscriber subscriber, bool endOfPeriod = false) { if (subscriber == null) { throw new ArgumentNullException(nameof(subscriber)); } if (string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) { throw new GatewayException("No subscription."); } var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId); if (sub == null) { throw new GatewayException("Subscription was not found."); } if (sub.CanceledAt.HasValue || sub.Status == "canceled" || sub.Status == "unpaid" || sub.Status == "incomplete_expired") { // Already canceled return; } try { var canceledSub = endOfPeriod ? await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new SubscriptionUpdateOptions { CancelAtPeriodEnd = true }) : await _stripeAdapter.SubscriptionCancelAsync(sub.Id, new SubscriptionCancelOptions()); if (!canceledSub.CanceledAt.HasValue) { throw new GatewayException("Unable to cancel subscription."); } } catch (StripeException e) { if (e.Message != $"No such subscription: {subscriber.GatewaySubscriptionId}") { throw; } } } public async Task ReinstateSubscriptionAsync(ISubscriber subscriber) { if (subscriber == null) { throw new ArgumentNullException(nameof(subscriber)); } if (string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) { throw new GatewayException("No subscription."); } var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId); if (sub == null) { throw new GatewayException("Subscription was not found."); } if ((sub.Status != "active" && sub.Status != "trialing" && !sub.Status.StartsWith("incomplete")) || !sub.CanceledAt.HasValue) { throw new GatewayException("Subscription is not marked for cancellation."); } var updatedSub = await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new SubscriptionUpdateOptions { CancelAtPeriodEnd = false }); if (updatedSub.CanceledAt.HasValue) { throw new GatewayException("Unable to reinstate subscription."); } } public async Task UpdatePaymentMethodAsync(ISubscriber subscriber, PaymentMethodType paymentMethodType, string paymentToken, TaxInfo taxInfo = null) { if (subscriber == null) { throw new ArgumentNullException(nameof(subscriber)); } if (subscriber.Gateway.HasValue && subscriber.Gateway.Value != GatewayType.Stripe) { throw new GatewayException("Switching from one payment type to another is not supported. " + "Contact us for assistance."); } var createdCustomer = false; Braintree.Customer braintreeCustomer = null; string stipeCustomerSourceToken = null; string stipeCustomerPaymentMethodId = null; var stripeCustomerMetadata = new Dictionary { { "region", _globalSettings.BaseServiceUri.CloudRegion } }; var stripePaymentMethod = paymentMethodType is PaymentMethodType.Card or PaymentMethodType.BankAccount; Customer customer = null; if (!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) { var options = new CustomerGetOptions { Expand = ["sources", "tax", "subscriptions"] }; customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, options); if (customer.Metadata?.Any() ?? false) { stripeCustomerMetadata = customer.Metadata; } } var hadBtCustomer = stripeCustomerMetadata.ContainsKey("btCustomerId"); if (stripePaymentMethod) { if (paymentToken.StartsWith("pm_")) { stipeCustomerPaymentMethodId = paymentToken; } else { stipeCustomerSourceToken = paymentToken; } } else if (paymentMethodType == PaymentMethodType.PayPal) { if (hadBtCustomer) { var pmResult = await _btGateway.PaymentMethod.CreateAsync(new Braintree.PaymentMethodRequest { CustomerId = stripeCustomerMetadata["btCustomerId"], PaymentMethodNonce = paymentToken }); if (pmResult.IsSuccess()) { var customerResult = await _btGateway.Customer.UpdateAsync( stripeCustomerMetadata["btCustomerId"], new Braintree.CustomerRequest { DefaultPaymentMethodToken = pmResult.Target.Token }); if (customerResult.IsSuccess() && customerResult.Target.PaymentMethods.Length > 0) { braintreeCustomer = customerResult.Target; } else { await _btGateway.PaymentMethod.DeleteAsync(pmResult.Target.Token); hadBtCustomer = false; } } else { hadBtCustomer = false; } } if (!hadBtCustomer) { var customerResult = await _btGateway.Customer.CreateAsync(new Braintree.CustomerRequest { PaymentMethodNonce = paymentToken, Email = subscriber.BillingEmailAddress(), Id = subscriber.BraintreeCustomerIdPrefix() + subscriber.Id.ToString("N").ToLower() + Utilities.CoreHelpers.RandomString(3, upper: false, numeric: false), CustomFields = new Dictionary { [subscriber.BraintreeIdField()] = subscriber.Id.ToString(), [subscriber.BraintreeCloudRegionField()] = _globalSettings.BaseServiceUri.CloudRegion } }); if (!customerResult.IsSuccess() || customerResult.Target.PaymentMethods.Length == 0) { throw new GatewayException("Failed to create PayPal customer record."); } braintreeCustomer = customerResult.Target; } } else { throw new GatewayException("Payment method is not supported at this time."); } if (stripeCustomerMetadata.ContainsKey("btCustomerId")) { if (braintreeCustomer?.Id != stripeCustomerMetadata["btCustomerId"]) { stripeCustomerMetadata["btCustomerId_old"] = stripeCustomerMetadata["btCustomerId"]; } stripeCustomerMetadata["btCustomerId"] = braintreeCustomer?.Id; } else if (!string.IsNullOrWhiteSpace(braintreeCustomer?.Id)) { stripeCustomerMetadata.Add("btCustomerId", braintreeCustomer.Id); } try { if (!string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber)) { taxInfo.TaxIdType = taxInfo.TaxIdType ?? _taxService.GetStripeTaxCode(taxInfo.BillingAddressCountry, taxInfo.TaxIdNumber); } if (customer == null) { customer = await _stripeAdapter.CustomerCreateAsync(new CustomerCreateOptions { Description = subscriber.BillingName(), Email = subscriber.BillingEmailAddress(), Metadata = stripeCustomerMetadata, Source = stipeCustomerSourceToken, PaymentMethod = stipeCustomerPaymentMethodId, InvoiceSettings = new CustomerInvoiceSettingsOptions { DefaultPaymentMethod = stipeCustomerPaymentMethodId, CustomFields = [ new CustomerInvoiceSettingsCustomFieldOptions() { Name = subscriber.SubscriberType(), Value = subscriber.GetFormattedInvoiceName() } ] }, Address = taxInfo == null ? null : new AddressOptions { Country = taxInfo.BillingAddressCountry, PostalCode = taxInfo.BillingAddressPostalCode, Line1 = taxInfo.BillingAddressLine1 ?? string.Empty, Line2 = taxInfo.BillingAddressLine2, City = taxInfo.BillingAddressCity, State = taxInfo.BillingAddressState }, TaxIdData = string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber) ? [] : [ new CustomerTaxIdDataOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber } ], Expand = ["sources", "tax", "subscriptions"], }); subscriber.Gateway = GatewayType.Stripe; subscriber.GatewayCustomerId = customer.Id; createdCustomer = true; } if (!createdCustomer) { string defaultSourceId = null; string defaultPaymentMethodId = null; if (stripePaymentMethod) { if (!string.IsNullOrWhiteSpace(stipeCustomerSourceToken) && paymentToken.StartsWith("btok_")) { var bankAccount = await _stripeAdapter.BankAccountCreateAsync(customer.Id, new BankAccountCreateOptions { Source = paymentToken }); defaultSourceId = bankAccount.Id; } else if (!string.IsNullOrWhiteSpace(stipeCustomerPaymentMethodId)) { await _stripeAdapter.PaymentMethodAttachAsync(stipeCustomerPaymentMethodId, new PaymentMethodAttachOptions { Customer = customer.Id }); defaultPaymentMethodId = stipeCustomerPaymentMethodId; } } if (customer.Sources != null) { foreach (var source in customer.Sources.Where(s => s.Id != defaultSourceId)) { if (source is BankAccount) { await _stripeAdapter.BankAccountDeleteAsync(customer.Id, source.Id); } else if (source is Card) { await _stripeAdapter.CardDeleteAsync(customer.Id, source.Id); } } } var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging(new PaymentMethodListOptions { Customer = customer.Id, Type = "card" }); foreach (var cardMethod in cardPaymentMethods.Where(m => m.Id != defaultPaymentMethodId)) { await _stripeAdapter.PaymentMethodDetachAsync(cardMethod.Id, new PaymentMethodDetachOptions()); } await _subscriberService.UpdateTaxInformation(subscriber, TaxInformation.From(taxInfo)); customer = await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { Metadata = stripeCustomerMetadata, DefaultSource = defaultSourceId, InvoiceSettings = new CustomerInvoiceSettingsOptions { DefaultPaymentMethod = defaultPaymentMethodId, CustomFields = [ new CustomerInvoiceSettingsCustomFieldOptions() { Name = subscriber.SubscriberType(), Value = subscriber.GetFormattedInvoiceName() } ] }, Expand = ["tax", "subscriptions"] }); } if (_featureService.IsEnabled(FeatureFlagKeys.PM19147_AutomaticTaxImprovements)) { if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId)) { var subscriptionGetOptions = new SubscriptionGetOptions { Expand = ["customer.tax", "customer.tax_ids"] }; var subscription = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, subscriptionGetOptions); var automaticTaxParameters = new AutomaticTaxFactoryParameters(subscriber, subscription.Items.Select(x => x.Price.Id)); var automaticTaxStrategy = await _automaticTaxFactory.CreateAsync(automaticTaxParameters); var subscriptionUpdateOptions = automaticTaxStrategy.GetUpdateOptions(subscription); if (subscriptionUpdateOptions != null) { _ = await _stripeAdapter.SubscriptionUpdateAsync( subscriber.GatewaySubscriptionId, subscriptionUpdateOptions); } } } else { if (!string.IsNullOrEmpty(subscriber.GatewaySubscriptionId) && customer.Subscriptions.Any(sub => sub.Id == subscriber.GatewaySubscriptionId && !sub.AutomaticTax.Enabled) && customer.HasTaxLocationVerified()) { var subscriptionUpdateOptions = new SubscriptionUpdateOptions { AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }, DefaultTaxRates = [] }; _ = await _stripeAdapter.SubscriptionUpdateAsync( subscriber.GatewaySubscriptionId, subscriptionUpdateOptions); } } } catch { if (braintreeCustomer != null && !hadBtCustomer) { await _btGateway.Customer.DeleteAsync(braintreeCustomer.Id); } throw; } return createdCustomer; } public async Task CreditAccountAsync(ISubscriber subscriber, decimal creditAmount) { Customer customer = null; var customerExists = subscriber.Gateway == GatewayType.Stripe && !string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId); if (customerExists) { customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId); } else { customer = await _stripeAdapter.CustomerCreateAsync(new CustomerCreateOptions { Email = subscriber.BillingEmailAddress(), Description = subscriber.BillingName(), }); subscriber.Gateway = GatewayType.Stripe; subscriber.GatewayCustomerId = customer.Id; } await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { Balance = customer.Balance - (long)(creditAmount * 100) }); return !customerExists; } public async Task GetBillingAsync(ISubscriber subscriber) { var customer = await GetCustomerAsync(subscriber.GatewayCustomerId, GetCustomerPaymentOptions()); var billingInfo = new BillingInfo { Balance = customer.GetBillingBalance(), PaymentSource = await GetBillingPaymentSourceAsync(customer) }; return billingInfo; } public async Task GetBillingHistoryAsync(ISubscriber subscriber) { var customer = await GetCustomerAsync(subscriber.GatewayCustomerId); var billingInfo = new BillingHistoryInfo { Transactions = await GetBillingTransactionsAsync(subscriber, 20), Invoices = await GetBillingInvoicesAsync(customer, 20) }; return billingInfo; } public async Task GetSubscriptionAsync(ISubscriber subscriber) { var subscriptionInfo = new SubscriptionInfo(); if (!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) { var customerGetOptions = new CustomerGetOptions(); customerGetOptions.AddExpand("discount.coupon.applies_to"); var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerGetOptions); if (customer.Discount != null) { subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(customer.Discount); } } if (string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) { return subscriptionInfo; } var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, new SubscriptionGetOptions { Expand = ["test_clock"] }); if (sub != null) { subscriptionInfo.Subscription = new SubscriptionInfo.BillingSubscription(sub); var (suspensionDate, unpaidPeriodEndDate) = await GetSuspensionDateAsync(sub); if (suspensionDate.HasValue && unpaidPeriodEndDate.HasValue) { subscriptionInfo.Subscription.SuspensionDate = suspensionDate; subscriptionInfo.Subscription.UnpaidPeriodEndDate = unpaidPeriodEndDate; } } if (sub is { CanceledAt: not null } || string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) { return subscriptionInfo; } try { var upcomingInvoiceOptions = new UpcomingInvoiceOptions { Customer = subscriber.GatewayCustomerId }; var upcomingInvoice = await _stripeAdapter.InvoiceUpcomingAsync(upcomingInvoiceOptions); if (upcomingInvoice != null) { subscriptionInfo.UpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(upcomingInvoice); } } catch (StripeException ex) { _logger.LogWarning(ex, "Encountered an unexpected Stripe error"); } return subscriptionInfo; } public async Task GetTaxInfoAsync(ISubscriber subscriber) { if (subscriber == null || string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) { return null; } var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, new CustomerGetOptions { Expand = ["tax_ids"] }); if (customer == null) { return null; } var address = customer.Address; var taxId = customer.TaxIds?.FirstOrDefault(); // Line1 is required, so if missing we're using the subscriber name // see: https://stripe.com/docs/api/customers/create#create_customer-address-line1 if (address != null && string.IsNullOrWhiteSpace(address.Line1)) { address.Line1 = null; } return new TaxInfo { TaxIdNumber = taxId?.Value, TaxIdType = taxId?.Type, BillingAddressLine1 = address?.Line1, BillingAddressLine2 = address?.Line2, BillingAddressCity = address?.City, BillingAddressState = address?.State, BillingAddressPostalCode = address?.PostalCode, BillingAddressCountry = address?.Country, }; } public async Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo) { if (string.IsNullOrWhiteSpace(subscriber?.GatewayCustomerId) || subscriber.IsUser()) { return; } var customer = await _stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions { Address = new AddressOptions { Line1 = taxInfo.BillingAddressLine1 ?? string.Empty, Line2 = taxInfo.BillingAddressLine2, City = taxInfo.BillingAddressCity, State = taxInfo.BillingAddressState, PostalCode = taxInfo.BillingAddressPostalCode, Country = taxInfo.BillingAddressCountry, }, Expand = ["tax_ids"] }); if (customer == null) { return; } var taxId = customer.TaxIds?.FirstOrDefault(); if (taxId != null) { await _stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id); } if (string.IsNullOrWhiteSpace(taxInfo.TaxIdNumber)) { return; } var taxIdType = taxInfo.TaxIdType; if (string.IsNullOrWhiteSpace(taxIdType)) { taxIdType = _taxService.GetStripeTaxCode(taxInfo.BillingAddressCountry, taxInfo.TaxIdNumber); if (taxIdType == null) { _logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", taxInfo.BillingAddressCountry, taxInfo.TaxIdNumber); throw new BadRequestException("billingTaxIdTypeInferenceError"); } } try { await _stripeAdapter.TaxIdCreateAsync(customer.Id, new TaxIdCreateOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber, }); } catch (StripeException e) { switch (e.StripeError.Code) { case StripeConstants.ErrorCodes.TaxIdInvalid: _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", taxInfo.TaxIdNumber, taxInfo.BillingAddressCountry); throw new BadRequestException("billingInvalidTaxIdError"); default: _logger.LogError(e, "Error creating tax ID '{TaxId}' in country '{Country}' for customer '{CustomerID}'.", taxInfo.TaxIdNumber, taxInfo.BillingAddressCountry, customer.Id); throw new BadRequestException("billingTaxIdCreationError"); } } } public async Task AddSecretsManagerToSubscription( Organization org, StaticStore.Plan plan, int additionalSmSeats, int additionalServiceAccount) => await FinalizeSubscriptionChangeAsync( org, new SecretsManagerSubscribeUpdate(org, plan, additionalSmSeats, additionalServiceAccount), true); public async Task HasSecretsManagerStandalone(Organization organization) => await HasSecretsManagerStandaloneAsync(gatewayCustomerId: organization.GatewayCustomerId, organizationHasSecretsManager: organization.UseSecretsManager); public async Task HasSecretsManagerStandalone(InviteOrganization organization) => await HasSecretsManagerStandaloneAsync(gatewayCustomerId: organization.GatewayCustomerId, organizationHasSecretsManager: organization.UseSecretsManager); private async Task HasSecretsManagerStandaloneAsync(string gatewayCustomerId, bool organizationHasSecretsManager) { if (string.IsNullOrEmpty(gatewayCustomerId)) { return false; } if (organizationHasSecretsManager is false) { return false; } var customer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId); return customer?.Discount?.Coupon?.Id == SecretsManagerStandaloneDiscountId; } private async Task<(DateTime?, DateTime?)> GetSuspensionDateAsync(Subscription subscription) { if (subscription.Status is not "past_due" && subscription.Status is not "unpaid") { return (null, null); } var openInvoices = await _stripeAdapter.InvoiceSearchAsync(new InvoiceSearchOptions { Query = $"subscription:'{subscription.Id}' status:'open'" }); if (openInvoices.Count == 0) { return (null, null); } var currentDate = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; switch (subscription.CollectionMethod) { case "charge_automatically": { var firstOverdueInvoice = openInvoices .Where(invoice => invoice.PeriodEnd < currentDate && invoice.Attempted) .MinBy(invoice => invoice.Created); return (firstOverdueInvoice?.Created.AddDays(14), firstOverdueInvoice?.PeriodEnd); } case "send_invoice": { var firstOverdueInvoice = openInvoices .Where(invoice => invoice.DueDate < currentDate) .MinBy(invoice => invoice.Created); return (firstOverdueInvoice?.DueDate?.AddDays(30), firstOverdueInvoice?.PeriodEnd); } default: return (null, null); } } public async Task PreviewInvoiceAsync( PreviewIndividualInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId) { var options = new InvoiceCreatePreviewOptions { AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true, }, Currency = "usd", SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = [ new() { Quantity = 1, Plan = "premium-annually" }, new() { Quantity = parameters.PasswordManager.AdditionalStorage, Plan = "storage-gb-annually" } ] }, CustomerDetails = new InvoiceCustomerDetailsOptions { Address = new AddressOptions { PostalCode = parameters.TaxInformation.PostalCode, Country = parameters.TaxInformation.Country, } }, }; if (!string.IsNullOrEmpty(parameters.TaxInformation.TaxId)) { var taxIdType = _taxService.GetStripeTaxCode( options.CustomerDetails.Address.Country, parameters.TaxInformation.TaxId); if (taxIdType == null) { _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", parameters.TaxInformation.TaxId, parameters.TaxInformation.Country); throw new BadRequestException("billingPreviewInvalidTaxIdError"); } options.CustomerDetails.TaxIds = [ new InvoiceCustomerDetailsTaxIdOptions { Type = taxIdType, Value = parameters.TaxInformation.TaxId } ]; } if (!string.IsNullOrWhiteSpace(gatewayCustomerId)) { var gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId); if (gatewayCustomer.Discount != null) { options.Coupon = gatewayCustomer.Discount.Coupon.Id; } } if (!string.IsNullOrWhiteSpace(gatewaySubscriptionId)) { var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId); if (gatewaySubscription?.Discount != null) { options.Coupon ??= gatewaySubscription.Discount.Coupon.Id; } } _personalUseTaxStrategy.SetInvoiceCreatePreviewOptions(options); try { var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null ? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() : 0M; var result = new PreviewInvoiceResponseModel( effectiveTaxRate, invoice.TotalExcludingTax.ToMajor() ?? 0, invoice.Tax.ToMajor() ?? 0, invoice.Total.ToMajor()); return result; } catch (StripeException e) { switch (e.StripeError.Code) { case StripeConstants.ErrorCodes.TaxIdInvalid: _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", parameters.TaxInformation.TaxId, parameters.TaxInformation.Country); throw new BadRequestException("billingPreviewInvalidTaxIdError"); default: _logger.LogError(e, "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.", parameters.TaxInformation.TaxId, parameters.TaxInformation.Country); throw new BadRequestException("billingPreviewInvoiceError"); } } } public async Task PreviewInvoiceAsync( PreviewOrganizationInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId) { var plan = await _pricingClient.GetPlanOrThrow(parameters.PasswordManager.Plan); var options = new InvoiceCreatePreviewOptions { Currency = "usd", SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = [ new() { Quantity = parameters.PasswordManager.AdditionalStorage, Plan = plan.PasswordManager.StripeStoragePlanId } ] }, CustomerDetails = new InvoiceCustomerDetailsOptions { Address = new AddressOptions { PostalCode = parameters.TaxInformation.PostalCode, Country = parameters.TaxInformation.Country, } }, }; if (plan.PasswordManager.HasAdditionalSeatsOption) { options.SubscriptionDetails.Items.Add( new() { Quantity = parameters.PasswordManager.Seats, Plan = plan.PasswordManager.StripeSeatPlanId } ); } else { options.SubscriptionDetails.Items.Add( new() { Quantity = 1, Plan = plan.PasswordManager.StripePlanId } ); } if (plan.SupportsSecretsManager) { if (plan.SecretsManager.HasAdditionalSeatsOption) { options.SubscriptionDetails.Items.Add(new() { Quantity = parameters.SecretsManager?.Seats ?? 0, Plan = plan.SecretsManager.StripeSeatPlanId }); } if (plan.SecretsManager.HasAdditionalServiceAccountOption) { options.SubscriptionDetails.Items.Add(new() { Quantity = parameters.SecretsManager?.AdditionalMachineAccounts ?? 0, Plan = plan.SecretsManager.StripeServiceAccountPlanId }); } } if (!string.IsNullOrWhiteSpace(parameters.TaxInformation.TaxId)) { var taxIdType = _taxService.GetStripeTaxCode( options.CustomerDetails.Address.Country, parameters.TaxInformation.TaxId); if (taxIdType == null) { _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", parameters.TaxInformation.TaxId, parameters.TaxInformation.Country); throw new BadRequestException("billingTaxIdTypeInferenceError"); } options.CustomerDetails.TaxIds = [ new InvoiceCustomerDetailsTaxIdOptions { Type = taxIdType, Value = parameters.TaxInformation.TaxId } ]; } Customer gatewayCustomer = null; if (!string.IsNullOrWhiteSpace(gatewayCustomerId)) { gatewayCustomer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId); if (gatewayCustomer.Discount != null) { options.Coupon = gatewayCustomer.Discount.Coupon.Id; } } if (!string.IsNullOrWhiteSpace(gatewaySubscriptionId)) { var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId); if (gatewaySubscription?.Discount != null) { options.Coupon ??= gatewaySubscription.Discount.Coupon.Id; } } var automaticTaxFactoryParameters = new AutomaticTaxFactoryParameters(parameters.PasswordManager.Plan); var automaticTaxStrategy = await _automaticTaxFactory.CreateAsync(automaticTaxFactoryParameters); automaticTaxStrategy.SetInvoiceCreatePreviewOptions(options); try { var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null ? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() : 0M; var result = new PreviewInvoiceResponseModel( effectiveTaxRate, invoice.TotalExcludingTax.ToMajor() ?? 0, invoice.Tax.ToMajor() ?? 0, invoice.Total.ToMajor()); return result; } catch (StripeException e) { switch (e.StripeError.Code) { case StripeConstants.ErrorCodes.TaxIdInvalid: _logger.LogWarning("Invalid tax ID '{TaxID}' for country '{Country}'.", parameters.TaxInformation.TaxId, parameters.TaxInformation.Country); throw new BadRequestException("billingPreviewInvalidTaxIdError"); default: _logger.LogError(e, "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.", parameters.TaxInformation.TaxId, parameters.TaxInformation.Country); throw new BadRequestException("billingPreviewInvoiceError"); } } } private PaymentMethod GetLatestCardPaymentMethod(string customerId) { var cardPaymentMethods = _stripeAdapter.PaymentMethodListAutoPaging( new PaymentMethodListOptions { Customer = customerId, Type = "card" }); return cardPaymentMethods.OrderByDescending(m => m.Created).FirstOrDefault(); } private async Task GetBillingPaymentSourceAsync(Customer customer) { if (customer == null) { return null; } if (customer.Metadata?.ContainsKey("btCustomerId") ?? false) { try { var braintreeCustomer = await _btGateway.Customer.FindAsync( customer.Metadata["btCustomerId"]); if (braintreeCustomer?.DefaultPaymentMethod != null) { return new BillingInfo.BillingSource( braintreeCustomer.DefaultPaymentMethod); } } catch (Braintree.Exceptions.NotFoundException) { } } if (customer.InvoiceSettings?.DefaultPaymentMethod?.Type == "card") { return new BillingInfo.BillingSource( customer.InvoiceSettings.DefaultPaymentMethod); } if (customer.DefaultSource != null && (customer.DefaultSource is Card || customer.DefaultSource is BankAccount)) { return new BillingInfo.BillingSource(customer.DefaultSource); } var paymentMethod = GetLatestCardPaymentMethod(customer.Id); return paymentMethod != null ? new BillingInfo.BillingSource(paymentMethod) : null; } private CustomerGetOptions GetCustomerPaymentOptions() { var customerOptions = new CustomerGetOptions(); customerOptions.AddExpand("default_source"); customerOptions.AddExpand("invoice_settings.default_payment_method"); return customerOptions; } private async Task GetCustomerAsync(string gatewayCustomerId, CustomerGetOptions options = null) { if (string.IsNullOrWhiteSpace(gatewayCustomerId)) { return null; } Customer customer = null; try { customer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId, options); } catch (StripeException) { } return customer; } private async Task> GetBillingTransactionsAsync(ISubscriber subscriber, int? limit = null) { var transactions = subscriber switch { User => await _transactionRepository.GetManyByUserIdAsync(subscriber.Id, limit), Organization => await _transactionRepository.GetManyByOrganizationIdAsync(subscriber.Id, limit), _ => null }; return transactions?.OrderByDescending(i => i.CreationDate) .Select(t => new BillingHistoryInfo.BillingTransaction(t)); } private async Task> GetBillingInvoicesAsync(Customer customer, int? limit = null) { if (customer == null) { return null; } try { var paidInvoicesTask = _stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions { Customer = customer.Id, SelectAll = !limit.HasValue, Limit = limit, Status = "paid" }); var openInvoicesTask = _stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions { Customer = customer.Id, SelectAll = !limit.HasValue, Limit = limit, Status = "open" }); var uncollectibleInvoicesTask = _stripeAdapter.InvoiceListAsync(new StripeInvoiceListOptions { Customer = customer.Id, SelectAll = !limit.HasValue, Limit = limit, Status = "uncollectible" }); var paidInvoices = await paidInvoicesTask; var openInvoices = await openInvoicesTask; var uncollectibleInvoices = await uncollectibleInvoicesTask; var invoices = paidInvoices .Concat(openInvoices) .Concat(uncollectibleInvoices); var result = invoices .OrderByDescending(invoice => invoice.Created) .Select(invoice => new BillingHistoryInfo.BillingInvoice(invoice)); return limit.HasValue ? result.Take(limit.Value) : result; } catch (StripeException exception) { _logger.LogError(exception, "An error occurred while listing Stripe invoices"); throw new GatewayException("Failed to retrieve current invoices", exception); } } }