diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index 1395b4081..ce0151673 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -23,7 +23,6 @@ using Stripe; using Customer = Stripe.Customer; using Event = Stripe.Event; using JsonSerializer = System.Text.Json.JsonSerializer; -using PaymentMethod = Stripe.PaymentMethod; using Subscription = Stripe.Subscription; using TaxRate = Bit.Core.Entities.TaxRate; using Transaction = Bit.Core.Entities.Transaction; @@ -113,20 +112,10 @@ public class StripeController : Controller return new BadRequestResult(); } - Event parsedEvent; - using (var sr = new StreamReader(HttpContext.Request.Body)) + var parsedEvent = await TryParseEventFromRequestBodyAsync(); + if (parsedEvent is null) { - var json = await sr.ReadToEndAsync(); - var webhookSecret = PickStripeWebhookSecret(json); - - if (string.IsNullOrEmpty(webhookSecret)) - { - return new OkResult(); - } - - parsedEvent = EventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"], - webhookSecret, - throwOnApiVersionMismatch: false); + return Ok(); } if (StripeConfiguration.ApiVersion != parsedEvent.ApiVersion) @@ -158,453 +147,686 @@ public class StripeController : Controller return new OkResult(); } - var subDeleted = parsedEvent.Type.Equals(HandledStripeWebhook.SubscriptionDeleted); - var subUpdated = parsedEvent.Type.Equals(HandledStripeWebhook.SubscriptionUpdated); - - if (subDeleted || subUpdated) + switch (parsedEvent.Type) { - var subscription = await _stripeEventService.GetSubscription(parsedEvent, true); - var ids = GetIdsFromMetaData(subscription.Metadata); - var organizationId = ids.Item1 ?? Guid.Empty; - var userId = ids.Item2 ?? Guid.Empty; - var subCanceled = subDeleted && subscription.Status == StripeSubscriptionStatus.Canceled; - var subUnpaid = subUpdated && subscription.Status == StripeSubscriptionStatus.Unpaid; - var subActive = subUpdated && subscription.Status == StripeSubscriptionStatus.Active; - var subIncompleteExpired = subUpdated && subscription.Status == StripeSubscriptionStatus.IncompleteExpired; - - if (subCanceled || subUnpaid || subIncompleteExpired) - { - // org - if (organizationId != Guid.Empty) + case HandledStripeWebhook.SubscriptionDeleted: { - await _organizationService.DisableAsync(organizationId, subscription.CurrentPeriodEnd); + await HandleCustomerSubscriptionDeletedEventAsync(parsedEvent); + return Ok(); } - // user - else if (userId != Guid.Empty) + case HandledStripeWebhook.SubscriptionUpdated: { - if (subUnpaid && subscription.Items.Any(i => i.Price.Id is PremiumPlanId or PremiumPlanIdAppStore)) + 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) = 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); } - var user = await _userService.GetUserByIdAsync(userId); - if (user?.Premium == true) + 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.DisablePremiumAsync(userId, subscription.CurrentPeriodEnd); + await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); } - } - } - if (subActive) - { - - if (organizationId != Guid.Empty) - { - await _organizationService.EnableAsync(organizationId); + break; } - else if (userId != Guid.Empty) - { - await _userService.EnablePremiumAsync(userId, - subscription.CurrentPeriodEnd); - } - } - - if (subUpdated) - { - // org - if (organizationId != Guid.Empty) - { - await _organizationService.UpdateExpirationDateAsync(organizationId, - subscription.CurrentPeriodEnd); - if (IsSponsoredSubscription(subscription)) - { - await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId, subscription.CurrentPeriodEnd); - } - } - // user - else if (userId != Guid.Empty) - { - await _userService.UpdatePremiumExpirationAsync(userId, - subscription.CurrentPeriodEnd); - } - } } - else if (parsedEvent.Type.Equals(HandledStripeWebhook.UpcomingInvoice)) + + if (organizationId.HasValue) { - var invoice = await _stripeEventService.GetInvoice(parsedEvent); - - if (string.IsNullOrEmpty(invoice.SubscriptionId)) + await _organizationService.UpdateExpirationDateAsync(organizationId.Value, subscription.CurrentPeriodEnd); + if (IsSponsoredSubscription(subscription)) { - _logger.LogWarning("Received 'invoice.upcoming' Event with ID '{eventId}' that did not include a Subscription ID", parsedEvent.Id); - return new OkResult(); + await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, subscription.CurrentPeriodEnd); } - 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) = GetIdsFromMetaData(updatedSubscription.Metadata); - - var invoiceLineItemDescriptions = invoice.Lines.Select(i => i.Description).ToList(); - - 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); - } - } - - if (organizationId.HasValue) - { - if (IsSponsoredSubscription(updatedSubscription)) - { - await _validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value); - } - - var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); - - if (organization == null || !OrgPlanForInvoiceNotifications(organization)) - { - return new OkResult(); - } - - 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 }); - } - } + await RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(parsedEvent, subscription); } - else if (parsedEvent.Type.Equals(HandledStripeWebhook.ChargeSucceeded)) + else if (userId.HasValue) { - var charge = await _stripeEventService.GetCharge(parsedEvent); - var chargeTransaction = await _transactionRepository.GetByGatewayIdAsync( - GatewayType.Stripe, charge.Id); - if (chargeTransaction != null) + 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) = 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, _) = 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) = GetIdsFromMetaData(subscription.Metadata); + if (organizationId.HasValue) + { + if (!subscription.Items.Any(i => + StaticStore.Plans.Any(p => p.PasswordManager.StripePlanId == i.Plan.Id))) { - _logger.LogWarning("Charge success already processed. " + charge.Id); - return new OkResult(); + return; } - Tuple ids = null; - Subscription subscription = null; + await _organizationService.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd); + var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); - if (charge.InvoiceId != null) - { - var invoice = await _stripeFacade.GetInvoice(charge.InvoiceId); - if (invoice?.SubscriptionId != null) + await _referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.Rebilled, organization, _currentContext) { - subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); - ids = GetIdsFromMetaData(subscription?.Metadata); - } - } - - if (subscription == null || ids == null || (ids.Item1.HasValue && ids.Item2.HasValue)) - { - var subscriptions = await _stripeFacade.ListSubscriptions(new SubscriptionListOptions - { - Customer = charge.CustomerId + PlanName = organization?.Plan, + PlanType = organization?.PlanType, + Seats = organization?.Seats, + Storage = organization?.MaxStorageGb, }); - foreach (var sub in subscriptions) - { - if (sub.Status != StripeSubscriptionStatus.Canceled && sub.Status != StripeSubscriptionStatus.IncompleteExpired) - { - ids = GetIdsFromMetaData(sub.Metadata); - if (ids.Item1.HasValue || ids.Item2.HasValue) - { - subscription = sub; - break; - } - } - } + } + else if (userId.HasValue) + { + if (subscription.Items.All(i => i.Plan.Id != PremiumPlanId)) + { + return; } - if (!ids.Item1.HasValue && !ids.Item2.HasValue) - { - _logger.LogWarning("Charge success has no subscriber ids. " + charge.Id); - return new BadRequestResult(); - } + await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); - var tx = new Transaction - { - Amount = charge.Amount / 100M, - CreationDate = charge.Created, - OrganizationId = ids.Item1, - UserId = ids.Item2, - Type = TransactionType.Charge, - Gateway = GatewayType.Stripe, - GatewayId = charge.Id - }; - - if (charge.Source != null && charge.Source is Card card) - { - tx.PaymentMethodType = PaymentMethodType.Card; - tx.Details = $"{card.Brand}, *{card.Last4}"; - } - else if (charge.Source != null && charge.Source is BankAccount bankAccount) - { - tx.PaymentMethodType = PaymentMethodType.BankAccount; - tx.Details = $"{bankAccount.BankName}, *{bankAccount.Last4}"; - } - else if (charge.Source != null && charge.Source is Source source) - { - if (source.Card != null) - { - tx.PaymentMethodType = PaymentMethodType.Card; - tx.Details = $"{source.Card.Brand}, *{source.Card.Last4}"; - } - else if (source.AchDebit != null) - { - tx.PaymentMethodType = PaymentMethodType.BankAccount; - tx.Details = $"{source.AchDebit.BankName}, *{source.AchDebit.Last4}"; - } - else if (source.AchCreditTransfer != null) - { - tx.PaymentMethodType = PaymentMethodType.BankAccount; - tx.Details = $"ACH => {source.AchCreditTransfer.BankName}, " + - $"{source.AchCreditTransfer.AccountNumber}"; - } - } - else if (charge.PaymentMethodDetails != null) - { - if (charge.PaymentMethodDetails.Card != null) - { - tx.PaymentMethodType = PaymentMethodType.Card; - tx.Details = $"{charge.PaymentMethodDetails.Card.Brand?.ToUpperInvariant()}, " + - $"*{charge.PaymentMethodDetails.Card.Last4}"; - } - else if (charge.PaymentMethodDetails.AchDebit != null) - { - tx.PaymentMethodType = PaymentMethodType.BankAccount; - tx.Details = $"{charge.PaymentMethodDetails.AchDebit.BankName}, " + - $"*{charge.PaymentMethodDetails.AchDebit.Last4}"; - } - else if (charge.PaymentMethodDetails.AchCreditTransfer != null) - { - tx.PaymentMethodType = PaymentMethodType.BankAccount; - tx.Details = $"ACH => {charge.PaymentMethodDetails.AchCreditTransfer.BankName}, " + - $"{charge.PaymentMethodDetails.AchCreditTransfer.AccountNumber}"; - } - } - - if (!tx.PaymentMethodType.HasValue) - { - _logger.LogWarning("Charge success from unsupported source/method. " + charge.Id); - return new OkResult(); - } + 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) = await GetEntityIdsFromChargeAsync(charge); + var tx = FromChargeToTransaction(charge, organizationId, userId); try { - await _transactionRepository.CreateAsync(tx); + 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; } - // Catch foreign key violations because user/org could have been deleted. - catch (SqlException e) when (e.Number == 547) { } } - else if (parsedEvent.Type.Equals(HandledStripeWebhook.ChargeRefunded)) + + var amountRefunded = charge.AmountRefunded / 100M; + + if (parentTransaction.Refunded.GetValueOrDefault() || + parentTransaction.RefundedAmount.GetValueOrDefault() >= amountRefunded) { - var charge = await _stripeEventService.GetCharge(parsedEvent, true, ["refunds"]); - var chargeTransaction = await _transactionRepository.GetByGatewayIdAsync( - GatewayType.Stripe, charge.Id); - if (chargeTransaction == null) + _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) { - throw new Exception("Cannot find refunded charge. " + charge.Id); + continue; } - var amountRefunded = charge.AmountRefunded / 100M; - - if (!chargeTransaction.Refunded.GetValueOrDefault() && - chargeTransaction.RefundedAmount.GetValueOrDefault() < amountRefunded) + await _transactionRepository.CreateAsync(new Transaction { - chargeTransaction.RefundedAmount = amountRefunded; - if (charge.Refunded) - { - chargeTransaction.Refunded = true; - } - await _transactionRepository.ReplaceAsync(chargeTransaction); + Amount = refund.Amount / 100M, + CreationDate = refund.Created, + OrganizationId = parentTransaction.OrganizationId, + UserId = parentTransaction.UserId, + Type = TransactionType.Refund, + Gateway = GatewayType.Stripe, + GatewayId = refund.Id, + PaymentMethodType = parentTransaction.PaymentMethodType, + Details = parentTransaction.Details + }); + } + } - foreach (var refund in charge.Refunds) - { - var refundTransaction = await _transactionRepository.GetByGatewayIdAsync( - GatewayType.Stripe, refund.Id); - if (refundTransaction != null) - { - continue; - } + /// + /// 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; + } - await _transactionRepository.CreateAsync(new Transaction + var (organizationId, userId) = await GetEntityIdsFromChargeAsync(charge); + if (!organizationId.HasValue && !userId.HasValue) + { + _logger.LogWarning("Charge success has no subscriber ids. {ChargeId}", charge.Id); + return; + } + + var transaction = FromChargeToTransaction(charge, organizationId, userId); + 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 { - Amount = refund.Amount / 100M, - CreationDate = refund.Created, - OrganizationId = chargeTransaction.OrganizationId, - UserId = chargeTransaction.UserId, - Type = TransactionType.Refund, - Gateway = GatewayType.Stripe, - GatewayId = refund.Id, - PaymentMethodType = chargeTransaction.PaymentMethodType, - Details = chargeTransaction.Details + DefaultTaxRates = [], + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } }); - } - } - else - { - _logger.LogWarning("Charge refund amount doesn't seem correct. " + charge.Id); } } - else if (parsedEvent.Type.Equals(HandledStripeWebhook.PaymentSucceeded)) + + var updatedSubscription = pm5766AutomaticTaxIsEnabled + ? subscription + : await VerifyCorrectTaxRateForCharge(invoice, subscription); + + var (organizationId, userId) = GetIdsFromMetaData(updatedSubscription.Metadata); + + var invoiceLineItemDescriptions = invoice.Lines.Select(i => i.Description).ToList(); + + if (organizationId.HasValue) { - var invoice = await _stripeEventService.GetInvoice(parsedEvent, true); - if (invoice.Paid && invoice.BillingReason == "subscription_create") + if (IsSponsoredSubscription(updatedSubscription)) { - var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); - if (subscription?.Status == StripeSubscriptionStatus.Active) - { - if (DateTime.UtcNow - invoice.Created < TimeSpan.FromMinutes(1)) - { - await Task.Delay(5000); - } - - var ids = GetIdsFromMetaData(subscription.Metadata); - // org - if (ids.Item1.HasValue) - { - if (subscription.Items.Any(i => StaticStore.Plans.Any(p => p.PasswordManager.StripePlanId == i.Plan.Id))) - { - await _organizationService.EnableAsync(ids.Item1.Value, subscription.CurrentPeriodEnd); - - var organization = await _organizationRepository.GetByIdAsync(ids.Item1.Value); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Rebilled, organization, _currentContext) - { - PlanName = organization?.Plan, - PlanType = organization?.PlanType, - Seats = organization?.Seats, - Storage = organization?.MaxStorageGb, - }); - } - } - // user - else if (ids.Item2.HasValue) - { - if (subscription.Items.Any(i => i.Plan.Id == PremiumPlanId)) - { - await _userService.EnablePremiumAsync(ids.Item2.Value, subscription.CurrentPeriodEnd); - - var user = await _userRepository.GetByIdAsync(ids.Item2.Value); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.Rebilled, user, _currentContext) - { - PlanName = PremiumPlanId, - Storage = user?.MaxStorageGb, - }); - } - } - } - } - } - else if (parsedEvent.Type.Equals(HandledStripeWebhook.PaymentFailed)) - { - await HandlePaymentFailed(await _stripeEventService.GetInvoice(parsedEvent, true)); - } - else if (parsedEvent.Type.Equals(HandledStripeWebhook.InvoiceCreated)) - { - var invoice = await _stripeEventService.GetInvoice(parsedEvent, true); - if (!invoice.Paid && UnpaidAutoChargeInvoiceForSubscriptionCycle(invoice)) - { - await AttemptToPayInvoiceAsync(invoice); - } - } - else if (parsedEvent.Type.Equals(HandledStripeWebhook.PaymentMethodAttached)) - { - var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent); - await HandlePaymentMethodAttachedAsync(paymentMethod); - } - else if (parsedEvent.Type.Equals(HandledStripeWebhook.CustomerUpdated)) - { - var customer = - await _stripeEventService.GetCustomer(parsedEvent, true, ["subscriptions"]); - - if (customer.Subscriptions == null || !customer.Subscriptions.Any()) - { - return new OkResult(); - } - - var subscription = customer.Subscriptions.First(); - - var (organizationId, _) = GetIdsFromMetaData(subscription.Metadata); - - if (!organizationId.HasValue) - { - return new OkResult(); + await _validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value); } var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); - organization.BillingEmail = customer.Email; - await _organizationRepository.ReplaceAsync(organization); - await _referenceEventService.RaiseEventAsync( - new ReferenceEvent(ReferenceEventType.OrganizationEditedInStripe, organization, _currentContext)); + 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 + else if (userId.HasValue) { - _logger.LogWarning("Unsupported event received. " + parsedEvent.Type); + var user = await _userService.GetUserByIdAsync(userId.Value); + + if (user?.Premium == true) + { + await SendEmails(new List { user.Email }); + } } - return new OkResult(); + 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); + } + } } - private async Task HandlePaymentMethodAttachedAsync(PaymentMethod paymentMethod) + /// + /// Gets the organization or user ID from the metadata of a Stripe Charge object. + /// + /// + /// + private async Task<(Guid?, Guid?)> GetEntityIdsFromChargeAsync(Charge charge) { + Guid? organizationId = null; + Guid? userId = 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) = GetIdsFromMetaData(subscription?.Metadata); + } + } + + if (organizationId.HasValue || userId.HasValue) + { + return (organizationId, userId); + } + + 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) = GetIdsFromMetaData(subscription.Metadata); + + if (organizationId.HasValue || userId.HasValue) + { + return (organizationId, userId); + } + } + + return (null, null); + } + + /// + /// Converts a Stripe Charge object to a Bitwarden Transaction object. + /// + /// + /// + /// + /// + private static Transaction FromChargeToTransaction(Charge charge, Guid? organizationId, Guid? userId) + { + var transaction = new Transaction + { + Amount = charge.Amount / 100M, + CreationDate = charge.Created, + OrganizationId = organizationId, + UserId = userId, + 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"); @@ -865,11 +1087,15 @@ public class StripeController : Controller } } - private bool UnpaidAutoChargeInvoiceForSubscriptionCycle(Invoice invoice) - { - return invoice.AmountDue > 0 && !invoice.Paid && invoice.CollectionMethod == "charge_automatically" && - invoice.BillingReason is "subscription_cycle" or "automatic_pending_invoice_item_invoice" && invoice.SubscriptionId != null; - } + 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) { @@ -909,17 +1135,24 @@ public class StripeController : Controller private static bool IsSponsoredSubscription(Subscription subscription) => StaticStore.SponsoredPlans.Any(p => p.StripePlanId == subscription.Id); - private async Task HandlePaymentFailed(Invoice invoice) + /// + /// Handles the event type from Stripe. + /// + /// + private async Task HandlePaymentFailedEventAsync(Event parsedEvent) { - if (!invoice.Paid && invoice.AttemptCount > 1 && UnpaidAutoChargeInvoiceForSubscriptionCycle(invoice)) + var invoice = await _stripeEventService.GetInvoice(parsedEvent, true); + if (invoice.Paid || invoice.AttemptCount <= 1 || !ShouldAttemptToPayInvoice(invoice)) { - 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); - } + 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); } } @@ -960,4 +1193,36 @@ public class StripeController : Controller 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; + } } diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs index 836f15aed..0791e507f 100644 --- a/src/Billing/Services/IStripeFacade.cs +++ b/src/Billing/Services/IStripeFacade.cs @@ -79,4 +79,14 @@ public interface IStripeFacade TaxRateGetOptions options = null, RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + + Task DeleteCustomerDiscount( + string customerId, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + + Task DeleteSubscriptionDiscount( + string subscriptionId, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); } diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs index fb42030e0..05ad9e0f4 100644 --- a/src/Billing/Services/Implementations/StripeFacade.cs +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -10,6 +10,7 @@ public class StripeFacade : IStripeFacade private readonly PaymentMethodService _paymentMethodService = new(); private readonly SubscriptionService _subscriptionService = new(); private readonly TaxRateService _taxRateService = new(); + private readonly DiscountService _discountService = new(); public async Task GetCharge( string chargeId, @@ -96,4 +97,16 @@ public class StripeFacade : IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default) => await _taxRateService.GetAsync(taxRateId, options, requestOptions, cancellationToken); + + public async Task DeleteCustomerDiscount( + string customerId, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _discountService.DeleteCustomerDiscountAsync(customerId, requestOptions, cancellationToken); + + public async Task DeleteSubscriptionDiscount( + string subscriptionId, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _discountService.DeleteSubscriptionDiscountAsync(subscriptionId, requestOptions, cancellationToken); }