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);
}