mirror of
https://github.com/bitwarden/server.git
synced 2025-07-02 00:22:50 -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:
@ -11,4 +11,5 @@ public static class HandledStripeWebhook
|
||||
public const string PaymentFailed = "invoice.payment_failed";
|
||||
public const string InvoiceCreated = "invoice.created";
|
||||
public const string PaymentMethodAttached = "payment_method.attached";
|
||||
public const string CustomerUpdated = "customer.updated";
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Bit.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@ -44,12 +45,13 @@ public class StripeController : Controller
|
||||
private readonly IAppleIapService _appleIapService;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly ILogger<StripeController> _logger;
|
||||
private readonly Braintree.BraintreeGateway _btGateway;
|
||||
private readonly BraintreeGateway _btGateway;
|
||||
private readonly IReferenceEventService _referenceEventService;
|
||||
private readonly ITaxRateRepository _taxRateRepository;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly IStripeEventService _stripeEventService;
|
||||
|
||||
public StripeController(
|
||||
GlobalSettings globalSettings,
|
||||
@ -67,7 +69,8 @@ public class StripeController : Controller
|
||||
ILogger<StripeController> logger,
|
||||
ITaxRateRepository taxRateRepository,
|
||||
IUserRepository userRepository,
|
||||
ICurrentContext currentContext)
|
||||
ICurrentContext currentContext,
|
||||
IStripeEventService stripeEventService)
|
||||
{
|
||||
_billingSettings = billingSettings?.Value;
|
||||
_hostingEnvironment = hostingEnvironment;
|
||||
@ -83,7 +86,7 @@ public class StripeController : Controller
|
||||
_taxRateRepository = taxRateRepository;
|
||||
_userRepository = userRepository;
|
||||
_logger = logger;
|
||||
_btGateway = new Braintree.BraintreeGateway
|
||||
_btGateway = new BraintreeGateway
|
||||
{
|
||||
Environment = globalSettings.Braintree.Production ?
|
||||
Braintree.Environment.PRODUCTION : Braintree.Environment.SANDBOX,
|
||||
@ -93,6 +96,7 @@ public class StripeController : Controller
|
||||
};
|
||||
_currentContext = currentContext;
|
||||
_globalSettings = globalSettings;
|
||||
_stripeEventService = stripeEventService;
|
||||
}
|
||||
|
||||
[HttpPost("webhook")]
|
||||
@ -103,7 +107,7 @@ public class StripeController : Controller
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
Stripe.Event parsedEvent;
|
||||
Event parsedEvent;
|
||||
using (var sr = new StreamReader(HttpContext.Request.Body))
|
||||
{
|
||||
var json = await sr.ReadToEndAsync();
|
||||
@ -125,7 +129,7 @@ public class StripeController : Controller
|
||||
}
|
||||
|
||||
// If the customer and server cloud regions don't match, early return 200 to avoid unnecessary errors
|
||||
if (!await ValidateCloudRegionAsync(parsedEvent))
|
||||
if (!await _stripeEventService.ValidateCloudRegion(parsedEvent))
|
||||
{
|
||||
return new OkResult();
|
||||
}
|
||||
@ -135,7 +139,7 @@ public class StripeController : Controller
|
||||
|
||||
if (subDeleted || subUpdated)
|
||||
{
|
||||
var subscription = await GetSubscriptionAsync(parsedEvent, true);
|
||||
var subscription = await _stripeEventService.GetSubscription(parsedEvent, true);
|
||||
var ids = GetIdsFromMetaData(subscription.Metadata);
|
||||
var organizationId = ids.Item1 ?? Guid.Empty;
|
||||
var userId = ids.Item2 ?? Guid.Empty;
|
||||
@ -204,7 +208,7 @@ public class StripeController : Controller
|
||||
}
|
||||
else if (parsedEvent.Type.Equals(HandledStripeWebhook.UpcomingInvoice))
|
||||
{
|
||||
var invoice = await GetInvoiceAsync(parsedEvent);
|
||||
var invoice = await _stripeEventService.GetInvoice(parsedEvent);
|
||||
var subscriptionService = new SubscriptionService();
|
||||
var subscription = await subscriptionService.GetAsync(invoice.SubscriptionId);
|
||||
if (subscription == null)
|
||||
@ -250,7 +254,7 @@ public class StripeController : Controller
|
||||
}
|
||||
else if (parsedEvent.Type.Equals(HandledStripeWebhook.ChargeSucceeded))
|
||||
{
|
||||
var charge = await GetChargeAsync(parsedEvent);
|
||||
var charge = await _stripeEventService.GetCharge(parsedEvent);
|
||||
var chargeTransaction = await _transactionRepository.GetByGatewayIdAsync(
|
||||
GatewayType.Stripe, charge.Id);
|
||||
if (chargeTransaction != null)
|
||||
@ -377,7 +381,7 @@ public class StripeController : Controller
|
||||
}
|
||||
else if (parsedEvent.Type.Equals(HandledStripeWebhook.ChargeRefunded))
|
||||
{
|
||||
var charge = await GetChargeAsync(parsedEvent);
|
||||
var charge = await _stripeEventService.GetCharge(parsedEvent);
|
||||
var chargeTransaction = await _transactionRepository.GetByGatewayIdAsync(
|
||||
GatewayType.Stripe, charge.Id);
|
||||
if (chargeTransaction == null)
|
||||
@ -427,7 +431,7 @@ public class StripeController : Controller
|
||||
}
|
||||
else if (parsedEvent.Type.Equals(HandledStripeWebhook.PaymentSucceeded))
|
||||
{
|
||||
var invoice = await GetInvoiceAsync(parsedEvent, true);
|
||||
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
|
||||
if (invoice.Paid && invoice.BillingReason == "subscription_create")
|
||||
{
|
||||
var subscriptionService = new SubscriptionService();
|
||||
@ -479,11 +483,11 @@ public class StripeController : Controller
|
||||
}
|
||||
else if (parsedEvent.Type.Equals(HandledStripeWebhook.PaymentFailed))
|
||||
{
|
||||
await HandlePaymentFailed(await GetInvoiceAsync(parsedEvent, true));
|
||||
await HandlePaymentFailed(await _stripeEventService.GetInvoice(parsedEvent, true));
|
||||
}
|
||||
else if (parsedEvent.Type.Equals(HandledStripeWebhook.InvoiceCreated))
|
||||
{
|
||||
var invoice = await GetInvoiceAsync(parsedEvent, true);
|
||||
var invoice = await _stripeEventService.GetInvoice(parsedEvent, true);
|
||||
if (!invoice.Paid && UnpaidAutoChargeInvoiceForSubscriptionCycle(invoice))
|
||||
{
|
||||
await AttemptToPayInvoiceAsync(invoice);
|
||||
@ -491,9 +495,35 @@ public class StripeController : Controller
|
||||
}
|
||||
else if (parsedEvent.Type.Equals(HandledStripeWebhook.PaymentMethodAttached))
|
||||
{
|
||||
var paymentMethod = await GetPaymentMethodAsync(parsedEvent);
|
||||
var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent);
|
||||
await HandlePaymentMethodAttachedAsync(paymentMethod);
|
||||
}
|
||||
else if (parsedEvent.Type.Equals(HandledStripeWebhook.CustomerUpdated))
|
||||
{
|
||||
var customer =
|
||||
await _stripeEventService.GetCustomer(parsedEvent, true, new List<string> { "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();
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);
|
||||
organization.BillingEmail = customer.Email;
|
||||
await _organizationRepository.ReplaceAsync(organization);
|
||||
|
||||
await _referenceEventService.RaiseEventAsync(
|
||||
new ReferenceEvent(ReferenceEventType.OrganizationEditedInStripe, organization, _currentContext));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Unsupported event received. " + parsedEvent.Type);
|
||||
@ -502,104 +532,6 @@ public class StripeController : Controller
|
||||
return new OkResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures that the customer associated with the parsed event's data 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="parsedEvent"></param>
|
||||
/// <returns>true if the customer's region and the server's region match, otherwise false</returns>
|
||||
/// <exception cref="Exception"></exception>
|
||||
private async Task<bool> ValidateCloudRegionAsync(Event parsedEvent)
|
||||
{
|
||||
var serverRegion = _globalSettings.BaseServiceUri.CloudRegion;
|
||||
var eventType = parsedEvent.Type;
|
||||
var expandOptions = new List<string> { "customer" };
|
||||
|
||||
try
|
||||
{
|
||||
Dictionary<string, string> customerMetadata;
|
||||
switch (eventType)
|
||||
{
|
||||
case HandledStripeWebhook.SubscriptionDeleted:
|
||||
case HandledStripeWebhook.SubscriptionUpdated:
|
||||
customerMetadata = (await GetSubscriptionAsync(parsedEvent, true, expandOptions))?.Customer
|
||||
?.Metadata;
|
||||
break;
|
||||
case HandledStripeWebhook.ChargeSucceeded:
|
||||
case HandledStripeWebhook.ChargeRefunded:
|
||||
customerMetadata = (await GetChargeAsync(parsedEvent, true, expandOptions))?.Customer?.Metadata;
|
||||
break;
|
||||
case HandledStripeWebhook.UpcomingInvoice:
|
||||
customerMetadata = (await GetInvoiceAsync(parsedEvent))?.Customer?.Metadata;
|
||||
break;
|
||||
case HandledStripeWebhook.PaymentSucceeded:
|
||||
case HandledStripeWebhook.PaymentFailed:
|
||||
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;
|
||||
}
|
||||
|
||||
if (customerMetadata is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var customerRegion = GetCustomerRegionFromMetadata(customerMetadata);
|
||||
|
||||
return customerRegion == serverRegion;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Encountered unexpected error while validating cloud region");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the customer's region from the metadata.
|
||||
/// </summary>
|
||||
/// <param name="customerMetadata">The metadata of the customer.</param>
|
||||
/// <returns>The region of the customer. If the region is not specified, it returns "US", if metadata is null,
|
||||
/// it returns null. It is case insensitive.</returns>
|
||||
private static string GetCustomerRegionFromMetadata(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;
|
||||
}
|
||||
|
||||
private async Task HandlePaymentMethodAttachedAsync(PaymentMethod paymentMethod)
|
||||
{
|
||||
if (paymentMethod is null)
|
||||
@ -975,109 +907,6 @@ public class StripeController : Controller
|
||||
invoice.BillingReason == "subscription_cycle" && invoice.SubscriptionId != null;
|
||||
}
|
||||
|
||||
private async Task<Charge> GetChargeAsync(Event parsedEvent, bool fresh = false, List<string> expandOptions = null)
|
||||
{
|
||||
if (!(parsedEvent.Data.Object is Charge eventCharge))
|
||||
{
|
||||
throw new Exception("Charge is null (from parsed event). " + parsedEvent.Id);
|
||||
}
|
||||
if (!fresh)
|
||||
{
|
||||
return eventCharge;
|
||||
}
|
||||
var chargeService = new ChargeService();
|
||||
var chargeGetOptions = new ChargeGetOptions { Expand = expandOptions };
|
||||
var charge = await chargeService.GetAsync(eventCharge.Id, chargeGetOptions);
|
||||
if (charge == null)
|
||||
{
|
||||
throw new Exception("Charge is null. " + eventCharge.Id);
|
||||
}
|
||||
return charge;
|
||||
}
|
||||
|
||||
private async Task<Invoice> GetInvoiceAsync(Stripe.Event parsedEvent, bool fresh = false, List<string> expandOptions = null)
|
||||
{
|
||||
if (!(parsedEvent.Data.Object is Invoice eventInvoice))
|
||||
{
|
||||
throw new Exception("Invoice is null (from parsed event). " + parsedEvent.Id);
|
||||
}
|
||||
if (!fresh)
|
||||
{
|
||||
return eventInvoice;
|
||||
}
|
||||
var invoiceService = new InvoiceService();
|
||||
var invoiceGetOptions = new InvoiceGetOptions { Expand = expandOptions };
|
||||
var invoice = await invoiceService.GetAsync(eventInvoice.Id, invoiceGetOptions);
|
||||
if (invoice == null)
|
||||
{
|
||||
throw new Exception("Invoice is null. " + eventInvoice.Id);
|
||||
}
|
||||
return invoice;
|
||||
}
|
||||
|
||||
private async Task<Subscription> GetSubscriptionAsync(Stripe.Event parsedEvent, bool fresh = false,
|
||||
List<string> expandOptions = null)
|
||||
{
|
||||
if (parsedEvent.Data.Object is not Subscription eventSubscription)
|
||||
{
|
||||
throw new Exception("Subscription is null (from parsed event). " + parsedEvent.Id);
|
||||
}
|
||||
if (!fresh)
|
||||
{
|
||||
return eventSubscription;
|
||||
}
|
||||
var subscriptionService = new SubscriptionService();
|
||||
var subscriptionGetOptions = new SubscriptionGetOptions { Expand = expandOptions };
|
||||
var subscription = await subscriptionService.GetAsync(eventSubscription.Id, subscriptionGetOptions);
|
||||
if (subscription == null)
|
||||
{
|
||||
throw new Exception("Subscription is null. " + eventSubscription.Id);
|
||||
}
|
||||
return subscription;
|
||||
}
|
||||
|
||||
private async Task<Customer> GetCustomerAsync(string customerId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(customerId))
|
||||
{
|
||||
throw new Exception("Customer ID cannot be empty when attempting to get a customer from Stripe");
|
||||
}
|
||||
|
||||
var customerService = new CustomerService();
|
||||
var customer = await customerService.GetAsync(customerId);
|
||||
if (customer == null)
|
||||
{
|
||||
throw new Exception($"Customer is null. {customerId}");
|
||||
}
|
||||
|
||||
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))
|
||||
|
80
src/Billing/Services/IStripeEventService.cs
Normal file
80
src/Billing/Services/IStripeEventService.cs
Normal 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);
|
||||
}
|
36
src/Billing/Services/IStripeFacade.cs
Normal file
36
src/Billing/Services/IStripeFacade.cs
Normal 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);
|
||||
}
|
197
src/Billing/Services/Implementations/StripeEventService.cs
Normal file
197
src/Billing/Services/Implementations/StripeEventService.cs
Normal 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;
|
||||
}
|
||||
}
|
47
src/Billing/Services/Implementations/StripeFacade.cs
Normal file
47
src/Billing/Services/Implementations/StripeFacade.cs
Normal 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);
|
||||
}
|
@ -1,4 +1,6 @@
|
||||
using System.Globalization;
|
||||
using Bit.Billing.Services;
|
||||
using Bit.Billing.Services.Implementations;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
using Bit.Core.SecretsManager.Repositories.Noop;
|
||||
@ -80,6 +82,9 @@ public class Startup
|
||||
|
||||
// Set up HttpClients
|
||||
services.AddHttpClient("FreshdeskApi");
|
||||
|
||||
services.AddScoped<IStripeFacade, StripeFacade>();
|
||||
services.AddScoped<IStripeEventService, StripeEventService>();
|
||||
}
|
||||
|
||||
public void Configure(
|
||||
|
@ -42,6 +42,8 @@ public enum ReferenceEventType
|
||||
OrganizationEditedByAdmin,
|
||||
[EnumMember(Value = "organization-created-by-admin")]
|
||||
OrganizationCreatedByAdmin,
|
||||
[EnumMember(Value = "organization-edited-in-stripe")]
|
||||
OrganizationEditedInStripe,
|
||||
[EnumMember(Value = "sm-service-account-accessed-secret")]
|
||||
SmServiceAccountAccessedSecret,
|
||||
}
|
||||
|
Reference in New Issue
Block a user