mirror of
https://github.com/bitwarden/server.git
synced 2024-12-27 17:47:37 +01:00
[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
This commit is contained in:
parent
fae4d3ca1b
commit
8eee9b330d
@ -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";
|
||||
}
|
||||
|
10
src/Billing/Constants/StripeInvoiceStatus.cs
Normal file
10
src/Billing/Constants/StripeInvoiceStatus.cs
Normal file
@ -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";
|
||||
}
|
13
src/Billing/Constants/StripeSubscriptionStatus.cs
Normal file
13
src/Billing/Constants/StripeSubscriptionStatus.cs
Normal file
@ -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";
|
||||
}
|
@ -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<string> { "data.latest_invoice" }
|
||||
};
|
||||
|
||||
StripeList<Subscription> 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<Guid?, Guid?> GetIdsFromMetaData(IDictionary<string, string> metaData)
|
||||
{
|
||||
if (metaData == null || !metaData.Any())
|
||||
@ -631,7 +723,7 @@ public class StripeController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> AttemptToPayInvoiceAsync(Invoice invoice)
|
||||
private async Task<bool> 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<bool> 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<PaymentMethod> GetPaymentMethodAsync(Event parsedEvent, bool fresh = false,
|
||||
List<string> 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<Subscription> 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);
|
||||
|
Loading…
Reference in New Issue
Block a user