using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; 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; #nullable enable public class OrganizationBillingService( IBraintreeGateway braintreeGateway, IGlobalSettings globalSettings, ILogger logger, IOrganizationRepository organizationRepository, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, ISubscriberService subscriberService, ITaxService taxService) : IOrganizationBillingService { public async Task Finalize(OrganizationSale sale) { var (organization, customerSetup, subscriptionSetup) = sale; var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null ? await CreateCustomerAsync(organization, customerSetup) : await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = ["tax"] }); 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); if (organization == null) { return null; } var isEligibleForSelfHost = IsEligibleForSelfHost(organization); var isManaged = organization.Status == OrganizationStatusType.Managed; if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId)) { return new OrganizationMetadata(isEligibleForSelfHost, isManaged, false, false, false, false, false, null, null, null); } var customer = await subscriberService.GetCustomer(organization, new CustomerGetOptions { Expand = ["discount.coupon.applies_to"] }); var subscription = await subscriberService.GetSubscription(organization); var isOnSecretsManagerStandalone = IsOnSecretsManagerStandalone(organization, customer, subscription); var isSubscriptionUnpaid = IsSubscriptionUnpaid(subscription); var isSubscriptionCanceled = IsSubscriptionCanceled(subscription); var hasSubscription = true; var openInvoice = await HasOpenInvoiceAsync(subscription); var hasOpenInvoice = openInvoice.HasOpenInvoice; var invoiceDueDate = openInvoice.DueDate; var invoiceCreatedDate = openInvoice.CreatedDate; var subPeriodEndDate = subscription?.CurrentPeriodEnd; return new OrganizationMetadata(isEligibleForSelfHost, isManaged, isOnSecretsManagerStandalone, isSubscriptionUnpaid, hasSubscription, hasOpenInvoice, isSubscriptionCanceled, invoiceDueDate, invoiceCreatedDate, subPeriodEndDate); } public async Task UpdatePaymentMethod( Organization organization, TokenizedPaymentSource tokenizedPaymentSource, TaxInformation taxInformation) { if (string.IsNullOrEmpty(organization.GatewayCustomerId)) { var customer = await CreateCustomerAsync(organization, new CustomerSetup { TokenizedPaymentSource = tokenizedPaymentSource, TaxInformation = taxInformation }); organization.Gateway = GatewayType.Stripe; organization.GatewayCustomerId = customer.Id; await organizationRepository.ReplaceAsync(organization); } else { await subscriberService.UpdatePaymentSource(organization, tokenizedPaymentSource); await subscriberService.UpdateTaxInformation(organization, taxInformation); } } #region Utilities private async Task CreateCustomerAsync( Organization organization, CustomerSetup customerSetup) { var displayName = organization.DisplayName(); var customerCreateOptions = new CustomerCreateOptions { Coupon = customerSetup.Coupon, Description = organization.DisplayBusinessName(), Email = organization.BillingEmail, Expand = ["tax"], InvoiceSettings = new CustomerInvoiceSettingsOptions { CustomFields = [ new CustomerInvoiceSettingsCustomFieldOptions { Name = organization.SubscriberType(), Value = displayName.Length <= 30 ? displayName : displayName[..30] }] }, Metadata = new Dictionary { ["organizationId"] = organization.Id.ToString(), ["region"] = globalSettings.BaseServiceUri.CloudRegion } }; var braintreeCustomerId = ""; if (customerSetup.IsBillable) { if (customerSetup.TokenizedPaymentSource is not { Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal, Token: not null and not "" }) { logger.LogError( "Cannot create customer for organization ({OrganizationID}) without a valid payment source", organization.Id); throw new BillingException(); } if (customerSetup.TaxInformation is not { Country: not null and not "", PostalCode: not null and not "" }) { logger.LogError( "Cannot create customer for organization ({OrganizationID}) without valid tax information", organization.Id); throw new BillingException(); } customerCreateOptions.Address = new AddressOptions { Line1 = customerSetup.TaxInformation.Line1, Line2 = customerSetup.TaxInformation.Line2, City = customerSetup.TaxInformation.City, PostalCode = customerSetup.TaxInformation.PostalCode, State = customerSetup.TaxInformation.State, Country = customerSetup.TaxInformation.Country, }; customerCreateOptions.Tax = new CustomerTaxOptions { ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately }; if (!string.IsNullOrEmpty(customerSetup.TaxInformation.TaxId)) { var taxIdType = taxService.GetStripeTaxCode(customerSetup.TaxInformation.Country, customerSetup.TaxInformation.TaxId); if (taxIdType == null) { logger.LogWarning("Could not determine tax ID type for organization '{OrganizationID}' in country '{Country}' with tax ID '{TaxID}'.", organization.Id, customerSetup.TaxInformation.Country, customerSetup.TaxInformation.TaxId); } customerCreateOptions.TaxIdData = [ new() { Type = taxIdType, Value = customerSetup.TaxInformation.TaxId } ]; } var (paymentMethodType, paymentMethodToken) = customerSetup.TokenizedPaymentSource; // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault switch (paymentMethodType) { case PaymentMethodType.BankAccount: { var setupIntent = (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethodToken })) .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 = paymentMethodToken; customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethodToken; break; } case PaymentMethodType.PayPal: { braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(organization, paymentMethodToken); 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, paymentMethodType.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() { if (customerSetup.IsBillable) { // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault switch (customerSetup.TokenizedPaymentSource!.Type) { case PaymentMethodType.BankAccount: { await setupIntentCache.Remove(organization.Id); break; } case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): { await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); break; } } } } } private async Task CreateSubscriptionAsync( Guid organizationId, Customer customer, SubscriptionSetup subscriptionSetup) { var plan = subscriptionSetup.Plan; var passwordManagerOptions = subscriptionSetup.PasswordManagerOptions; var subscriptionItemOptionsList = new List { plan.HasNonSeatBasedPasswordManagerPlan() ? new SubscriptionItemOptions { Price = plan.PasswordManager.StripePlanId, Quantity = 1 } : new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = passwordManagerOptions.Seats } }; if (passwordManagerOptions.PremiumAccess is true) { subscriptionItemOptionsList.Add(new SubscriptionItemOptions { Price = plan.PasswordManager.StripePremiumAccessPlanId, Quantity = 1 }); } if (passwordManagerOptions.Storage is > 0) { subscriptionItemOptionsList.Add(new SubscriptionItemOptions { Price = plan.PasswordManager.StripeStoragePlanId, Quantity = passwordManagerOptions.Storage }); } var secretsManagerOptions = subscriptionSetup.SecretsManagerOptions; if (secretsManagerOptions != null) { subscriptionItemOptionsList.Add(new SubscriptionItemOptions { Price = plan.SecretsManager.StripeSeatPlanId, Quantity = secretsManagerOptions.Seats }); if (secretsManagerOptions.ServiceAccounts is > 0) { subscriptionItemOptionsList.Add(new SubscriptionItemOptions { Price = plan.SecretsManager.StripeServiceAccountPlanId, Quantity = secretsManagerOptions.ServiceAccounts }); } } var subscriptionCreateOptions = new SubscriptionCreateOptions { AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }, CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, Customer = customer.Id, Items = subscriptionItemOptionsList, Metadata = new Dictionary { ["organizationId"] = organizationId.ToString() }, OffSession = true, TrialPeriodDays = plan.TrialPeriodDays }; return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); } private static bool IsEligibleForSelfHost( Organization organization) { var eligibleSelfHostPlans = StaticStore.Plans.Where(plan => plan.HasSelfHost).Select(plan => plan.Type); return eligibleSelfHostPlans.Contains(organization.PlanType); } private static bool IsOnSecretsManagerStandalone( Organization organization, Customer? customer, Subscription? subscription) { if (customer == null || subscription == null) { return false; } var plan = StaticStore.GetPlan(organization.PlanType); if (!plan.SupportsSecretsManager) { return false; } var hasCoupon = customer.Discount?.Coupon?.Id == StripeConstants.CouponIDs.SecretsManagerStandalone; if (!hasCoupon) { return false; } var subscriptionProductIds = subscription.Items.Data.Select(item => item.Plan.ProductId); var couponAppliesTo = customer.Discount?.Coupon?.AppliesTo?.Products; return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any(); } private static bool IsSubscriptionUnpaid(Subscription subscription) { if (subscription == null) { return false; } return subscription.Status == "unpaid"; } private async Task<(bool HasOpenInvoice, DateTime? CreatedDate, DateTime? DueDate)> HasOpenInvoiceAsync(Subscription subscription) { if (subscription?.LatestInvoiceId == null) { return (false, null, null); } var invoice = await stripeAdapter.InvoiceGetAsync(subscription.LatestInvoiceId, new InvoiceGetOptions()); return invoice?.Status == "open" ? (true, invoice.Created, invoice.DueDate) : (false, null, null); } private static bool IsSubscriptionCanceled(Subscription subscription) { if (subscription == null) { return false; } return subscription.Status == "canceled"; } #endregion }