using Bit.Billing.Constants; using Bit.Billing.Models; using Bit.Billing.Services; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Business; using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Braintree; using Braintree.Exceptions; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Options; using Stripe; using Customer = Stripe.Customer; using Event = Stripe.Event; using JsonSerializer = System.Text.Json.JsonSerializer; using Subscription = Stripe.Subscription; using TaxRate = Bit.Core.Entities.TaxRate; using Transaction = Bit.Core.Entities.Transaction; using TransactionType = Bit.Core.Enums.TransactionType; namespace Bit.Billing.Controllers; [Route("stripe")] public class StripeController : Controller { private const string PremiumPlanId = "premium-annually"; private const string PremiumPlanIdAppStore = "premium-annually-app"; private readonly BillingSettings _billingSettings; private readonly IWebHostEnvironment _hostingEnvironment; private readonly IOrganizationService _organizationService; private readonly IValidateSponsorshipCommand _validateSponsorshipCommand; private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand; private readonly IOrganizationRepository _organizationRepository; private readonly ITransactionRepository _transactionRepository; private readonly IUserService _userService; private readonly IMailService _mailService; private readonly ILogger _logger; private readonly BraintreeGateway _btGateway; private readonly IReferenceEventService _referenceEventService; private readonly ITaxRateRepository _taxRateRepository; private readonly IUserRepository _userRepository; private readonly ICurrentContext _currentContext; private readonly GlobalSettings _globalSettings; private readonly IStripeEventService _stripeEventService; private readonly IStripeFacade _stripeFacade; private readonly IFeatureService _featureService; private readonly IProviderRepository _providerRepository; public StripeController( GlobalSettings globalSettings, IOptions billingSettings, IWebHostEnvironment hostingEnvironment, IOrganizationService organizationService, IValidateSponsorshipCommand validateSponsorshipCommand, IOrganizationSponsorshipRenewCommand organizationSponsorshipRenewCommand, IOrganizationRepository organizationRepository, ITransactionRepository transactionRepository, IUserService userService, IMailService mailService, IReferenceEventService referenceEventService, ILogger logger, ITaxRateRepository taxRateRepository, IUserRepository userRepository, ICurrentContext currentContext, IStripeEventService stripeEventService, IStripeFacade stripeFacade, IFeatureService featureService, IProviderRepository providerRepository) { _billingSettings = billingSettings?.Value; _hostingEnvironment = hostingEnvironment; _organizationService = organizationService; _validateSponsorshipCommand = validateSponsorshipCommand; _organizationSponsorshipRenewCommand = organizationSponsorshipRenewCommand; _organizationRepository = organizationRepository; _transactionRepository = transactionRepository; _userService = userService; _mailService = mailService; _referenceEventService = referenceEventService; _taxRateRepository = taxRateRepository; _userRepository = userRepository; _logger = logger; _btGateway = new BraintreeGateway { Environment = globalSettings.Braintree.Production ? Braintree.Environment.PRODUCTION : Braintree.Environment.SANDBOX, MerchantId = globalSettings.Braintree.MerchantId, PublicKey = globalSettings.Braintree.PublicKey, PrivateKey = globalSettings.Braintree.PrivateKey }; _currentContext = currentContext; _globalSettings = globalSettings; _stripeEventService = stripeEventService; _stripeFacade = stripeFacade; _featureService = featureService; _providerRepository = providerRepository; } [HttpPost("webhook")] public async Task PostWebhook([FromQuery] string key) { if (!CoreHelpers.FixedTimeEquals(key, _billingSettings.StripeWebhookKey)) { return new BadRequestResult(); } var parsedEvent = await TryParseEventFromRequestBodyAsync(); if (parsedEvent is null) { return Ok(); } if (StripeConfiguration.ApiVersion != parsedEvent.ApiVersion) { _logger.LogWarning( "Stripe {WebhookType} webhook's API version ({WebhookAPIVersion}) does not match SDK API Version ({SDKAPIVersion})", parsedEvent.Type, parsedEvent.ApiVersion, StripeConfiguration.ApiVersion); return new OkResult(); } if (string.IsNullOrWhiteSpace(parsedEvent?.Id)) { _logger.LogWarning("No event id."); return new BadRequestResult(); } if (_hostingEnvironment.IsProduction() && !parsedEvent.Livemode) { _logger.LogWarning("Getting test events in production."); return new BadRequestResult(); } // If the customer and server cloud regions don't match, early return 200 to avoid unnecessary errors if (!await _stripeEventService.ValidateCloudRegion(parsedEvent)) { return new OkResult(); } switch (parsedEvent.Type) { case HandledStripeWebhook.SubscriptionDeleted: { await HandleCustomerSubscriptionDeletedEventAsync(parsedEvent); return Ok(); } case HandledStripeWebhook.SubscriptionUpdated: { await HandleCustomerSubscriptionUpdatedEventAsync(parsedEvent); return Ok(); } case HandledStripeWebhook.UpcomingInvoice: { await HandleUpcomingInvoiceEventAsync(parsedEvent); return Ok(); } case HandledStripeWebhook.ChargeSucceeded: { await HandleChargeSucceededEventAsync(parsedEvent); return Ok(); } case HandledStripeWebhook.ChargeRefunded: { await HandleChargeRefundedEventAsync(parsedEvent); return Ok(); } case HandledStripeWebhook.PaymentSucceeded: { await HandlePaymentSucceededEventAsync(parsedEvent); return Ok(); } case HandledStripeWebhook.PaymentFailed: { await HandlePaymentFailedEventAsync(parsedEvent); return Ok(); } case HandledStripeWebhook.InvoiceCreated: { await HandleInvoiceCreatedEventAsync(parsedEvent); return Ok(); } case HandledStripeWebhook.PaymentMethodAttached: { await HandlePaymentMethodAttachedAsync(parsedEvent); return Ok(); } case HandledStripeWebhook.CustomerUpdated: { await HandleCustomerUpdatedEventAsync(parsedEvent); return Ok(); } default: { _logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type); return Ok(); } } } /// /// Handles the event type from Stripe. /// /// private async Task HandleCustomerSubscriptionUpdatedEventAsync(Event parsedEvent) { var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts"]); var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata); switch (subscription.Status) { case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired when organizationId.HasValue: { await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd); break; } case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired: { if (!userId.HasValue) { break; } if (subscription.Status is StripeSubscriptionStatus.Unpaid && subscription.Items.Any(i => i.Price.Id is PremiumPlanId or PremiumPlanIdAppStore)) { await CancelSubscription(subscription.Id); await VoidOpenInvoices(subscription.Id); } await _userService.DisablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); break; } case StripeSubscriptionStatus.Active when organizationId.HasValue: { await _organizationService.EnableAsync(organizationId.Value); break; } case StripeSubscriptionStatus.Active: { if (userId.HasValue) { await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); } break; } } if (organizationId.HasValue) { await _organizationService.UpdateExpirationDateAsync(organizationId.Value, subscription.CurrentPeriodEnd); if (IsSponsoredSubscription(subscription)) { await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, subscription.CurrentPeriodEnd); } await RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(parsedEvent, subscription); } else if (userId.HasValue) { await _userService.UpdatePremiumExpirationAsync(userId.Value, subscription.CurrentPeriodEnd); } } /// /// Removes the Password Manager coupon if the organization is removing the Secrets Manager trial. /// Only applies to organizations that have a subscription from the Secrets Manager trial. /// /// /// private async Task RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(Event parsedEvent, Subscription subscription) { if (parsedEvent.Data.PreviousAttributes?.items is null) { return; } var previousSubscription = parsedEvent.Data .PreviousAttributes .ToObject() as Subscription; // This being false doesn't necessarily mean that the organization doesn't subscribe to Secrets Manager. // If there are changes to any subscription item, Stripe sends every item in the subscription, both // changed and unchanged. var previousSubscriptionHasSecretsManager = previousSubscription?.Items is not null && previousSubscription.Items.Any(previousItem => StaticStore.Plans.Any(p => p.SecretsManager is not null && p.SecretsManager.StripeSeatPlanId == previousItem.Plan.Id)); var currentSubscriptionHasSecretsManager = subscription.Items.Any(i => StaticStore.Plans.Any(p => p.SecretsManager is not null && p.SecretsManager.StripeSeatPlanId == i.Plan.Id)); if (!previousSubscriptionHasSecretsManager || currentSubscriptionHasSecretsManager) { return; } var customerHasSecretsManagerTrial = subscription.Customer ?.Discount ?.Coupon ?.Id == "sm-standalone"; var subscriptionHasSecretsManagerTrial = subscription.Discount ?.Coupon ?.Id == "sm-standalone"; if (customerHasSecretsManagerTrial) { await _stripeFacade.DeleteCustomerDiscount(subscription.CustomerId); } if (subscriptionHasSecretsManagerTrial) { await _stripeFacade.DeleteSubscriptionDiscount(subscription.Id); } } /// /// Handles the event type from Stripe. /// /// private async Task HandleCustomerSubscriptionDeletedEventAsync(Event parsedEvent) { var subscription = await _stripeEventService.GetSubscription(parsedEvent, true); var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata); var subCanceled = subscription.Status == StripeSubscriptionStatus.Canceled; if (!subCanceled) { return; } if (organizationId.HasValue) { await _organizationService.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd); } else if (userId.HasValue) { await _userService.DisablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); } } /// /// Handles the event type from Stripe. /// /// private async Task HandleCustomerUpdatedEventAsync(Event parsedEvent) { var customer = await _stripeEventService.GetCustomer(parsedEvent, true, ["subscriptions"]); if (customer.Subscriptions == null || !customer.Subscriptions.Any()) { return; } var subscription = customer.Subscriptions.First(); var (organizationId, _, providerId) = GetIdsFromMetadata(subscription.Metadata); if (!organizationId.HasValue) { return; } var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); organization.BillingEmail = customer.Email; await _organizationRepository.ReplaceAsync(organization); await _referenceEventService.RaiseEventAsync( new ReferenceEvent(ReferenceEventType.OrganizationEditedInStripe, organization, _currentContext)); } /// /// Handles the event type from Stripe. /// /// private async Task HandleInvoiceCreatedEventAsync(Event parsedEvent) { var invoice = await _stripeEventService.GetInvoice(parsedEvent, true); if (invoice.Paid || !ShouldAttemptToPayInvoice(invoice)) { return; } await AttemptToPayInvoiceAsync(invoice); } /// /// Handles the event type from Stripe. /// /// private async Task HandlePaymentSucceededEventAsync(Event parsedEvent) { var invoice = await _stripeEventService.GetInvoice(parsedEvent, true); if (!invoice.Paid || invoice.BillingReason != "subscription_create") { return; } var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); if (subscription?.Status != StripeSubscriptionStatus.Active) { return; } if (DateTime.UtcNow - invoice.Created < TimeSpan.FromMinutes(1)) { await Task.Delay(5000); } var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata); if (providerId.HasValue) { var provider = await _providerRepository.GetByIdAsync(providerId.Value); if (provider == null) { _logger.LogError( "Received invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) that does not exist", parsedEvent.Id, providerId.Value); return; } var teamsMonthly = StaticStore.GetPlan(PlanType.TeamsMonthly); var enterpriseMonthly = StaticStore.GetPlan(PlanType.EnterpriseMonthly); var teamsMonthlyLineItem = subscription.Items.Data.FirstOrDefault(item => item.Plan.Id == teamsMonthly.PasswordManager.StripeSeatPlanId); var enterpriseMonthlyLineItem = subscription.Items.Data.FirstOrDefault(item => item.Plan.Id == enterpriseMonthly.PasswordManager.StripeSeatPlanId); if (teamsMonthlyLineItem == null || enterpriseMonthlyLineItem == null) { _logger.LogError("invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) indicates missing subscription line items", parsedEvent.Id, provider.Id); return; } await _referenceEventService.RaiseEventAsync(new ReferenceEvent { Type = ReferenceEventType.Rebilled, Source = ReferenceEventSource.Provider, Id = provider.Id, PlanType = PlanType.TeamsMonthly, Seats = (int)teamsMonthlyLineItem.Quantity }); await _referenceEventService.RaiseEventAsync(new ReferenceEvent { Type = ReferenceEventType.Rebilled, Source = ReferenceEventSource.Provider, Id = provider.Id, PlanType = PlanType.EnterpriseMonthly, Seats = (int)enterpriseMonthlyLineItem.Quantity }); } else if (organizationId.HasValue) { if (!subscription.Items.Any(i => StaticStore.Plans.Any(p => p.PasswordManager.StripePlanId == i.Plan.Id))) { return; } await _organizationService.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd); var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); await _referenceEventService.RaiseEventAsync( new ReferenceEvent(ReferenceEventType.Rebilled, organization, _currentContext) { PlanName = organization?.Plan, PlanType = organization?.PlanType, Seats = organization?.Seats, Storage = organization?.MaxStorageGb, }); } else if (userId.HasValue) { if (subscription.Items.All(i => i.Plan.Id != PremiumPlanId)) { return; } await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); var user = await _userRepository.GetByIdAsync(userId.Value); await _referenceEventService.RaiseEventAsync( new ReferenceEvent(ReferenceEventType.Rebilled, user, _currentContext) { PlanName = PremiumPlanId, Storage = user?.MaxStorageGb, }); } } /// /// Handles the event type from Stripe. /// /// private async Task HandleChargeRefundedEventAsync(Event parsedEvent) { var charge = await _stripeEventService.GetCharge(parsedEvent, true, ["refunds"]); var parentTransaction = await _transactionRepository.GetByGatewayIdAsync(GatewayType.Stripe, charge.Id); if (parentTransaction == null) { // Attempt to create a transaction for the charge if it doesn't exist var (organizationId, userId, providerId) = await GetEntityIdsFromChargeAsync(charge); var tx = FromChargeToTransaction(charge, organizationId, userId, providerId); try { parentTransaction = await _transactionRepository.CreateAsync(tx); } catch (SqlException e) when (e.Number == 547) // FK constraint violation { _logger.LogWarning( "Charge refund could not create transaction as entity may have been deleted. {ChargeId}", charge.Id); return; } } var amountRefunded = charge.AmountRefunded / 100M; if (parentTransaction.Refunded.GetValueOrDefault() || parentTransaction.RefundedAmount.GetValueOrDefault() >= amountRefunded) { _logger.LogWarning( "Charge refund amount doesn't match parent transaction's amount or parent has already been refunded. {ChargeId}", charge.Id); return; } parentTransaction.RefundedAmount = amountRefunded; if (charge.Refunded) { parentTransaction.Refunded = true; } await _transactionRepository.ReplaceAsync(parentTransaction); foreach (var refund in charge.Refunds) { var refundTransaction = await _transactionRepository.GetByGatewayIdAsync( GatewayType.Stripe, refund.Id); if (refundTransaction != null) { continue; } await _transactionRepository.CreateAsync(new Transaction { Amount = refund.Amount / 100M, CreationDate = refund.Created, OrganizationId = parentTransaction.OrganizationId, UserId = parentTransaction.UserId, ProviderId = parentTransaction.ProviderId, Type = TransactionType.Refund, Gateway = GatewayType.Stripe, GatewayId = refund.Id, PaymentMethodType = parentTransaction.PaymentMethodType, Details = parentTransaction.Details }); } } /// /// Handles the event type from Stripe. /// /// private async Task HandleChargeSucceededEventAsync(Event parsedEvent) { var charge = await _stripeEventService.GetCharge(parsedEvent); var existingTransaction = await _transactionRepository.GetByGatewayIdAsync(GatewayType.Stripe, charge.Id); if (existingTransaction is not null) { _logger.LogInformation("Charge success already processed. {ChargeId}", charge.Id); return; } var (organizationId, userId, providerId) = await GetEntityIdsFromChargeAsync(charge); if (!organizationId.HasValue && !userId.HasValue && !providerId.HasValue) { _logger.LogWarning("Charge success has no subscriber ids. {ChargeId}", charge.Id); return; } var transaction = FromChargeToTransaction(charge, organizationId, userId, providerId); if (!transaction.PaymentMethodType.HasValue) { _logger.LogWarning("Charge success from unsupported source/method. {ChargeId}", charge.Id); return; } try { await _transactionRepository.CreateAsync(transaction); } catch (SqlException e) when (e.Number == 547) { _logger.LogWarning( "Charge success could not create transaction as entity may have been deleted. {ChargeId}", charge.Id); } } /// /// Handles the event type from Stripe. /// /// /// private async Task HandleUpcomingInvoiceEventAsync(Event parsedEvent) { var invoice = await _stripeEventService.GetInvoice(parsedEvent); if (string.IsNullOrEmpty(invoice.SubscriptionId)) { _logger.LogWarning("Received 'invoice.upcoming' Event with ID '{eventId}' that did not include a Subscription ID", parsedEvent.Id); return; } var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); if (subscription == null) { throw new Exception( $"Received null Subscription from Stripe for ID '{invoice.SubscriptionId}' while processing Event with ID '{parsedEvent.Id}'"); } var pm5766AutomaticTaxIsEnabled = _featureService.IsEnabled(FeatureFlagKeys.PM5766AutomaticTax); if (pm5766AutomaticTaxIsEnabled) { var customerGetOptions = new CustomerGetOptions(); customerGetOptions.AddExpand("tax"); var customer = await _stripeFacade.GetCustomer(subscription.CustomerId, customerGetOptions); if (!subscription.AutomaticTax.Enabled && customer.Tax?.AutomaticTax == StripeConstants.AutomaticTaxStatus.Supported) { subscription = await _stripeFacade.UpdateSubscription(subscription.Id, new SubscriptionUpdateOptions { DefaultTaxRates = [], AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } }); } } var updatedSubscription = pm5766AutomaticTaxIsEnabled ? subscription : await VerifyCorrectTaxRateForCharge(invoice, subscription); var (organizationId, userId, providerId) = GetIdsFromMetadata(updatedSubscription.Metadata); var invoiceLineItemDescriptions = invoice.Lines.Select(i => i.Description).ToList(); if (organizationId.HasValue) { if (IsSponsoredSubscription(updatedSubscription)) { await _validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value); } var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); if (organization == null || !OrgPlanForInvoiceNotifications(organization)) { return; } await SendEmails(new List { organization.BillingEmail }); /* * TODO: https://bitwarden.atlassian.net/browse/PM-4862 * Disabling this as part of a hot fix. It needs to check whether the organization * belongs to a Reseller provider and only send an email to the organization owners if it does. * It also requires a new email template as the current one contains too much billing information. */ // var ownerEmails = await _organizationRepository.GetOwnerEmailAddressesById(organization.Id); // await SendEmails(ownerEmails); } else if (userId.HasValue) { var user = await _userService.GetUserByIdAsync(userId.Value); if (user?.Premium == true) { await SendEmails(new List { user.Email }); } } else if (providerId.HasValue) { var provider = await _providerRepository.GetByIdAsync(providerId.Value); if (provider == null) { _logger.LogError( "Received invoice.Upcoming webhook ({EventID}) for Provider ({ProviderID}) that does not exist", parsedEvent.Id, providerId.Value); return; } await SendEmails(new List { provider.BillingEmail }); } return; /* * Sends emails to the given email addresses. */ async Task SendEmails(IEnumerable emails) { var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); if (invoice.NextPaymentAttempt.HasValue) { await _mailService.SendInvoiceUpcoming( validEmails, invoice.AmountDue / 100M, invoice.NextPaymentAttempt.Value, invoiceLineItemDescriptions, true); } } } /// /// Gets the organization or user ID from the metadata of a Stripe Charge object. /// /// /// private async Task<(Guid?, Guid?, Guid?)> GetEntityIdsFromChargeAsync(Charge charge) { Guid? organizationId = null; Guid? userId = null; Guid? providerId = null; if (charge.InvoiceId != null) { var invoice = await _stripeFacade.GetInvoice(charge.InvoiceId); if (invoice?.SubscriptionId != null) { var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); (organizationId, userId, providerId) = GetIdsFromMetadata(subscription?.Metadata); } } if (organizationId.HasValue || userId.HasValue || providerId.HasValue) { return (organizationId, userId, providerId); } var subscriptions = await _stripeFacade.ListSubscriptions(new SubscriptionListOptions { Customer = charge.CustomerId }); foreach (var subscription in subscriptions) { if (subscription.Status is StripeSubscriptionStatus.Canceled or StripeSubscriptionStatus.IncompleteExpired) { continue; } (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata); if (organizationId.HasValue || userId.HasValue || providerId.HasValue) { return (organizationId, userId, providerId); } } return (null, null, null); } /// /// Converts a Stripe Charge object to a Bitwarden Transaction object. /// /// /// /// /// /// /// private static Transaction FromChargeToTransaction(Charge charge, Guid? organizationId, Guid? userId, Guid? providerId) { var transaction = new Transaction { Amount = charge.Amount / 100M, CreationDate = charge.Created, OrganizationId = organizationId, UserId = userId, ProviderId = providerId, Type = TransactionType.Charge, Gateway = GatewayType.Stripe, GatewayId = charge.Id }; switch (charge.Source) { case Card card: { transaction.PaymentMethodType = PaymentMethodType.Card; transaction.Details = $"{card.Brand}, *{card.Last4}"; break; } case BankAccount bankAccount: { transaction.PaymentMethodType = PaymentMethodType.BankAccount; transaction.Details = $"{bankAccount.BankName}, *{bankAccount.Last4}"; break; } case Source { Card: not null } source: { transaction.PaymentMethodType = PaymentMethodType.Card; transaction.Details = $"{source.Card.Brand}, *{source.Card.Last4}"; break; } case Source { AchDebit: not null } source: { transaction.PaymentMethodType = PaymentMethodType.BankAccount; transaction.Details = $"{source.AchDebit.BankName}, *{source.AchDebit.Last4}"; break; } case Source source: { if (source.AchCreditTransfer == null) { break; } var achCreditTransfer = source.AchCreditTransfer; transaction.PaymentMethodType = PaymentMethodType.BankAccount; transaction.Details = $"ACH => {achCreditTransfer.BankName}, {achCreditTransfer.AccountNumber}"; break; } default: { if (charge.PaymentMethodDetails == null) { break; } if (charge.PaymentMethodDetails.Card != null) { var card = charge.PaymentMethodDetails.Card; transaction.PaymentMethodType = PaymentMethodType.Card; transaction.Details = $"{card.Brand?.ToUpperInvariant()}, *{card.Last4}"; } else if (charge.PaymentMethodDetails.AchDebit != null) { var achDebit = charge.PaymentMethodDetails.AchDebit; transaction.PaymentMethodType = PaymentMethodType.BankAccount; transaction.Details = $"{achDebit.BankName}, *{achDebit.Last4}"; } else if (charge.PaymentMethodDetails.AchCreditTransfer != null) { var achCreditTransfer = charge.PaymentMethodDetails.AchCreditTransfer; transaction.PaymentMethodType = PaymentMethodType.BankAccount; transaction.Details = $"ACH => {achCreditTransfer.BankName}, {achCreditTransfer.AccountNumber}"; } break; } } return transaction; } /// /// Handles the event type from Stripe. /// /// private async Task HandlePaymentMethodAttachedAsync(Event parsedEvent) { var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent); if (paymentMethod is null) { _logger.LogWarning("Attempted to handle the event payment_method.attached but paymentMethod was null"); return; } var subscriptionListOptions = new SubscriptionListOptions { Customer = paymentMethod.CustomerId, Status = StripeSubscriptionStatus.Unpaid, Expand = ["data.latest_invoice"] }; StripeList unpaidSubscriptions; try { unpaidSubscriptions = await _stripeFacade.ListSubscriptions(subscriptionListOptions); } catch (Exception e) { _logger.LogError(e, "Attempted to get unpaid invoices for customer {CustomerId} but encountered an error while calling Stripe", paymentMethod.CustomerId); return; } foreach (var unpaidSubscription in unpaidSubscriptions) { await AttemptToPayOpenSubscriptionAsync(unpaidSubscription); } } private async Task AttemptToPayOpenSubscriptionAsync(Subscription unpaidSubscription) { var latestInvoice = unpaidSubscription.LatestInvoice; if (unpaidSubscription.LatestInvoice is null) { _logger.LogWarning( "Attempted to pay unpaid subscription {SubscriptionId} but latest invoice didn't exist", unpaidSubscription.Id); return; } if (latestInvoice.Status != StripeInvoiceStatus.Open) { _logger.LogWarning( "Attempted to pay unpaid subscription {SubscriptionId} but latest invoice wasn't \"open\"", unpaidSubscription.Id); return; } try { await AttemptToPayInvoiceAsync(latestInvoice, true); } catch (Exception e) { _logger.LogError(e, "Attempted to pay open invoice {InvoiceId} on unpaid subscription {SubscriptionId} but encountered an error", latestInvoice.Id, unpaidSubscription.Id); throw; } } /// /// Gets the organizationId, userId, or providerId from the metadata of a Stripe Subscription object. /// /// /// private static Tuple GetIdsFromMetadata(Dictionary metadata) { if (metadata == null || metadata.Count == 0) { return new Tuple(null, null, null); } metadata.TryGetValue("organizationId", out var orgIdString); metadata.TryGetValue("userId", out var userIdString); metadata.TryGetValue("providerId", out var providerIdString); orgIdString ??= metadata.FirstOrDefault(x => x.Key.Equals("organizationId", StringComparison.OrdinalIgnoreCase)).Value; userIdString ??= metadata.FirstOrDefault(x => x.Key.Equals("userId", StringComparison.OrdinalIgnoreCase)).Value; providerIdString ??= metadata.FirstOrDefault(x => x.Key.Equals("providerId", StringComparison.OrdinalIgnoreCase)).Value; Guid? organizationId = string.IsNullOrWhiteSpace(orgIdString) ? null : new Guid(orgIdString); Guid? userId = string.IsNullOrWhiteSpace(userIdString) ? null : new Guid(userIdString); Guid? providerId = string.IsNullOrWhiteSpace(providerIdString) ? null : new Guid(providerIdString); return new Tuple(organizationId, userId, providerId); } private static bool OrgPlanForInvoiceNotifications(Organization org) => StaticStore.GetPlan(org.PlanType).IsAnnual; private async Task AttemptToPayInvoiceAsync(Invoice invoice, bool attemptToPayWithStripe = false) { var customer = await _stripeFacade.GetCustomer(invoice.CustomerId); if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false) { return await AttemptToPayInvoiceWithBraintreeAsync(invoice, customer); } if (attemptToPayWithStripe) { return await AttemptToPayInvoiceWithStripeAsync(invoice); } return false; } private async Task AttemptToPayInvoiceWithBraintreeAsync(Invoice invoice, Customer customer) { _logger.LogDebug("Attempting to pay invoice with Braintree"); if (!customer?.Metadata?.ContainsKey("btCustomerId") ?? true) { _logger.LogWarning( "Attempted to pay invoice with Braintree but btCustomerId wasn't on Stripe customer metadata"); return false; } var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription?.Metadata); if (!organizationId.HasValue && !userId.HasValue) { _logger.LogWarning( "Attempted to pay invoice with Braintree but Stripe subscription metadata didn't contain either a organizationId or userId"); return false; } var orgTransaction = organizationId.HasValue; var btObjIdField = orgTransaction ? "organization_id" : "user_id"; var btObjId = organizationId ?? userId.Value; var btInvoiceAmount = invoice.AmountDue / 100M; var existingTransactions = orgTransaction ? await _transactionRepository.GetManyByOrganizationIdAsync(organizationId.Value) : await _transactionRepository.GetManyByUserIdAsync(userId.Value); var duplicateTimeSpan = TimeSpan.FromHours(24); var now = DateTime.UtcNow; var duplicateTransaction = existingTransactions? .FirstOrDefault(t => (now - t.CreationDate) < duplicateTimeSpan); if (duplicateTransaction != null) { _logger.LogWarning("There is already a recent PayPal transaction ({0}). " + "Do not charge again to prevent possible duplicate.", duplicateTransaction.GatewayId); return false; } Result transactionResult; try { 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 = $"{btObjIdField}:{btObjId},region:{_globalSettings.BaseServiceUri.CloudRegion}" } }, CustomFields = new Dictionary { [btObjIdField] = btObjId.ToString(), ["region"] = _globalSettings.BaseServiceUri.CloudRegion } }); } catch (NotFoundException e) { _logger.LogError(e, "Attempted to make a payment with Braintree, but customer did not exist for the given btCustomerId present on the Stripe metadata"); throw; } if (!transactionResult.IsSuccess()) { if (invoice.AttemptCount < 4) { await _mailService.SendPaymentFailedAsync(customer.Email, btInvoiceAmount, true); } return false; } try { await _stripeFacade.UpdateInvoice(invoice.Id, new InvoiceUpdateOptions { Metadata = new Dictionary { ["btTransactionId"] = transactionResult.Target.Id, ["btPayPalTransactionId"] = transactionResult.Target.PayPalDetails?.AuthorizationId } }); await _stripeFacade.PayInvoice(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true }); } catch (Exception e) { await _btGateway.Transaction.RefundAsync(transactionResult.Target.Id); if (e.Message.Contains("Invoice is already paid")) { await _stripeFacade.UpdateInvoice(invoice.Id, new InvoiceUpdateOptions { Metadata = invoice.Metadata }); } else { throw; } } return true; } private async Task AttemptToPayInvoiceWithStripeAsync(Invoice invoice) { try { await _stripeFacade.PayInvoice(invoice.Id); return true; } catch (Exception e) { _logger.LogWarning( e, "Exception occurred while trying to pay Stripe invoice with Id: {InvoiceId}", invoice.Id); throw; } } private static bool ShouldAttemptToPayInvoice(Invoice invoice) => invoice is { AmountDue: > 0, Paid: false, CollectionMethod: "charge_automatically", BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice", SubscriptionId: not null }; private async Task VerifyCorrectTaxRateForCharge(Invoice invoice, Subscription subscription) { if (string.IsNullOrWhiteSpace(invoice?.CustomerAddress?.Country) || string.IsNullOrWhiteSpace(invoice?.CustomerAddress?.PostalCode)) { return subscription; } var localBitwardenTaxRates = await _taxRateRepository.GetByLocationAsync( new TaxRate() { Country = invoice.CustomerAddress.Country, PostalCode = invoice.CustomerAddress.PostalCode } ); if (!localBitwardenTaxRates.Any()) { return subscription; } var stripeTaxRate = await _stripeFacade.GetTaxRate(localBitwardenTaxRates.First().Id); if (stripeTaxRate == null || subscription.DefaultTaxRates.Any(x => x == stripeTaxRate)) { return subscription; } subscription.DefaultTaxRates = [stripeTaxRate]; var subscriptionOptions = new SubscriptionUpdateOptions { DefaultTaxRates = [stripeTaxRate.Id] }; subscription = await _stripeFacade.UpdateSubscription(subscription.Id, subscriptionOptions); return subscription; } private static bool IsSponsoredSubscription(Subscription subscription) => StaticStore.SponsoredPlans .Any(p => subscription.Items .Any(i => i.Plan.Id == p.StripePlanId)); /// /// Handles the event type from Stripe. /// /// private async Task HandlePaymentFailedEventAsync(Event parsedEvent) { var invoice = await _stripeEventService.GetInvoice(parsedEvent, true); if (invoice.Paid || invoice.AttemptCount <= 1 || !ShouldAttemptToPayInvoice(invoice)) { return; } var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); // attempt count 4 = 11 days after initial failure if (invoice.AttemptCount <= 3 || !subscription.Items.Any(i => i.Price.Id is PremiumPlanId or PremiumPlanIdAppStore)) { await AttemptToPayInvoiceAsync(invoice); } } private async Task CancelSubscription(string subscriptionId) => await _stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions()); private async Task VoidOpenInvoices(string subscriptionId) { var options = new InvoiceListOptions { Status = StripeInvoiceStatus.Open, Subscription = subscriptionId }; var invoices = await _stripeFacade.ListInvoices(options); foreach (var invoice in invoices) { await _stripeFacade.VoidInvoice(invoice.Id); } } private string PickStripeWebhookSecret(string webhookBody) { var versionContainer = JsonSerializer.Deserialize(webhookBody); return versionContainer.ApiVersion switch { "2023-10-16" => _billingSettings.StripeWebhookSecret20231016, "2022-08-01" => _billingSettings.StripeWebhookSecret, _ => HandleDefault(versionContainer.ApiVersion) }; string HandleDefault(string version) { _logger.LogWarning( "Stripe webhook contained an recognized 'api_version': {ApiVersion}", version); return null; } } /// /// Attempts to pick the Stripe webhook secret from the JSON payload. /// /// Returns the event if the event was parsed, otherwise, null private async Task TryParseEventFromRequestBodyAsync() { using var sr = new StreamReader(HttpContext.Request.Body); var json = await sr.ReadToEndAsync(); var webhookSecret = PickStripeWebhookSecret(json); if (string.IsNullOrEmpty(webhookSecret)) { _logger.LogDebug("Unable to parse event. No webhook secret."); return null; } var parsedEvent = EventUtility.ConstructEvent( json, Request.Headers["Stripe-Signature"], webhookSecret, throwOnApiVersionMismatch: false); if (parsedEvent is not null) { return parsedEvent; } _logger.LogDebug("Stripe-Signature request header doesn't match configured Stripe webhook secret"); return null; } }