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, IOrganizationRepository organizationRepository, IProviderRepository providerRepository, ISetupIntentCache setupIntentCache, IStripeFacade stripeFacade) : IStripeEventService { public async Task GetCharge(Event stripeEvent, bool fresh = false, List? expand = null) { var charge = Extract(stripeEvent); if (!fresh) { return charge; } return await stripeFacade.GetCharge(charge.Id, new ChargeGetOptions { Expand = expand }); } public async Task GetCustomer(Event stripeEvent, bool fresh = false, List? expand = null) { var customer = Extract(stripeEvent); if (!fresh) { return customer; } return await stripeFacade.GetCustomer(customer.Id, new CustomerGetOptions { Expand = expand }); } public async Task GetInvoice(Event stripeEvent, bool fresh = false, List? expand = null) { var invoice = Extract(stripeEvent); if (!fresh) { return invoice; } return await stripeFacade.GetInvoice(invoice.Id, new InvoiceGetOptions { Expand = expand }); } public async Task GetPaymentMethod(Event stripeEvent, bool fresh = false, List? expand = null) { var paymentMethod = Extract(stripeEvent); if (!fresh) { return paymentMethod; } return await stripeFacade.GetPaymentMethod(paymentMethod.Id, new PaymentMethodGetOptions { Expand = expand }); } public async Task GetSetupIntent(Event stripeEvent, bool fresh = false, List? expand = null) { var setupIntent = Extract(stripeEvent); if (!fresh) { return setupIntent; } return await stripeFacade.GetSetupIntent(setupIntent.Id, new SetupIntentGetOptions { Expand = expand }); } public async Task GetSubscription(Event stripeEvent, bool fresh = false, List? expand = null) { var subscription = Extract(stripeEvent); if (!fresh) { return subscription; } return await stripeFacade.GetSubscription(subscription.Id, new SubscriptionGetOptions { Expand = expand }); } public async Task ValidateCloudRegion(Event stripeEvent) { var serverRegion = globalSettings.BaseServiceUri.CloudRegion; var customerExpansion = new List { "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?> 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?> GetCustomerMetadataFromSetupIntentSucceededEvent(Event localStripeEvent) { var setupIntent = await GetSetupIntent(localStripeEvent); var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id); if (subscriberId == null) { return null; } var organization = await organizationRepository.GetByIdAsync(subscriberId.Value); if (organization is { GatewayCustomerId: not null }) { var organizationCustomer = await stripeFacade.GetCustomer(organization.GatewayCustomerId); return organizationCustomer.Metadata; } var provider = await providerRepository.GetByIdAsync(subscriberId.Value); if (provider is not { GatewayCustomerId: not null }) { return null; } var providerCustomer = await stripeFacade.GetCustomer(provider.GatewayCustomerId); return providerCustomer.Metadata; } } private static T Extract(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 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; } }