1
0
mirror of https://github.com/bitwarden/server.git synced 2025-07-14 06:07:36 -05:00

[PM-212] Sync Organization Billing Email from Stripe Webhook (#3305)

* Add StripeFacade and StripeEventService

* Add StripeEventServiceTests

* Handle customer.updated event in StripeController
This commit is contained in:
Alex Morask
2023-10-11 15:57:51 -04:00
committed by GitHub
parent 3a71e7b081
commit b2af73f00f
19 changed files with 2316 additions and 214 deletions

View File

@ -0,0 +1,80 @@
using Stripe;
namespace Bit.Billing.Services;
public interface IStripeEventService
{
/// <summary>
/// Extracts the <see cref="Charge"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
/// uses the charge ID extracted from the event to retrieve the most up-to-update charge from Stripe's API
/// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param>
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the charge object from Stripe.</param>
/// <param name="expand">Optionally provided to expand the fresh charge object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="Charge"/>.</returns>
/// <exception cref="Exception">Thrown when the Stripe event does not contain a charge object.</exception>
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null charge object.</exception>
Task<Charge> GetCharge(Event stripeEvent, bool fresh = false, List<string> expand = null);
/// <summary>
/// Extracts the <see cref="Customer"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
/// uses the customer ID extracted from the event to retrieve the most up-to-update customer from Stripe's API
/// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param>
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the customer object from Stripe.</param>
/// <param name="expand">Optionally provided to expand the fresh customer object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="Customer"/>.</returns>
/// <exception cref="Exception">Thrown when the Stripe event does not contain a customer object.</exception>
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null customer object.</exception>
Task<Customer> GetCustomer(Event stripeEvent, bool fresh = false, List<string> expand = null);
/// <summary>
/// Extracts the <see cref="Invoice"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
/// uses the invoice ID extracted from the event to retrieve the most up-to-update invoice from Stripe's API
/// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param>
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the invoice object from Stripe.</param>
/// <param name="expand">Optionally provided to expand the fresh invoice object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="Invoice"/>.</returns>
/// <exception cref="Exception">Thrown when the Stripe event does not contain an invoice object.</exception>
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null invoice object.</exception>
Task<Invoice> GetInvoice(Event stripeEvent, bool fresh = false, List<string> expand = null);
/// <summary>
/// Extracts the <see cref="PaymentMethod"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
/// uses the payment method ID extracted from the event to retrieve the most up-to-update payment method from Stripe's API
/// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param>
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the payment method object from Stripe.</param>
/// <param name="expand">Optionally provided to expand the fresh payment method object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="PaymentMethod"/>.</returns>
/// <exception cref="Exception">Thrown when the Stripe event does not contain an payment method object.</exception>
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null payment method object.</exception>
Task<PaymentMethod> GetPaymentMethod(Event stripeEvent, bool fresh = false, List<string> expand = null);
/// <summary>
/// Extracts the <see cref="Subscription"/> object from the Stripe <see cref="Event"/>. When <paramref name="fresh"/> is true,
/// uses the subscription ID extracted from the event to retrieve the most up-to-update subscription from Stripe's API
/// and optionally expands it with the provided <see cref="expand"/> options.
/// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param>
/// <param name="fresh">Determines whether or not to retrieve a fresh copy of the subscription object from Stripe.</param>
/// <param name="expand">Optionally provided to expand the fresh subscription object retrieved from Stripe.</param>
/// <returns>A Stripe <see cref="Subscription"/>.</returns>
/// <exception cref="Exception">Thrown when the Stripe event does not contain an subscription object.</exception>
/// <exception cref="Exception">Thrown when <paramref name="fresh"/> is true and Stripe's API returns a null subscription object.</exception>
Task<Subscription> GetSubscription(Event stripeEvent, bool fresh = false, List<string> expand = null);
/// <summary>
/// Ensures that the customer associated with the Stripe <see cref="Event"/> is in the correct region for this server.
/// We use the customer instead of the subscription given that all subscriptions have customers, but not all
/// customers have subscriptions.
/// </summary>
/// <param name="stripeEvent">The Stripe webhook event.</param>
/// <returns>True if the customer's region and the server's region match, otherwise false.</returns>
Task<bool> ValidateCloudRegion(Event stripeEvent);
}

View File

@ -0,0 +1,36 @@
using Stripe;
namespace Bit.Billing.Services;
public interface IStripeFacade
{
Task<Charge> GetCharge(
string chargeId,
ChargeGetOptions chargeGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<Customer> GetCustomer(
string customerId,
CustomerGetOptions customerGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<Invoice> GetInvoice(
string invoiceId,
InvoiceGetOptions invoiceGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<PaymentMethod> GetPaymentMethod(
string paymentMethodId,
PaymentMethodGetOptions paymentMethodGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<Subscription> GetSubscription(
string subscriptionId,
SubscriptionGetOptions subscriptionGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,197 @@
using Bit.Billing.Constants;
using Bit.Core.Settings;
using Stripe;
namespace Bit.Billing.Services.Implementations;
public class StripeEventService : IStripeEventService
{
private readonly GlobalSettings _globalSettings;
private readonly IStripeFacade _stripeFacade;
public StripeEventService(
GlobalSettings globalSettings,
IStripeFacade stripeFacade)
{
_globalSettings = globalSettings;
_stripeFacade = stripeFacade;
}
public async Task<Charge> GetCharge(Event stripeEvent, bool fresh = false, List<string> expand = null)
{
var eventCharge = Extract<Charge>(stripeEvent);
if (!fresh)
{
return eventCharge;
}
var charge = await _stripeFacade.GetCharge(eventCharge.Id, new ChargeGetOptions { Expand = expand });
if (charge == null)
{
throw new Exception(
$"Received null Charge from Stripe for ID '{eventCharge.Id}' while processing Event with ID '{stripeEvent.Id}'");
}
return charge;
}
public async Task<Customer> GetCustomer(Event stripeEvent, bool fresh = false, List<string> expand = null)
{
var eventCustomer = Extract<Customer>(stripeEvent);
if (!fresh)
{
return eventCustomer;
}
var customer = await _stripeFacade.GetCustomer(eventCustomer.Id, new CustomerGetOptions { Expand = expand });
if (customer == null)
{
throw new Exception(
$"Received null Customer from Stripe for ID '{eventCustomer.Id}' while processing Event with ID '{stripeEvent.Id}'");
}
return customer;
}
public async Task<Invoice> GetInvoice(Event stripeEvent, bool fresh = false, List<string> expand = null)
{
var eventInvoice = Extract<Invoice>(stripeEvent);
if (!fresh)
{
return eventInvoice;
}
var invoice = await _stripeFacade.GetInvoice(eventInvoice.Id, new InvoiceGetOptions { Expand = expand });
if (invoice == null)
{
throw new Exception(
$"Received null Invoice from Stripe for ID '{eventInvoice.Id}' while processing Event with ID '{stripeEvent.Id}'");
}
return invoice;
}
public async Task<PaymentMethod> GetPaymentMethod(Event stripeEvent, bool fresh = false, List<string> expand = null)
{
var eventPaymentMethod = Extract<PaymentMethod>(stripeEvent);
if (!fresh)
{
return eventPaymentMethod;
}
var paymentMethod = await _stripeFacade.GetPaymentMethod(eventPaymentMethod.Id, new PaymentMethodGetOptions { Expand = expand });
if (paymentMethod == null)
{
throw new Exception(
$"Received null Payment Method from Stripe for ID '{eventPaymentMethod.Id}' while processing Event with ID '{stripeEvent.Id}'");
}
return paymentMethod;
}
public async Task<Subscription> GetSubscription(Event stripeEvent, bool fresh = false, List<string> expand = null)
{
var eventSubscription = Extract<Subscription>(stripeEvent);
if (!fresh)
{
return eventSubscription;
}
var subscription = await _stripeFacade.GetSubscription(eventSubscription.Id, new SubscriptionGetOptions { Expand = expand });
if (subscription == null)
{
throw new Exception(
$"Received null Subscription from Stripe for ID '{eventSubscription.Id}' while processing Event with ID '{stripeEvent.Id}'");
}
return subscription;
}
public async Task<bool> ValidateCloudRegion(Event stripeEvent)
{
var serverRegion = _globalSettings.BaseServiceUri.CloudRegion;
var customerExpansion = new List<string> { "customer" };
var customerMetadata = stripeEvent.Type switch
{
HandledStripeWebhook.SubscriptionDeleted or HandledStripeWebhook.SubscriptionUpdated =>
(await GetSubscription(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
HandledStripeWebhook.ChargeSucceeded or HandledStripeWebhook.ChargeRefunded =>
(await GetCharge(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
HandledStripeWebhook.UpcomingInvoice =>
(await GetInvoice(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed or HandledStripeWebhook.InvoiceCreated =>
(await GetInvoice(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
HandledStripeWebhook.PaymentMethodAttached =>
(await GetPaymentMethod(stripeEvent, true, customerExpansion))?.Customer?.Metadata,
HandledStripeWebhook.CustomerUpdated =>
(await GetCustomer(stripeEvent, true))?.Metadata,
_ => null
};
if (customerMetadata == null)
{
return false;
}
var customerRegion = GetCustomerRegion(customerMetadata);
return customerRegion == serverRegion;
}
private static T Extract<T>(Event stripeEvent)
{
if (stripeEvent.Data.Object is not T type)
{
throw new Exception($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{typeof(T).Name}'");
}
return type;
}
private static string GetCustomerRegion(IDictionary<string, string> customerMetadata)
{
const string defaultRegion = "US";
if (customerMetadata is null)
{
return null;
}
if (customerMetadata.TryGetValue("region", out var value))
{
return value;
}
var miscasedRegionKey = customerMetadata.Keys
.FirstOrDefault(key => key.Equals("region", StringComparison.OrdinalIgnoreCase));
if (miscasedRegionKey is null)
{
return defaultRegion;
}
_ = customerMetadata.TryGetValue(miscasedRegionKey, out var regionValue);
return !string.IsNullOrWhiteSpace(regionValue)
? regionValue
: defaultRegion;
}
}

View File

@ -0,0 +1,47 @@
using Stripe;
namespace Bit.Billing.Services.Implementations;
public class StripeFacade : IStripeFacade
{
private readonly ChargeService _chargeService = new();
private readonly CustomerService _customerService = new();
private readonly InvoiceService _invoiceService = new();
private readonly PaymentMethodService _paymentMethodService = new();
private readonly SubscriptionService _subscriptionService = new();
public async Task<Charge> GetCharge(
string chargeId,
ChargeGetOptions chargeGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default) =>
await _chargeService.GetAsync(chargeId, chargeGetOptions, requestOptions, cancellationToken);
public async Task<Customer> GetCustomer(
string customerId,
CustomerGetOptions customerGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default) =>
await _customerService.GetAsync(customerId, customerGetOptions, requestOptions, cancellationToken);
public async Task<Invoice> GetInvoice(
string invoiceId,
InvoiceGetOptions invoiceGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default) =>
await _invoiceService.GetAsync(invoiceId, invoiceGetOptions, requestOptions, cancellationToken);
public async Task<PaymentMethod> GetPaymentMethod(
string paymentMethodId,
PaymentMethodGetOptions paymentMethodGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default) =>
await _paymentMethodService.GetAsync(paymentMethodId, paymentMethodGetOptions, requestOptions, cancellationToken);
public async Task<Subscription> GetSubscription(
string subscriptionId,
SubscriptionGetOptions subscriptionGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default) =>
await _subscriptionService.GetAsync(subscriptionId, subscriptionGetOptions, requestOptions, cancellationToken);
}