From 8eee9b330d828e0227c29f005a696102f8104ce7 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Mon, 28 Aug 2023 09:56:50 -0400 Subject: [PATCH] [AC-1223][AC-1184] Failed Renewals (#3158) * Added null checks when getting customer metadata * Added additional logging around paypal payments * Refactor region validation in StripeController * Update region retrieval method in StripeController Refactored the method GetCustomerRegionFromMetadata in StripeController. Previously, it returned null in case of nonexisting region key. Now, it checks all keys with case-insensitive comparison, and if no "region" key is found, it defaults to "US". This was done to handle cases where the region key might not be properly formatted or missing. * Updated switch expression to be switch statement * Updated new log to not log user input * Add handling for 'payment_method.attached' webhook * Cancelling unpaid premium subscriptions * Update hardcoded Stripe status strings to constants * Updated expand string to use snake_case * Removed unnecessary comments --- src/Billing/Constants/HandledStripeWebhook.cs | 1 + src/Billing/Constants/StripeInvoiceStatus.cs | 10 + .../Constants/StripeSubscriptionStatus.cs | 13 ++ src/Billing/Controllers/StripeController.cs | 171 ++++++++++++++++-- 4 files changed, 179 insertions(+), 16 deletions(-) create mode 100644 src/Billing/Constants/StripeInvoiceStatus.cs create mode 100644 src/Billing/Constants/StripeSubscriptionStatus.cs diff --git a/src/Billing/Constants/HandledStripeWebhook.cs b/src/Billing/Constants/HandledStripeWebhook.cs index f7baa4675..7b894a295 100644 --- a/src/Billing/Constants/HandledStripeWebhook.cs +++ b/src/Billing/Constants/HandledStripeWebhook.cs @@ -10,4 +10,5 @@ public static class HandledStripeWebhook public const string PaymentSucceeded = "invoice.payment_succeeded"; public const string PaymentFailed = "invoice.payment_failed"; public const string InvoiceCreated = "invoice.created"; + public const string PaymentMethodAttached = "payment_method.attached"; } diff --git a/src/Billing/Constants/StripeInvoiceStatus.cs b/src/Billing/Constants/StripeInvoiceStatus.cs new file mode 100644 index 000000000..82d286d8a --- /dev/null +++ b/src/Billing/Constants/StripeInvoiceStatus.cs @@ -0,0 +1,10 @@ +namespace Bit.Billing.Constants; + +public static class StripeInvoiceStatus +{ + public const string Draft = "draft"; + public const string Open = "open"; + public const string Paid = "paid"; + public const string Void = "void"; + public const string Uncollectible = "uncollectible"; +} diff --git a/src/Billing/Constants/StripeSubscriptionStatus.cs b/src/Billing/Constants/StripeSubscriptionStatus.cs new file mode 100644 index 000000000..4589b5005 --- /dev/null +++ b/src/Billing/Constants/StripeSubscriptionStatus.cs @@ -0,0 +1,13 @@ +namespace Bit.Billing.Constants; + +public static class StripeSubscriptionStatus +{ + public const string Trialing = "trialing"; + public const string Active = "active"; + public const string Incomplete = "incomplete"; + public const string IncompleteExpired = "incomplete_expired"; + public const string PastDue = "past_due"; + public const string Canceled = "canceled"; + public const string Unpaid = "unpaid"; + public const string Paused = "paused"; +} diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index 4a15541a1..00a8fa5ac 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -18,6 +18,7 @@ using Microsoft.Extensions.Options; using Stripe; using Customer = Stripe.Customer; using Event = Stripe.Event; +using PaymentMethod = Stripe.PaymentMethod; using Subscription = Stripe.Subscription; using TaxRate = Bit.Core.Entities.TaxRate; using Transaction = Bit.Core.Entities.Transaction; @@ -138,10 +139,10 @@ public class StripeController : Controller var ids = GetIdsFromMetaData(subscription.Metadata); var organizationId = ids.Item1 ?? Guid.Empty; var userId = ids.Item2 ?? Guid.Empty; - var subCanceled = subDeleted && subscription.Status == "canceled"; - var subUnpaid = subUpdated && subscription.Status == "unpaid"; - var subActive = subUpdated && subscription.Status == "active"; - var subIncompleteExpired = subUpdated && subscription.Status == "incomplete_expired"; + 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) { @@ -153,7 +154,17 @@ public class StripeController : Controller // user else if (userId != Guid.Empty) { - await _userService.DisablePremiumAsync(userId, subscription.CurrentPeriodEnd); + if (subUnpaid && 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) + { + await _userService.DisablePremiumAsync(userId, subscription.CurrentPeriodEnd); + } } } @@ -271,7 +282,7 @@ public class StripeController : Controller }); foreach (var sub in subscriptions) { - if (sub.Status != "canceled" && sub.Status != "incomplete_expired") + if (sub.Status != StripeSubscriptionStatus.Canceled && sub.Status != StripeSubscriptionStatus.IncompleteExpired) { ids = GetIdsFromMetaData(sub.Metadata); if (ids.Item1.HasValue || ids.Item2.HasValue) @@ -421,7 +432,7 @@ public class StripeController : Controller { var subscriptionService = new SubscriptionService(); var subscription = await subscriptionService.GetAsync(invoice.SubscriptionId); - if (subscription?.Status == "active") + if (subscription?.Status == StripeSubscriptionStatus.Active) { if (DateTime.UtcNow - invoice.Created < TimeSpan.FromMinutes(1)) { @@ -478,6 +489,11 @@ public class StripeController : Controller await AttemptToPayInvoiceAsync(invoice); } } + else if (parsedEvent.Type.Equals(HandledStripeWebhook.PaymentMethodAttached)) + { + var paymentMethod = await GetPaymentMethodAsync(parsedEvent); + await HandlePaymentMethodAttachedAsync(paymentMethod); + } else { _logger.LogWarning("Unsupported event received. " + parsedEvent.Type); @@ -522,6 +538,11 @@ public class StripeController : Controller case HandledStripeWebhook.InvoiceCreated: customerMetadata = (await GetInvoiceAsync(parsedEvent, true, expandOptions))?.Customer?.Metadata; break; + case HandledStripeWebhook.PaymentMethodAttached: + customerMetadata = (await GetPaymentMethodAsync(parsedEvent, true, expandOptions)) + ?.Customer + ?.Metadata; + break; default: customerMetadata = null; break; @@ -579,6 +600,77 @@ public class StripeController : Controller : defaultRegion; } + private async Task HandlePaymentMethodAttachedAsync(PaymentMethod paymentMethod) + { + if (paymentMethod is null) + { + _logger.LogWarning("Attempted to handle the event payment_method.attached but paymentMethod was null"); + return; + } + + var subscriptionService = new SubscriptionService(); + var subscriptionListOptions = new SubscriptionListOptions + { + Customer = paymentMethod.CustomerId, + Status = StripeSubscriptionStatus.Unpaid, + Expand = new List { "data.latest_invoice" } + }; + + StripeList unpaidSubscriptions; + try + { + unpaidSubscriptions = await subscriptionService.ListAsync(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; + } + } + private Tuple GetIdsFromMetaData(IDictionary metaData) { if (metaData == null || !metaData.Any()) @@ -631,7 +723,7 @@ public class StripeController : Controller } } - private async Task AttemptToPayInvoiceAsync(Invoice invoice) + private async Task AttemptToPayInvoiceAsync(Invoice invoice, bool attemptToPayWithStripe = false) { var customerService = new CustomerService(); var customer = await customerService.GetAsync(invoice.CustomerId); @@ -639,10 +731,17 @@ public class StripeController : Controller { return await AttemptToPayInvoiceWithAppleReceiptAsync(invoice, customer); } - else if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false) + + if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false) { return await AttemptToPayInvoiceWithBraintreeAsync(invoice, customer); } + + if (attemptToPayWithStripe) + { + return await AttemptToPayInvoiceWithStripeAsync(invoice); + } + return false; } @@ -851,6 +950,25 @@ public class StripeController : Controller return true; } + private async Task AttemptToPayInvoiceWithStripeAsync(Invoice invoice) + { + try + { + var invoiceService = new InvoiceService(); + await invoiceService.PayAsync(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 bool UnpaidAutoChargeInvoiceForSubscriptionCycle(Invoice invoice) { return invoice.AmountDue > 0 && !invoice.Paid && invoice.CollectionMethod == "charge_automatically" && @@ -935,6 +1053,31 @@ public class StripeController : Controller return customer; } + private async Task GetPaymentMethodAsync(Event parsedEvent, bool fresh = false, + List expandOptions = null) + { + if (parsedEvent.Data.Object is not PaymentMethod eventPaymentMethod) + { + throw new Exception("Invoice is null (from parsed event). " + parsedEvent.Id); + } + + if (!fresh) + { + return eventPaymentMethod; + } + + var paymentMethodService = new PaymentMethodService(); + var paymentMethodGetOptions = new PaymentMethodGetOptions { Expand = expandOptions }; + var paymentMethod = await paymentMethodService.GetAsync(eventPaymentMethod.Id, paymentMethodGetOptions); + + if (paymentMethod == null) + { + throw new Exception($"Payment method is null. {eventPaymentMethod.Id}"); + } + + return paymentMethod; + } + private async Task VerifyCorrectTaxRateForCharge(Invoice invoice, Subscription subscription) { if (!string.IsNullOrWhiteSpace(invoice?.CustomerAddress?.Country) && !string.IsNullOrWhiteSpace(invoice?.CustomerAddress?.PostalCode)) @@ -971,12 +1114,8 @@ public class StripeController : Controller var subscriptionService = new SubscriptionService(); var subscription = await subscriptionService.GetAsync(invoice.SubscriptionId); // attempt count 4 = 11 days after initial failure - if (invoice.AttemptCount > 3 && subscription.Items.Any(i => i.Price.Id == PremiumPlanId || i.Price.Id == PremiumPlanIdAppStore)) - { - await CancelSubscription(invoice.SubscriptionId); - await VoidOpenInvoices(invoice.SubscriptionId); - } - else + if (invoice.AttemptCount <= 3 || + !subscription.Items.Any(i => i.Price.Id is PremiumPlanId or PremiumPlanIdAppStore)) { await AttemptToPayInvoiceAsync(invoice); } @@ -993,7 +1132,7 @@ public class StripeController : Controller var invoiceService = new InvoiceService(); var options = new InvoiceListOptions { - Status = "open", + Status = StripeInvoiceStatus.Open, Subscription = subscriptionId }; var invoices = invoiceService.List(options);