Files
server/src/Billing/Services/Implementations/StripeEventService.cs
Alex Morask 5104ec5f98 [PM-31040] Add logging to bank account setup process (#6898)
* Add logging to bank account setup process

* Missed test file constructor
2026-01-26 11:46:08 -06:00

216 lines
8.2 KiB
C#

using Bit.Billing.Constants;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Caches;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Stripe;
namespace Bit.Billing.Services.Implementations;
public class StripeEventService(
GlobalSettings globalSettings,
ILogger<StripeEventService> logger,
IOrganizationRepository organizationRepository,
IProviderRepository providerRepository,
ISetupIntentCache setupIntentCache,
IStripeFacade stripeFacade)
: IStripeEventService
{
public async Task<Charge> GetCharge(Event stripeEvent, bool fresh = false, List<string>? expand = null)
{
var charge = Extract<Charge>(stripeEvent);
if (!fresh)
{
return charge;
}
return await stripeFacade.GetCharge(charge.Id, new ChargeGetOptions { Expand = expand });
}
public async Task<Customer> GetCustomer(Event stripeEvent, bool fresh = false, List<string>? expand = null)
{
var customer = Extract<Customer>(stripeEvent);
if (!fresh)
{
return customer;
}
return await stripeFacade.GetCustomer(customer.Id, new CustomerGetOptions { Expand = expand });
}
public async Task<Invoice> GetInvoice(Event stripeEvent, bool fresh = false, List<string>? expand = null)
{
var invoice = Extract<Invoice>(stripeEvent);
if (!fresh)
{
return invoice;
}
return await stripeFacade.GetInvoice(invoice.Id, new InvoiceGetOptions { Expand = expand });
}
public async Task<PaymentMethod> GetPaymentMethod(Event stripeEvent, bool fresh = false,
List<string>? expand = null)
{
var paymentMethod = Extract<PaymentMethod>(stripeEvent);
if (!fresh)
{
return paymentMethod;
}
return await stripeFacade.GetPaymentMethod(paymentMethod.Id, new PaymentMethodGetOptions { Expand = expand });
}
public async Task<SetupIntent> GetSetupIntent(Event stripeEvent, bool fresh = false, List<string>? expand = null)
{
var setupIntent = Extract<SetupIntent>(stripeEvent);
if (!fresh)
{
return setupIntent;
}
return await stripeFacade.GetSetupIntent(setupIntent.Id, new SetupIntentGetOptions { Expand = expand });
}
public async Task<Subscription> GetSubscription(Event stripeEvent, bool fresh = false, List<string>? expand = null)
{
var subscription = Extract<Subscription>(stripeEvent);
if (!fresh)
{
return subscription;
}
return await stripeFacade.GetSubscription(subscription.Id, new SubscriptionGetOptions { Expand = expand });
}
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 GetCustomerMetadataFromUpcomingInvoiceEvent(stripeEvent),
HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed
or HandledStripeWebhook.InvoiceCreated or HandledStripeWebhook.InvoiceFinalized =>
(await GetInvoice(stripeEvent, true, customerExpansion)).Customer?.Metadata,
HandledStripeWebhook.PaymentMethodAttached =>
(await GetPaymentMethod(stripeEvent, true, customerExpansion)).Customer?.Metadata,
HandledStripeWebhook.CustomerUpdated =>
(await GetCustomer(stripeEvent, true)).Metadata,
HandledStripeWebhook.SetupIntentSucceeded =>
await GetCustomerMetadataFromSetupIntentSucceededEvent(stripeEvent),
_ => null
};
if (customerMetadata == null)
{
return false;
}
var customerRegion = GetCustomerRegion(customerMetadata);
return customerRegion == serverRegion;
/* In Stripe, when we receive an invoice.upcoming event, the event does not include an Invoice ID because
the invoice hasn't been created yet. Therefore, rather than retrieving the fresh Invoice with a 'customer'
expansion, we need to use the Customer ID on the event to retrieve the metadata. */
async Task<Dictionary<string, string>?> GetCustomerMetadataFromUpcomingInvoiceEvent(Event localStripeEvent)
{
var invoice = await GetInvoice(localStripeEvent);
var customer = !string.IsNullOrEmpty(invoice.CustomerId)
? await stripeFacade.GetCustomer(invoice.CustomerId)
: null;
return customer?.Metadata;
}
async Task<Dictionary<string, string>?> GetCustomerMetadataFromSetupIntentSucceededEvent(Event localStripeEvent)
{
var setupIntent = await GetSetupIntent(localStripeEvent);
logger.LogInformation("Extracted Setup Intent ({SetupIntentId}) from Stripe 'setup_intent.succeeded' event", setupIntent.Id);
var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id);
logger.LogInformation("Retrieved subscriber ID ({SubscriberId}) from cache for Setup Intent ({SetupIntentId})", subscriberId, setupIntent.Id);
if (subscriberId == null)
{
logger.LogError("Cached subscriber ID for Setup Intent ({SetupIntentId}) is null", setupIntent.Id);
return null;
}
var organization = await organizationRepository.GetByIdAsync(subscriberId.Value);
logger.LogInformation("Retrieved organization ({OrganizationId}) via subscriber ID for Setup Intent ({SetupIntentId})", organization?.Id, setupIntent.Id);
if (organization is { GatewayCustomerId: not null })
{
var organizationCustomer = await stripeFacade.GetCustomer(organization.GatewayCustomerId);
logger.LogInformation("Retrieved customer ({CustomerId}) via organization ID for Setup Intent ({SetupIntentId})", organization.Id, setupIntent.Id);
return organizationCustomer.Metadata;
}
var provider = await providerRepository.GetByIdAsync(subscriberId.Value);
logger.LogInformation("Retrieved provider ({ProviderId}) via subscriber ID for Setup Intent ({SetupIntentId})", provider?.Id, setupIntent.Id);
if (provider is not { GatewayCustomerId: not null })
{
return null;
}
var providerCustomer = await stripeFacade.GetCustomer(provider.GatewayCustomerId);
logger.LogInformation("Retrieved customer ({CustomerId}) via provider ID for Setup Intent ({SetupIntentId})", provider.Id, setupIntent.Id);
return providerCustomer.Metadata;
}
}
private static T Extract<T>(Event stripeEvent)
=> 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}'")
: type;
private static string GetCustomerRegion(IDictionary<string, string> customerMetadata)
{
const string defaultRegion = Core.Constants.CountryAbbreviations.UnitedStates;
if (customerMetadata.TryGetValue("region", out var value))
{
return value;
}
var incorrectlyCasedRegionKey = customerMetadata.Keys
.FirstOrDefault(key => key.Equals("region", StringComparison.OrdinalIgnoreCase));
if (incorrectlyCasedRegionKey is null)
{
return defaultRegion;
}
_ = customerMetadata.TryGetValue(incorrectlyCasedRegionKey, out var regionValue);
return !string.IsNullOrWhiteSpace(regionValue)
? regionValue
: defaultRegion;
}
}