From 052f760fbb3f23ffc11fb4f54924ac8c06d60cd7 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Tue, 31 May 2022 10:55:56 -0400 Subject: [PATCH] [fix] Cancel unpaid subscriptions (#2017) * [refactor] Create a static class for documenting handled stripe webhooks * [fix] Cancel unpaid subscriptions after 4 failed payments --- src/Billing/Constants/HandledStripeWebhook.cs | 14 +++++ src/Billing/Controllers/AppleController.cs | 2 +- src/Billing/Controllers/StripeController.cs | 60 +++++++++++++++---- 3 files changed, 62 insertions(+), 14 deletions(-) create mode 100644 src/Billing/Constants/HandledStripeWebhook.cs diff --git a/src/Billing/Constants/HandledStripeWebhook.cs b/src/Billing/Constants/HandledStripeWebhook.cs new file mode 100644 index 000000000..a26bfede0 --- /dev/null +++ b/src/Billing/Constants/HandledStripeWebhook.cs @@ -0,0 +1,14 @@ +namespace Bit.Billing.Constants +{ + public static class HandledStripeWebhook + { + public static string SubscriptionDeleted => "customer.subscription.deleted"; + public static string SubscriptionUpdated => "customer.subscriptions.updated"; + public static string UpcomingInvoice => "invoice.upcoming"; + public static string ChargeSucceeded => "charge.succeeded"; + public static string ChargeRefunded => "charge.refunded"; + public static string PaymentSucceeded => "invoice.payment_succeeded"; + public static string PaymentFailed => "invoice.payment_failed"; + public static string InvoiceCreated => "invoice.created"; + } +} diff --git a/src/Billing/Controllers/AppleController.cs b/src/Billing/Controllers/AppleController.cs index c3b9bf5fa..0a45f6a32 100644 --- a/src/Billing/Controllers/AppleController.cs +++ b/src/Billing/Controllers/AppleController.cs @@ -54,7 +54,7 @@ namespace Bit.Billing.Controllers try { var json = JsonSerializer.Serialize(JsonSerializer.Deserialize(body), JsonHelpers.Indented); - _logger.LogInformation(Constants.BypassFiltersEventId, "Apple IAP Notification:\n\n{0}", json); + _logger.LogInformation(Bit.Core.Constants.BypassFiltersEventId, "Apple IAP Notification:\n\n{0}", json); return new OkResult(); } catch (Exception e) diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index ac5dce95a..15742a39d 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -4,6 +4,7 @@ using System.Data.SqlClient; using System.IO; using System.Linq; using System.Threading.Tasks; +using Bit.Billing.Constants; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Business; @@ -113,8 +114,8 @@ namespace Bit.Billing.Controllers return new BadRequestResult(); } - var subDeleted = parsedEvent.Type.Equals("customer.subscription.deleted"); - var subUpdated = parsedEvent.Type.Equals("customer.subscription.updated"); + var subDeleted = parsedEvent.Type.Equals(HandledStripeWebhook.SubscriptionDeleted); + var subUpdated = parsedEvent.Type.Equals(HandledStripeWebhook.SubscriptionUpdated); if (subDeleted || subUpdated) { @@ -159,7 +160,7 @@ namespace Bit.Billing.Controllers } } } - else if (parsedEvent.Type.Equals("invoice.upcoming")) + else if (parsedEvent.Type.Equals(HandledStripeWebhook.UpcomingInvoice)) { var invoice = await GetInvoiceAsync(parsedEvent); var subscriptionService = new SubscriptionService(); @@ -205,7 +206,7 @@ namespace Bit.Billing.Controllers invoice.NextPaymentAttempt.Value, items, true); } } - else if (parsedEvent.Type.Equals("charge.succeeded")) + else if (parsedEvent.Type.Equals(HandledStripeWebhook.ChargeSucceeded)) { var charge = await GetChargeAsync(parsedEvent); var chargeTransaction = await _transactionRepository.GetByGatewayIdAsync( @@ -332,7 +333,7 @@ namespace Bit.Billing.Controllers // Catch foreign key violations because user/org could have been deleted. catch (SqlException e) when (e.Number == 547) { } } - else if (parsedEvent.Type.Equals("charge.refunded")) + else if (parsedEvent.Type.Equals(HandledStripeWebhook.ChargeRefunded)) { var charge = await GetChargeAsync(parsedEvent); var chargeTransaction = await _transactionRepository.GetByGatewayIdAsync( @@ -382,7 +383,7 @@ namespace Bit.Billing.Controllers _logger.LogWarning("Charge refund amount doesn't seem correct. " + charge.Id); } } - else if (parsedEvent.Type.Equals("invoice.payment_succeeded")) + else if (parsedEvent.Type.Equals(HandledStripeWebhook.PaymentSucceeded)) { var invoice = await GetInvoiceAsync(parsedEvent, true); if (invoice.Paid && invoice.BillingReason == "subscription_create") @@ -434,15 +435,11 @@ namespace Bit.Billing.Controllers } } } - else if (parsedEvent.Type.Equals("invoice.payment_failed")) + else if (parsedEvent.Type.Equals(HandledStripeWebhook.PaymentFailed)) { - var invoice = await GetInvoiceAsync(parsedEvent, true); - if (!invoice.Paid && invoice.AttemptCount > 1 && UnpaidAutoChargeInvoiceForSubscriptionCycle(invoice)) - { - await AttemptToPayInvoiceAsync(invoice); - } + await HandlePaymentFailed(await GetInvoiceAsync(parsedEvent, true)); } - else if (parsedEvent.Type.Equals("invoice.created")) + else if (parsedEvent.Type.Equals(HandledStripeWebhook.InvoiceCreated)) { var invoice = await GetInvoiceAsync(parsedEvent, true); if (!invoice.Paid && UnpaidAutoChargeInvoiceForSubscriptionCycle(invoice)) @@ -804,5 +801,42 @@ namespace Bit.Billing.Controllers private static bool IsSponsoredSubscription(Subscription subscription) => StaticStore.SponsoredPlans.Any(p => p.StripePlanId == subscription.Id); + + private async Task HandlePaymentFailed(Invoice invoice) + { + if (!invoice.Paid && invoice.AttemptCount > 1 && UnpaidAutoChargeInvoiceForSubscriptionCycle(invoice)) + { + // attempt count 4 = 11 days after initial failure + if (invoice.AttemptCount > 3) + { + await CancelSubscription(invoice.SubscriptionId); + await VoidOpenInvoices(invoice.SubscriptionId); + } + else + { + await AttemptToPayInvoiceAsync(invoice); + } + } + } + + private async Task CancelSubscription(string subscriptionId) + { + await new SubscriptionService().CancelAsync(subscriptionId, new SubscriptionCancelOptions()); + } + + private async Task VoidOpenInvoices(string subscriptionId) + { + var invoiceService = new InvoiceService(); + var options = new InvoiceListOptions + { + Status = "open", + Subscription = subscriptionId + }; + var invoices = invoiceService.List(options); + foreach (var invoice in invoices) + { + await invoiceService.VoidInvoiceAsync(invoice.Id); + } + } } }