From b2af73f00f09cb37813d98e9016a7ca3cc425591 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 11 Oct 2023 15:57:51 -0400 Subject: [PATCH 1/4] [PM-212] Sync Organization Billing Email from Stripe Webhook (#3305) * Add StripeFacade and StripeEventService * Add StripeEventServiceTests * Handle customer.updated event in StripeController --- src/Billing/Constants/HandledStripeWebhook.cs | 1 + src/Billing/Controllers/StripeController.cs | 243 +----- src/Billing/Services/IStripeEventService.cs | 80 ++ src/Billing/Services/IStripeFacade.cs | 36 + .../Implementations/StripeEventService.cs | 197 +++++ .../Services/Implementations/StripeFacade.cs | 47 ++ src/Billing/Startup.cs | 5 + src/Core/Tools/Enums/ReferenceEventType.cs | 2 + test/Billing.Test/Billing.Test.csproj | 22 + .../Resources/Events/charge.succeeded.json | 130 ++++ .../Events/customer.subscription.updated.json | 177 +++++ .../Resources/Events/customer.updated.json | 311 ++++++++ .../Resources/Events/invoice.created.json | 222 ++++++ .../Resources/Events/invoice.upcoming.json | 225 ++++++ .../Events/payment_method.attached.json | 63 ++ .../Services/StripeEventServiceTests.cs | 691 ++++++++++++++++++ .../Utilities/EmbeddedResourceReader.cs | 22 + .../Utilities/StripeTestEvents.cs | 33 + test/Billing.Test/packages.lock.json | 9 + 19 files changed, 2309 insertions(+), 207 deletions(-) create mode 100644 src/Billing/Services/IStripeEventService.cs create mode 100644 src/Billing/Services/IStripeFacade.cs create mode 100644 src/Billing/Services/Implementations/StripeEventService.cs create mode 100644 src/Billing/Services/Implementations/StripeFacade.cs create mode 100644 test/Billing.Test/Resources/Events/charge.succeeded.json create mode 100644 test/Billing.Test/Resources/Events/customer.subscription.updated.json create mode 100644 test/Billing.Test/Resources/Events/customer.updated.json create mode 100644 test/Billing.Test/Resources/Events/invoice.created.json create mode 100644 test/Billing.Test/Resources/Events/invoice.upcoming.json create mode 100644 test/Billing.Test/Resources/Events/payment_method.attached.json create mode 100644 test/Billing.Test/Services/StripeEventServiceTests.cs create mode 100644 test/Billing.Test/Utilities/EmbeddedResourceReader.cs create mode 100644 test/Billing.Test/Utilities/StripeTestEvents.cs diff --git a/src/Billing/Constants/HandledStripeWebhook.cs b/src/Billing/Constants/HandledStripeWebhook.cs index 7b894a295e3c..707a5dd5d5ea 100644 --- a/src/Billing/Constants/HandledStripeWebhook.cs +++ b/src/Billing/Constants/HandledStripeWebhook.cs @@ -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"; } diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index 00a8fa5ac6cd..19366b53a3a6 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -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 _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 StripeController( ILogger logger, ITaxRateRepository taxRateRepository, IUserRepository userRepository, - ICurrentContext currentContext) + ICurrentContext currentContext, + IStripeEventService stripeEventService) { _billingSettings = billingSettings?.Value; _hostingEnvironment = hostingEnvironment; @@ -83,7 +86,7 @@ public StripeController( _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 StripeController( }; _currentContext = currentContext; _globalSettings = globalSettings; + _stripeEventService = stripeEventService; } [HttpPost("webhook")] @@ -103,7 +107,7 @@ public async Task PostWebhook([FromQuery] string key) 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 async Task PostWebhook([FromQuery] string key) } // 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 async Task PostWebhook([FromQuery] string key) 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 @@ await _userService.UpdatePremiumExpirationAsync(userId, } 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 @@ await _mailService.SendInvoiceUpcomingAsync(email, invoice.AmountDue / 100M, } 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 @@ await _mailService.SendInvoiceUpcomingAsync(email, invoice.AmountDue / 100M, } 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 @@ await _transactionRepository.CreateAsync(new Transaction } 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 @@ await _referenceEventService.RaiseEventAsync( } 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,113 +495,41 @@ await _referenceEventService.RaiseEventAsync( } else if (parsedEvent.Type.Equals(HandledStripeWebhook.PaymentMethodAttached)) { - var paymentMethod = await GetPaymentMethodAsync(parsedEvent); + var paymentMethod = await _stripeEventService.GetPaymentMethod(parsedEvent); await HandlePaymentMethodAttachedAsync(paymentMethod); } - else - { - _logger.LogWarning("Unsupported event received. " + parsedEvent.Type); - } - - return new OkResult(); - } - - /// - /// 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 - /// - /// - /// true if the customer's region and the server's region match, otherwise false - /// - private async Task ValidateCloudRegionAsync(Event parsedEvent) - { - var serverRegion = _globalSettings.BaseServiceUri.CloudRegion; - var eventType = parsedEvent.Type; - var expandOptions = new List { "customer" }; - - try + else if (parsedEvent.Type.Equals(HandledStripeWebhook.CustomerUpdated)) { - Dictionary 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; - } + var customer = + await _stripeEventService.GetCustomer(parsedEvent, true, new List { "subscriptions" }); - if (customerMetadata is null) + if (customer.Subscriptions == null || !customer.Subscriptions.Any()) { - return false; + return new OkResult(); } - var customerRegion = GetCustomerRegionFromMetadata(customerMetadata); + var subscription = customer.Subscriptions.First(); - return customerRegion == serverRegion; - } - catch (Exception e) - { - _logger.LogError(e, "Encountered unexpected error while validating cloud region"); - throw; - } - } + var (organizationId, _) = GetIdsFromMetaData(subscription.Metadata); - /// - /// Gets the customer's region from the metadata. - /// - /// The metadata of the customer. - /// 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. - private static string GetCustomerRegionFromMetadata(IDictionary customerMetadata) - { - const string defaultRegion = "US"; + if (!organizationId.HasValue) + { + return new OkResult(); + } - if (customerMetadata is null) - { - return null; - } + var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); + organization.BillingEmail = customer.Email; + await _organizationRepository.ReplaceAsync(organization); - if (customerMetadata.TryGetValue("region", out var value)) - { - return value; + await _referenceEventService.RaiseEventAsync( + new ReferenceEvent(ReferenceEventType.OrganizationEditedInStripe, organization, _currentContext)); } - - var miscasedRegionKey = customerMetadata.Keys - .FirstOrDefault(key => - key.Equals("region", StringComparison.OrdinalIgnoreCase)); - - if (miscasedRegionKey is null) + else { - return defaultRegion; + _logger.LogWarning("Unsupported event received. " + parsedEvent.Type); } - _ = customerMetadata.TryGetValue(miscasedRegionKey, out var regionValue); - - return !string.IsNullOrWhiteSpace(regionValue) - ? regionValue - : defaultRegion; + return new OkResult(); } private async Task HandlePaymentMethodAttachedAsync(PaymentMethod paymentMethod) @@ -975,109 +907,6 @@ private bool UnpaidAutoChargeInvoiceForSubscriptionCycle(Invoice invoice) invoice.BillingReason == "subscription_cycle" && invoice.SubscriptionId != null; } - private async Task GetChargeAsync(Event parsedEvent, bool fresh = false, List 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 GetInvoiceAsync(Stripe.Event parsedEvent, bool fresh = false, List 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 GetSubscriptionAsync(Stripe.Event parsedEvent, bool fresh = false, - List 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 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 GetPaymentMethodAsync(Event parsedEvent, bool fresh = false, - List 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 VerifyCorrectTaxRateForCharge(Invoice invoice, Subscription subscription) { if (!string.IsNullOrWhiteSpace(invoice?.CustomerAddress?.Country) && !string.IsNullOrWhiteSpace(invoice?.CustomerAddress?.PostalCode)) diff --git a/src/Billing/Services/IStripeEventService.cs b/src/Billing/Services/IStripeEventService.cs new file mode 100644 index 000000000000..6e2239cf9866 --- /dev/null +++ b/src/Billing/Services/IStripeEventService.cs @@ -0,0 +1,80 @@ +using Stripe; + +namespace Bit.Billing.Services; + +public interface IStripeEventService +{ + /// + /// Extracts the object from the Stripe . When 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 options. + /// + /// The Stripe webhook event. + /// Determines whether or not to retrieve a fresh copy of the charge object from Stripe. + /// Optionally provided to expand the fresh charge object retrieved from Stripe. + /// A Stripe . + /// Thrown when the Stripe event does not contain a charge object. + /// Thrown when is true and Stripe's API returns a null charge object. + Task GetCharge(Event stripeEvent, bool fresh = false, List expand = null); + + /// + /// Extracts the object from the Stripe . When 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 options. + /// + /// The Stripe webhook event. + /// Determines whether or not to retrieve a fresh copy of the customer object from Stripe. + /// Optionally provided to expand the fresh customer object retrieved from Stripe. + /// A Stripe . + /// Thrown when the Stripe event does not contain a customer object. + /// Thrown when is true and Stripe's API returns a null customer object. + Task GetCustomer(Event stripeEvent, bool fresh = false, List expand = null); + + /// + /// Extracts the object from the Stripe . When 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 options. + /// + /// The Stripe webhook event. + /// Determines whether or not to retrieve a fresh copy of the invoice object from Stripe. + /// Optionally provided to expand the fresh invoice object retrieved from Stripe. + /// A Stripe . + /// Thrown when the Stripe event does not contain an invoice object. + /// Thrown when is true and Stripe's API returns a null invoice object. + Task GetInvoice(Event stripeEvent, bool fresh = false, List expand = null); + + /// + /// Extracts the object from the Stripe . When 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 options. + /// + /// The Stripe webhook event. + /// Determines whether or not to retrieve a fresh copy of the payment method object from Stripe. + /// Optionally provided to expand the fresh payment method object retrieved from Stripe. + /// A Stripe . + /// Thrown when the Stripe event does not contain an payment method object. + /// Thrown when is true and Stripe's API returns a null payment method object. + Task GetPaymentMethod(Event stripeEvent, bool fresh = false, List expand = null); + + /// + /// Extracts the object from the Stripe . When 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 options. + /// + /// The Stripe webhook event. + /// Determines whether or not to retrieve a fresh copy of the subscription object from Stripe. + /// Optionally provided to expand the fresh subscription object retrieved from Stripe. + /// A Stripe . + /// Thrown when the Stripe event does not contain an subscription object. + /// Thrown when is true and Stripe's API returns a null subscription object. + Task GetSubscription(Event stripeEvent, bool fresh = false, List expand = null); + + /// + /// Ensures that the customer associated with the Stripe 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. + /// + /// The Stripe webhook event. + /// True if the customer's region and the server's region match, otherwise false. + Task ValidateCloudRegion(Event stripeEvent); +} diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs new file mode 100644 index 000000000000..cbe36b6a46b7 --- /dev/null +++ b/src/Billing/Services/IStripeFacade.cs @@ -0,0 +1,36 @@ +using Stripe; + +namespace Bit.Billing.Services; + +public interface IStripeFacade +{ + Task GetCharge( + string chargeId, + ChargeGetOptions chargeGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + + Task GetCustomer( + string customerId, + CustomerGetOptions customerGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + + Task GetInvoice( + string invoiceId, + InvoiceGetOptions invoiceGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + + Task GetPaymentMethod( + string paymentMethodId, + PaymentMethodGetOptions paymentMethodGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + + Task GetSubscription( + string subscriptionId, + SubscriptionGetOptions subscriptionGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); +} diff --git a/src/Billing/Services/Implementations/StripeEventService.cs b/src/Billing/Services/Implementations/StripeEventService.cs new file mode 100644 index 000000000000..076602e3d209 --- /dev/null +++ b/src/Billing/Services/Implementations/StripeEventService.cs @@ -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 GetCharge(Event stripeEvent, bool fresh = false, List expand = null) + { + var eventCharge = Extract(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 GetCustomer(Event stripeEvent, bool fresh = false, List expand = null) + { + var eventCustomer = Extract(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 GetInvoice(Event stripeEvent, bool fresh = false, List expand = null) + { + var eventInvoice = Extract(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 GetPaymentMethod(Event stripeEvent, bool fresh = false, List expand = null) + { + var eventPaymentMethod = Extract(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 GetSubscription(Event stripeEvent, bool fresh = false, List expand = null) + { + var eventSubscription = Extract(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 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 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(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 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; + } +} diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs new file mode 100644 index 000000000000..2ea4d0b93f82 --- /dev/null +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -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 GetCharge( + string chargeId, + ChargeGetOptions chargeGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _chargeService.GetAsync(chargeId, chargeGetOptions, requestOptions, cancellationToken); + + public async Task GetCustomer( + string customerId, + CustomerGetOptions customerGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _customerService.GetAsync(customerId, customerGetOptions, requestOptions, cancellationToken); + + public async Task GetInvoice( + string invoiceId, + InvoiceGetOptions invoiceGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _invoiceService.GetAsync(invoiceId, invoiceGetOptions, requestOptions, cancellationToken); + + public async Task GetPaymentMethod( + string paymentMethodId, + PaymentMethodGetOptions paymentMethodGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _paymentMethodService.GetAsync(paymentMethodId, paymentMethodGetOptions, requestOptions, cancellationToken); + + public async Task GetSubscription( + string subscriptionId, + SubscriptionGetOptions subscriptionGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _subscriptionService.GetAsync(subscriptionId, subscriptionGetOptions, requestOptions, cancellationToken); +} diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index d665677d62ab..cbde05ce06ac 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -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 void ConfigureServices(IServiceCollection services) // Set up HttpClients services.AddHttpClient("FreshdeskApi"); + + services.AddScoped(); + services.AddScoped(); } public void Configure( diff --git a/src/Core/Tools/Enums/ReferenceEventType.cs b/src/Core/Tools/Enums/ReferenceEventType.cs index a8a52e63179f..1e903b6a8728 100644 --- a/src/Core/Tools/Enums/ReferenceEventType.cs +++ b/src/Core/Tools/Enums/ReferenceEventType.cs @@ -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, } diff --git a/test/Billing.Test/Billing.Test.csproj b/test/Billing.Test/Billing.Test.csproj index 09252a70e1ae..302f590ad133 100644 --- a/test/Billing.Test/Billing.Test.csproj +++ b/test/Billing.Test/Billing.Test.csproj @@ -5,6 +5,7 @@ + @@ -24,4 +25,25 @@ + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + diff --git a/test/Billing.Test/Resources/Events/charge.succeeded.json b/test/Billing.Test/Resources/Events/charge.succeeded.json new file mode 100644 index 000000000000..e88efa40797a --- /dev/null +++ b/test/Billing.Test/Resources/Events/charge.succeeded.json @@ -0,0 +1,130 @@ +{ + "id": "evt_3NvKgBIGBnsLynRr0pJJqudS", + "object": "event", + "api_version": "2022-08-01", + "created": 1695909300, + "data": { + "object": { + "id": "ch_3NvKgBIGBnsLynRr0ZyvP9AN", + "object": "charge", + "amount": 7200, + "amount_captured": 7200, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_3NvKgBIGBnsLynRr0KbYEz76", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "calculated_statement_descriptor": "BITWARDEN", + "captured": true, + "created": 1695909299, + "currency": "usd", + "customer": "cus_OimAwOzQmThNXx", + "description": "Subscription update", + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": { + }, + "invoice": "in_1NvKgBIGBnsLynRrmRFHAcoV", + "livemode": false, + "metadata": { + }, + "on_behalf_of": null, + "order": null, + "outcome": { + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 37, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3NvKgBIGBnsLynRr09Ny3Heu", + "payment_method": "pm_1NvKbpIGBnsLynRrcOwez4A1", + "payment_method_details": { + "card": { + "amount_authorized": 7200, + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 6, + "exp_year": 2033, + "extended_authorization": { + "status": "disabled" + }, + "fingerprint": "0VgUBpvqcUUnuSmK", + "funding": "credit", + "incremental_authorization": { + "status": "unavailable" + }, + "installments": null, + "last4": "4242", + "mandate": null, + "multicapture": { + "status": "unavailable" + }, + "network": "visa", + "network_token": { + "used": false + }, + "overcapture": { + "maximum_amount_capturable": 7200, + "status": "unavailable" + }, + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": "cturnbull@bitwarden.com", + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/invoices/CAcaFwoVYWNjdF8xOXNtSVhJR0Juc0x5blJyKLSL1qgGMgYTnk_JOUA6LBY_SDEZNtuae1guQ6Dlcuev1TUHwn712t-UNnZdIc383zS15bXv_1dby8e4?s=ap", + "refunded": false, + "refunds": { + "object": "list", + "data": [ + ], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3NvKgBIGBnsLynRr0ZyvP9AN/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 9, + "request": { + "id": "req_rig8N5Ca8EXYRy", + "idempotency_key": "db75068d-5d90-4c65-a410-4e2ed8347509" + }, + "type": "charge.succeeded" +} diff --git a/test/Billing.Test/Resources/Events/customer.subscription.updated.json b/test/Billing.Test/Resources/Events/customer.subscription.updated.json new file mode 100644 index 000000000000..1a128c1508e3 --- /dev/null +++ b/test/Billing.Test/Resources/Events/customer.subscription.updated.json @@ -0,0 +1,177 @@ +{ + "id": "evt_1NvLMDIGBnsLynRr6oBxebrE", + "object": "event", + "api_version": "2022-08-01", + "created": 1695911902, + "data": { + "object": { + "id": "sub_1NvKoKIGBnsLynRrcLIAUWGf", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "enabled": false + }, + "billing_cycle_anchor": 1695911900, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1695909804, + "currency": "usd", + "current_period_end": 1727534300, + "current_period_start": 1695911900, + "customer": "cus_OimNNCC3RiI2HQ", + "days_until_due": null, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [ + ], + "description": null, + "discount": null, + "ended_at": null, + "items": { + "object": "list", + "data": [ + { + "id": "si_OimNgVtrESpqus", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1695909805, + "metadata": { + }, + "plan": { + "id": "enterprise-org-seat-annually", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 3600, + "amount_decimal": "3600", + "billing_scheme": "per_unit", + "created": 1494268677, + "currency": "usd", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": { + }, + "nickname": "2019 Enterprise Seat (Annually)", + "product": "prod_BUtogGemxnTi9z", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "enterprise-org-seat-annually", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1494268677, + "currency": "usd", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": { + }, + "nickname": "2019 Enterprise Seat (Annually)", + "product": "prod_BUtogGemxnTi9z", + "recurring": { + "aggregate_usage": null, + "interval": "year", + "interval_count": 1, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 3600, + "unit_amount_decimal": "3600" + }, + "quantity": 1, + "subscription": "sub_1NvKoKIGBnsLynRrcLIAUWGf", + "tax_rates": [ + ] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1NvKoKIGBnsLynRrcLIAUWGf" + }, + "latest_invoice": "in_1NvLM9IGBnsLynRrOysII07d", + "livemode": false, + "metadata": { + "organizationId": "84a569ea-4643-474a-83a9-b08b00e7a20d" + }, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "enterprise-org-seat-annually", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 3600, + "amount_decimal": "3600", + "billing_scheme": "per_unit", + "created": 1494268677, + "currency": "usd", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": { + }, + "nickname": "2019 Enterprise Seat (Annually)", + "product": "prod_BUtogGemxnTi9z", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1695909804, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": 1695911899, + "trial_settings": { + "end_behavior": { + "missing_payment_method": "create_invoice" + } + }, + "trial_start": 1695909804 + }, + "previous_attributes": { + "billing_cycle_anchor": 1696514604, + "current_period_end": 1696514604, + "current_period_start": 1695909804, + "latest_invoice": "in_1NvKoKIGBnsLynRrSNRC6oYI", + "status": "trialing", + "trial_end": 1696514604 + } + }, + "livemode": false, + "pending_webhooks": 8, + "request": { + "id": "req_DMZPUU3BI66zAx", + "idempotency_key": "3fd8b4a5-6a20-46ab-9f45-b37b02a8017f" + }, + "type": "customer.subscription.updated" +} diff --git a/test/Billing.Test/Resources/Events/customer.updated.json b/test/Billing.Test/Resources/Events/customer.updated.json new file mode 100644 index 000000000000..323a9b9ba5fe --- /dev/null +++ b/test/Billing.Test/Resources/Events/customer.updated.json @@ -0,0 +1,311 @@ +{ + "id": "evt_1NvKjSIGBnsLynRrS3MTK4DZ", + "object": "event", + "account": "acct_19smIXIGBnsLynRr", + "api_version": "2022-08-01", + "created": 1695909502, + "data": { + "object": { + "id": "cus_Of54kUr3gV88lM", + "object": "customer", + "address": { + "city": null, + "country": "US", + "line1": "", + "line2": null, + "postal_code": "33701", + "state": null + }, + "balance": 0, + "created": 1695056798, + "currency": "usd", + "default_source": "src_1NtAfeIGBnsLynRrYDrceax7", + "delinquent": false, + "description": "Premium User", + "discount": null, + "email": "premium@bitwarden.com", + "invoice_prefix": "C506E8CE", + "invoice_settings": { + "custom_fields": [ + { + "name": "Subscriber", + "value": "Premium User" + } + ], + "default_payment_method": "pm_1Nrku9IGBnsLynRrcsQ3hy6C", + "footer": null, + "rendering_options": null + }, + "livemode": false, + "metadata": { + "region": "US" + }, + "name": null, + "next_invoice_sequence": 2, + "phone": null, + "preferred_locales": [ + ], + "shipping": null, + "tax_exempt": "none", + "test_clock": null, + "account_balance": 0, + "cards": { + "object": "list", + "data": [ + ], + "has_more": false, + "total_count": 0, + "url": "/v1/customers/cus_Of54kUr3gV88lM/cards" + }, + "default_card": null, + "default_currency": "usd", + "sources": { + "object": "list", + "data": [ + { + "id": "src_1NtAfeIGBnsLynRrYDrceax7", + "object": "source", + "ach_credit_transfer": { + "account_number": "test_b2d1c6415f6f", + "routing_number": "110000000", + "fingerprint": "ePO4hBQanSft3gvU", + "swift_code": "TSTEZ122", + "bank_name": "TEST BANK", + "refund_routing_number": null, + "refund_account_holder_type": null, + "refund_account_holder_name": null + }, + "amount": null, + "client_secret": "src_client_secret_bUAP2uDRw6Pwj0xYk32LmJ3K", + "created": 1695394170, + "currency": "usd", + "customer": "cus_Of54kUr3gV88lM", + "flow": "receiver", + "livemode": false, + "metadata": { + }, + "owner": { + "address": null, + "email": "amount_0@stripe.com", + "name": null, + "phone": null, + "verified_address": null, + "verified_email": null, + "verified_name": null, + "verified_phone": null + }, + "receiver": { + "address": "110000000-test_b2d1c6415f6f", + "amount_charged": 0, + "amount_received": 0, + "amount_returned": 0, + "refund_attributes_method": "email", + "refund_attributes_status": "missing" + }, + "statement_descriptor": null, + "status": "pending", + "type": "ach_credit_transfer", + "usage": "reusable" + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/customers/cus_Of54kUr3gV88lM/sources" + }, + "subscriptions": { + "object": "list", + "data": [ + { + "id": "sub_1NrkuBIGBnsLynRrzjFGIjEw", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "enabled": false + }, + "billing": "charge_automatically", + "billing_cycle_anchor": 1695056799, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1695056799, + "currency": "usd", + "current_period_end": 1726679199, + "current_period_start": 1695056799, + "customer": "cus_Of54kUr3gV88lM", + "days_until_due": null, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [ + ], + "description": null, + "discount": null, + "ended_at": null, + "invoice_customer_balance_settings": { + "consume_applied_balance_on_void": true + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_Of54i3aK9I5Wro", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1695056800, + "metadata": { + }, + "plan": { + "id": "premium-annually", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 1000, + "amount_decimal": "1000", + "billing_scheme": "per_unit", + "created": 1499289328, + "currency": "usd", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": { + }, + "name": "Premium (Annually)", + "nickname": "Premium (Annually)", + "product": "prod_BUqgYr48VzDuCg", + "statement_description": null, + "statement_descriptor": null, + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "premium-annually", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1499289328, + "currency": "usd", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": { + }, + "nickname": "Premium (Annually)", + "product": "prod_BUqgYr48VzDuCg", + "recurring": { + "aggregate_usage": null, + "interval": "year", + "interval_count": 1, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 1000, + "unit_amount_decimal": "1000" + }, + "quantity": 1, + "subscription": "sub_1NrkuBIGBnsLynRrzjFGIjEw", + "tax_rates": [ + ] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1NrkuBIGBnsLynRrzjFGIjEw" + }, + "latest_invoice": "in_1NrkuBIGBnsLynRr40gyJTVU", + "livemode": false, + "metadata": { + "userId": "91f40b6d-ac3b-4348-804b-b0810119ac6a" + }, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "premium-annually", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 1000, + "amount_decimal": "1000", + "billing_scheme": "per_unit", + "created": 1499289328, + "currency": "usd", + "interval": "year", + "interval_count": 1, + "livemode": false, + "metadata": { + }, + "name": "Premium (Annually)", + "nickname": "Premium (Annually)", + "product": "prod_BUqgYr48VzDuCg", + "statement_description": null, + "statement_descriptor": null, + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start": 1695056799, + "start_date": 1695056799, + "status": "active", + "tax_percent": null, + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/customers/cus_Of54kUr3gV88lM/subscriptions" + }, + "tax_ids": { + "object": "list", + "data": [ + ], + "has_more": false, + "total_count": 0, + "url": "/v1/customers/cus_Of54kUr3gV88lM/tax_ids" + }, + "tax_info": null, + "tax_info_verification": null + }, + "previous_attributes": { + "email": "premium-new@bitwarden.com" + } + }, + "livemode": false, + "pending_webhooks": 5, + "request": "req_2RtGdXCfiicFLx", + "type": "customer.updated", + "user_id": "acct_19smIXIGBnsLynRr" +} diff --git a/test/Billing.Test/Resources/Events/invoice.created.json b/test/Billing.Test/Resources/Events/invoice.created.json new file mode 100644 index 000000000000..b70442ed3691 --- /dev/null +++ b/test/Billing.Test/Resources/Events/invoice.created.json @@ -0,0 +1,222 @@ +{ + "id": "evt_1NvKzfIGBnsLynRr0SkwrlkE", + "object": "event", + "api_version": "2022-08-01", + "created": 1695910506, + "data": { + "object": { + "id": "in_1NvKzdIGBnsLynRr8fE8cpbg", + "object": "invoice", + "account_country": "US", + "account_name": "Bitwarden Inc.", + "account_tax_ids": null, + "amount_due": 0, + "amount_paid": 0, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "application_fee_amount": null, + "attempt_count": 0, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "enabled": false, + "status": null + }, + "billing_reason": "subscription_create", + "charge": null, + "collection_method": "charge_automatically", + "created": 1695910505, + "currency": "usd", + "custom_fields": [ + { + "name": "Organization", + "value": "teams 2023 monthly - 2" + } + ], + "customer": "cus_OimYrxnMTMMK1E", + "customer_address": { + "city": null, + "country": "US", + "line1": "", + "line2": null, + "postal_code": "12345", + "state": null + }, + "customer_email": "cturnbull@bitwarden.com", + "customer_name": null, + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [ + ], + "description": null, + "discount": null, + "discounts": [ + ], + "due_date": null, + "effective_at": 1695910505, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_19smIXIGBnsLynRr/test_YWNjdF8xOXNtSVhJR0Juc0x5blJyLF9PaW1ZVlo4dFRtbkNQQVY5aHNpckQxN1QzRHBPcVBOLDg2NDUxMzA30200etYRHca2?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_19smIXIGBnsLynRr/test_YWNjdF8xOXNtSVhJR0Juc0x5blJyLF9PaW1ZVlo4dFRtbkNQQVY5aHNpckQxN1QzRHBPcVBOLDg2NDUxMzA30200etYRHca2/pdf?s=ap", + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1NvKzdIGBnsLynRr2pS4ZA8e", + "object": "line_item", + "amount": 0, + "amount_excluding_tax": 0, + "currency": "usd", + "description": "Trial period for Teams Organization Seat", + "discount_amounts": [ + ], + "discountable": true, + "discounts": [ + ], + "livemode": false, + "metadata": { + "organizationId": "3fbc84ce-102d-4919-b89b-b08b00ead71a" + }, + "period": { + "end": 1696515305, + "start": 1695910505 + }, + "plan": { + "id": "2020-teams-org-seat-monthly", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 400, + "amount_decimal": "400", + "billing_scheme": "per_unit", + "created": 1595263113, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": { + }, + "nickname": "Teams Organization Seat (Monthly) 2023", + "product": "prod_HgOooYXDr2DDAA", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "2020-teams-org-seat-monthly", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1595263113, + "currency": "usd", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": { + }, + "nickname": "Teams Organization Seat (Monthly) 2023", + "product": "prod_HgOooYXDr2DDAA", + "recurring": { + "aggregate_usage": null, + "interval": "month", + "interval_count": 1, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 400, + "unit_amount_decimal": "400" + }, + "proration": false, + "proration_details": { + "credited_items": null + }, + "quantity": 1, + "subscription": "sub_1NvKzdIGBnsLynRrKIHQamZc", + "subscription_item": "si_OimYNSbvuqdtTr", + "tax_amounts": [ + ], + "tax_rates": [ + ], + "type": "subscription", + "unit_amount_excluding_tax": "0" + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1NvKzdIGBnsLynRr8fE8cpbg/lines" + }, + "livemode": false, + "metadata": { + }, + "next_payment_attempt": null, + "number": "3E96D078-0001", + "on_behalf_of": null, + "paid": true, + "paid_out_of_band": false, + "payment_intent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "period_end": 1695910505, + "period_start": 1695910505, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "quote": null, + "receipt_number": null, + "rendering": null, + "rendering_options": null, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1695910505, + "marked_uncollectible_at": null, + "paid_at": 1695910505, + "voided_at": null + }, + "subscription": "sub_1NvKzdIGBnsLynRrKIHQamZc", + "subscription_details": { + "metadata": { + "organizationId": "3fbc84ce-102d-4919-b89b-b08b00ead71a" + } + }, + "subtotal": 0, + "subtotal_excluding_tax": 0, + "tax": null, + "test_clock": null, + "total": 0, + "total_discount_amounts": [ + ], + "total_excluding_tax": 0, + "total_tax_amounts": [ + ], + "transfer_data": null, + "webhooks_delivered_at": null + } + }, + "livemode": false, + "pending_webhooks": 8, + "request": { + "id": "req_roIwONfgyfZdr4", + "idempotency_key": "dd2a171b-b9c7-4d2d-89d5-1ceae3c0595d" + }, + "type": "invoice.created" +} diff --git a/test/Billing.Test/Resources/Events/invoice.upcoming.json b/test/Billing.Test/Resources/Events/invoice.upcoming.json new file mode 100644 index 000000000000..7b9055d497bd --- /dev/null +++ b/test/Billing.Test/Resources/Events/invoice.upcoming.json @@ -0,0 +1,225 @@ +{ + "id": "evt_1Nv0w8IGBnsLynRrZoDVI44u", + "object": "event", + "api_version": "2022-08-01", + "created": 1695833408, + "data": { + "object": { + "object": "invoice", + "account_country": "US", + "account_name": "Bitwarden Inc.", + "account_tax_ids": null, + "amount_due": 0, + "amount_paid": 0, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "application_fee_amount": null, + "attempt_count": 0, + "attempted": false, + "automatic_tax": { + "enabled": true, + "status": "complete" + }, + "billing_reason": "upcoming", + "charge": null, + "collection_method": "charge_automatically", + "created": 1697128681, + "currency": "usd", + "custom_fields": null, + "customer": "cus_M8DV9wiyNa2JxQ", + "customer_address": { + "city": null, + "country": "US", + "line1": "", + "line2": null, + "postal_code": "90019", + "state": null + }, + "customer_email": "vphan@bitwarden.com", + "customer_name": null, + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [ + ], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [ + ], + "description": null, + "discount": null, + "discounts": [ + ], + "due_date": null, + "effective_at": null, + "ending_balance": -6779, + "footer": null, + "from_invoice": null, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_tmp_12b5e8IGBnsLynRr1996ac3a", + "object": "line_item", + "amount": 2000, + "amount_excluding_tax": 2000, + "currency": "usd", + "description": "5 × 2019 Enterprise Seat (Monthly) (at $4.00 / month)", + "discount_amounts": [ + ], + "discountable": true, + "discounts": [ + ], + "livemode": false, + "metadata": { + }, + "period": { + "end": 1699807081, + "start": 1697128681 + }, + "plan": { + "id": "enterprise-org-seat-monthly", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 400, + "amount_decimal": "400", + "billing_scheme": "per_unit", + "created": 1494268635, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": { + }, + "nickname": "2019 Enterprise Seat (Monthly)", + "product": "prod_BVButYytPSlgs6", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "enterprise-org-seat-monthly", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1494268635, + "currency": "usd", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": { + }, + "nickname": "2019 Enterprise Seat (Monthly)", + "product": "prod_BVButYytPSlgs6", + "recurring": { + "aggregate_usage": null, + "interval": "month", + "interval_count": 1, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 400, + "unit_amount_decimal": "400" + }, + "proration": false, + "proration_details": { + "credited_items": null + }, + "quantity": 5, + "subscription": "sub_1NQxz4IGBnsLynRr1KbitG7v", + "subscription_item": "si_ODOmLnPDHBuMxX", + "tax_amounts": [ + { + "amount": 0, + "inclusive": false, + "tax_rate": "txr_1N6XCyIGBnsLynRr0LHs4AUD", + "taxability_reason": "product_exempt", + "taxable_amount": 0 + } + ], + "tax_rates": [ + ], + "type": "subscription", + "unit_amount_excluding_tax": "400" + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/upcoming/lines?customer=cus_M8DV9wiyNa2JxQ&subscription=sub_1NQxz4IGBnsLynRr1KbitG7v" + }, + "livemode": false, + "metadata": { + }, + "next_payment_attempt": 1697132281, + "number": null, + "on_behalf_of": null, + "paid": false, + "paid_out_of_band": false, + "payment_intent": null, + "payment_settings": { + "default_mandate": null, + "payment_method_options": null, + "payment_method_types": null + }, + "period_end": 1697128681, + "period_start": 1694536681, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "quote": null, + "receipt_number": null, + "rendering": null, + "rendering_options": null, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": -8779, + "statement_descriptor": null, + "status": "draft", + "status_transitions": { + "finalized_at": null, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subscription": "sub_1NQxz4IGBnsLynRr1KbitG7v", + "subscription_details": { + "metadata": { + } + }, + "subtotal": 2000, + "subtotal_excluding_tax": 2000, + "tax": 0, + "test_clock": null, + "total": 2000, + "total_discount_amounts": [ + ], + "total_excluding_tax": 2000, + "total_tax_amounts": [ + { + "amount": 0, + "inclusive": false, + "tax_rate": "txr_1N6XCyIGBnsLynRr0LHs4AUD", + "taxability_reason": "product_exempt", + "taxable_amount": 0 + } + ], + "transfer_data": null, + "webhooks_delivered_at": null + } + }, + "livemode": false, + "pending_webhooks": 5, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "invoice.upcoming" +} diff --git a/test/Billing.Test/Resources/Events/payment_method.attached.json b/test/Billing.Test/Resources/Events/payment_method.attached.json new file mode 100644 index 000000000000..40e6972bdd52 --- /dev/null +++ b/test/Billing.Test/Resources/Events/payment_method.attached.json @@ -0,0 +1,63 @@ +{ + "id": "evt_1NvKzcIGBnsLynRrPJ3hybkd", + "object": "event", + "api_version": "2022-08-01", + "created": 1695910504, + "data": { + "object": { + "id": "pm_1NvKzbIGBnsLynRry6x7Buvc", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 6, + "exp_year": 2033, + "fingerprint": "0VgUBpvqcUUnuSmK", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1695910503, + "customer": "cus_OimYrxnMTMMK1E", + "livemode": false, + "metadata": { + }, + "type": "card" + } + }, + "livemode": false, + "pending_webhooks": 7, + "request": { + "id": "req_2WslNSBD9wAV5v", + "idempotency_key": "db1a648a-3445-47b3-a403-9f3d1303a880" + }, + "type": "payment_method.attached" +} diff --git a/test/Billing.Test/Services/StripeEventServiceTests.cs b/test/Billing.Test/Services/StripeEventServiceTests.cs new file mode 100644 index 000000000000..5b1642413d9f --- /dev/null +++ b/test/Billing.Test/Services/StripeEventServiceTests.cs @@ -0,0 +1,691 @@ +using Bit.Billing.Services; +using Bit.Billing.Services.Implementations; +using Bit.Billing.Test.Utilities; +using Bit.Core.Settings; +using FluentAssertions; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Billing.Test.Services; + +public class StripeEventServiceTests +{ + private readonly IStripeFacade _stripeFacade; + private readonly IStripeEventService _stripeEventService; + + public StripeEventServiceTests() + { + var globalSettings = new GlobalSettings(); + var baseServiceUriSettings = new GlobalSettings.BaseServiceUriSettings(globalSettings) { CloudRegion = "US" }; + globalSettings.BaseServiceUri = baseServiceUriSettings; + + _stripeFacade = Substitute.For(); + _stripeEventService = new StripeEventService(globalSettings, _stripeFacade); + } + + #region GetCharge + [Fact] + public async Task GetCharge_EventNotChargeRelated_ThrowsException() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + + // Act + var function = async () => await _stripeEventService.GetCharge(stripeEvent); + + // Assert + await function + .Should() + .ThrowAsync() + .WithMessage($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Charge)}'"); + + await _stripeFacade.DidNotReceiveWithAnyArgs().GetCharge( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetCharge_NotFresh_ReturnsEventCharge() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.ChargeSucceeded); + + // Act + var charge = await _stripeEventService.GetCharge(stripeEvent); + + // Assert + charge.Should().BeEquivalentTo(stripeEvent.Data.Object as Charge); + + await _stripeFacade.DidNotReceiveWithAnyArgs().GetCharge( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetCharge_Fresh_Expand_ReturnsAPICharge() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.ChargeSucceeded); + + var eventCharge = stripeEvent.Data.Object as Charge; + + var apiCharge = Copy(eventCharge); + + var expand = new List { "customer" }; + + _stripeFacade.GetCharge( + apiCharge.Id, + Arg.Is(options => options.Expand == expand)) + .Returns(apiCharge); + + // Act + var charge = await _stripeEventService.GetCharge(stripeEvent, true, expand); + + // Assert + charge.Should().Be(apiCharge); + charge.Should().NotBeSameAs(eventCharge); + + await _stripeFacade.Received().GetCharge( + apiCharge.Id, + Arg.Is(options => options.Expand == expand), + Arg.Any(), + Arg.Any()); + } + #endregion + + #region GetCustomer + [Fact] + public async Task GetCustomer_EventNotCustomerRelated_ThrowsException() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + + // Act + var function = async () => await _stripeEventService.GetCustomer(stripeEvent); + + // Assert + await function + .Should() + .ThrowAsync() + .WithMessage($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Customer)}'"); + + await _stripeFacade.DidNotReceiveWithAnyArgs().GetCustomer( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetCustomer_NotFresh_ReturnsEventCustomer() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + + // Act + var customer = await _stripeEventService.GetCustomer(stripeEvent); + + // Assert + customer.Should().BeEquivalentTo(stripeEvent.Data.Object as Customer); + + await _stripeFacade.DidNotReceiveWithAnyArgs().GetCustomer( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetCustomer_Fresh_Expand_ReturnsAPICustomer() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + + var eventCustomer = stripeEvent.Data.Object as Customer; + + var apiCustomer = Copy(eventCustomer); + + var expand = new List { "subscriptions" }; + + _stripeFacade.GetCustomer( + apiCustomer.Id, + Arg.Is(options => options.Expand == expand)) + .Returns(apiCustomer); + + // Act + var customer = await _stripeEventService.GetCustomer(stripeEvent, true, expand); + + // Assert + customer.Should().Be(apiCustomer); + customer.Should().NotBeSameAs(eventCustomer); + + await _stripeFacade.Received().GetCustomer( + apiCustomer.Id, + Arg.Is(options => options.Expand == expand), + Arg.Any(), + Arg.Any()); + } + #endregion + + #region GetInvoice + [Fact] + public async Task GetInvoice_EventNotInvoiceRelated_ThrowsException() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + + // Act + var function = async () => await _stripeEventService.GetInvoice(stripeEvent); + + // Assert + await function + .Should() + .ThrowAsync() + .WithMessage($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Invoice)}'"); + + await _stripeFacade.DidNotReceiveWithAnyArgs().GetInvoice( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetInvoice_NotFresh_ReturnsEventInvoice() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + + // Act + var invoice = await _stripeEventService.GetInvoice(stripeEvent); + + // Assert + invoice.Should().BeEquivalentTo(stripeEvent.Data.Object as Invoice); + + await _stripeFacade.DidNotReceiveWithAnyArgs().GetInvoice( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetInvoice_Fresh_Expand_ReturnsAPIInvoice() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + + var eventInvoice = stripeEvent.Data.Object as Invoice; + + var apiInvoice = Copy(eventInvoice); + + var expand = new List { "customer" }; + + _stripeFacade.GetInvoice( + apiInvoice.Id, + Arg.Is(options => options.Expand == expand)) + .Returns(apiInvoice); + + // Act + var invoice = await _stripeEventService.GetInvoice(stripeEvent, true, expand); + + // Assert + invoice.Should().Be(apiInvoice); + invoice.Should().NotBeSameAs(eventInvoice); + + await _stripeFacade.Received().GetInvoice( + apiInvoice.Id, + Arg.Is(options => options.Expand == expand), + Arg.Any(), + Arg.Any()); + } + #endregion + + #region GetPaymentMethod + [Fact] + public async Task GetPaymentMethod_EventNotPaymentMethodRelated_ThrowsException() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + + // Act + var function = async () => await _stripeEventService.GetPaymentMethod(stripeEvent); + + // Assert + await function + .Should() + .ThrowAsync() + .WithMessage($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(PaymentMethod)}'"); + + await _stripeFacade.DidNotReceiveWithAnyArgs().GetPaymentMethod( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetPaymentMethod_NotFresh_ReturnsEventPaymentMethod() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached); + + // Act + var paymentMethod = await _stripeEventService.GetPaymentMethod(stripeEvent); + + // Assert + paymentMethod.Should().BeEquivalentTo(stripeEvent.Data.Object as PaymentMethod); + + await _stripeFacade.DidNotReceiveWithAnyArgs().GetPaymentMethod( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetPaymentMethod_Fresh_Expand_ReturnsAPIPaymentMethod() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached); + + var eventPaymentMethod = stripeEvent.Data.Object as PaymentMethod; + + var apiPaymentMethod = Copy(eventPaymentMethod); + + var expand = new List { "customer" }; + + _stripeFacade.GetPaymentMethod( + apiPaymentMethod.Id, + Arg.Is(options => options.Expand == expand)) + .Returns(apiPaymentMethod); + + // Act + var paymentMethod = await _stripeEventService.GetPaymentMethod(stripeEvent, true, expand); + + // Assert + paymentMethod.Should().Be(apiPaymentMethod); + paymentMethod.Should().NotBeSameAs(eventPaymentMethod); + + await _stripeFacade.Received().GetPaymentMethod( + apiPaymentMethod.Id, + Arg.Is(options => options.Expand == expand), + Arg.Any(), + Arg.Any()); + } + #endregion + + #region GetSubscription + [Fact] + public async Task GetSubscription_EventNotSubscriptionRelated_ThrowsException() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + + // Act + var function = async () => await _stripeEventService.GetSubscription(stripeEvent); + + // Assert + await function + .Should() + .ThrowAsync() + .WithMessage($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Subscription)}'"); + + await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetSubscription_NotFresh_ReturnsEventSubscription() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + + // Act + var subscription = await _stripeEventService.GetSubscription(stripeEvent); + + // Assert + subscription.Should().BeEquivalentTo(stripeEvent.Data.Object as Subscription); + + await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetSubscription_Fresh_Expand_ReturnsAPISubscription() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + + var eventSubscription = stripeEvent.Data.Object as Subscription; + + var apiSubscription = Copy(eventSubscription); + + var expand = new List { "customer" }; + + _stripeFacade.GetSubscription( + apiSubscription.Id, + Arg.Is(options => options.Expand == expand)) + .Returns(apiSubscription); + + // Act + var subscription = await _stripeEventService.GetSubscription(stripeEvent, true, expand); + + // Assert + subscription.Should().Be(apiSubscription); + subscription.Should().NotBeSameAs(eventSubscription); + + await _stripeFacade.Received().GetSubscription( + apiSubscription.Id, + Arg.Is(options => options.Expand == expand), + Arg.Any(), + Arg.Any()); + } + #endregion + + #region ValidateCloudRegion + [Fact] + public async Task ValidateCloudRegion_SubscriptionUpdated_Success() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + + var subscription = Copy(stripeEvent.Data.Object as Subscription); + + var customer = await GetCustomerAsync(); + + subscription.Customer = customer; + + _stripeFacade.GetSubscription( + subscription.Id, + Arg.Any()) + .Returns(subscription); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + cloudRegionValid.Should().BeTrue(); + + await _stripeFacade.Received(1).GetSubscription( + subscription.Id, + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ValidateCloudRegion_ChargeSucceeded_Success() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.ChargeSucceeded); + + var charge = Copy(stripeEvent.Data.Object as Charge); + + var customer = await GetCustomerAsync(); + + charge.Customer = customer; + + _stripeFacade.GetCharge( + charge.Id, + Arg.Any()) + .Returns(charge); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + cloudRegionValid.Should().BeTrue(); + + await _stripeFacade.Received(1).GetCharge( + charge.Id, + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ValidateCloudRegion_UpcomingInvoice_Success() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceUpcoming); + + var invoice = Copy(stripeEvent.Data.Object as Invoice); + + var customer = await GetCustomerAsync(); + + invoice.Customer = customer; + + _stripeFacade.GetInvoice( + invoice.Id, + Arg.Any()) + .Returns(invoice); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + cloudRegionValid.Should().BeTrue(); + + await _stripeFacade.Received(1).GetInvoice( + invoice.Id, + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ValidateCloudRegion_InvoiceCreated_Success() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + + var invoice = Copy(stripeEvent.Data.Object as Invoice); + + var customer = await GetCustomerAsync(); + + invoice.Customer = customer; + + _stripeFacade.GetInvoice( + invoice.Id, + Arg.Any()) + .Returns(invoice); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + cloudRegionValid.Should().BeTrue(); + + await _stripeFacade.Received(1).GetInvoice( + invoice.Id, + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ValidateCloudRegion_PaymentMethodAttached_Success() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached); + + var paymentMethod = Copy(stripeEvent.Data.Object as PaymentMethod); + + var customer = await GetCustomerAsync(); + + paymentMethod.Customer = customer; + + _stripeFacade.GetPaymentMethod( + paymentMethod.Id, + Arg.Any()) + .Returns(paymentMethod); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + cloudRegionValid.Should().BeTrue(); + + await _stripeFacade.Received(1).GetPaymentMethod( + paymentMethod.Id, + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ValidateCloudRegion_CustomerUpdated_Success() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + + var customer = Copy(stripeEvent.Data.Object as Customer); + + _stripeFacade.GetCustomer( + customer.Id, + Arg.Any()) + .Returns(customer); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + cloudRegionValid.Should().BeTrue(); + + await _stripeFacade.Received(1).GetCustomer( + customer.Id, + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ValidateCloudRegion_MetadataNull_ReturnsFalse() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + + var subscription = Copy(stripeEvent.Data.Object as Subscription); + + var customer = await GetCustomerAsync(); + customer.Metadata = null; + + subscription.Customer = customer; + + _stripeFacade.GetSubscription( + subscription.Id, + Arg.Any()) + .Returns(subscription); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + cloudRegionValid.Should().BeFalse(); + + await _stripeFacade.Received(1).GetSubscription( + subscription.Id, + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ValidateCloudRegion_MetadataNoRegion_DefaultUS_ReturnsTrue() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + + var subscription = Copy(stripeEvent.Data.Object as Subscription); + + var customer = await GetCustomerAsync(); + customer.Metadata = new Dictionary(); + + subscription.Customer = customer; + + _stripeFacade.GetSubscription( + subscription.Id, + Arg.Any()) + .Returns(subscription); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + cloudRegionValid.Should().BeTrue(); + + await _stripeFacade.Received(1).GetSubscription( + subscription.Id, + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task ValidateCloudRegion_MetadataMiscasedRegion_ReturnsTrue() + { + // Arrange + var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + + var subscription = Copy(stripeEvent.Data.Object as Subscription); + + var customer = await GetCustomerAsync(); + customer.Metadata = new Dictionary + { + { "Region", "US" } + }; + + subscription.Customer = customer; + + _stripeFacade.GetSubscription( + subscription.Id, + Arg.Any()) + .Returns(subscription); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + cloudRegionValid.Should().BeTrue(); + + await _stripeFacade.Received(1).GetSubscription( + subscription.Id, + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + #endregion + + private static T Copy(T input) + { + var copy = (T)Activator.CreateInstance(typeof(T)); + + var properties = input.GetType().GetProperties(); + + foreach (var property in properties) + { + var value = property.GetValue(input); + copy! + .GetType() + .GetProperty(property.Name)! + .SetValue(copy, value); + } + + return copy; + } + + private static async Task GetCustomerAsync() + => (await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated)).Data.Object as Customer; +} diff --git a/test/Billing.Test/Utilities/EmbeddedResourceReader.cs b/test/Billing.Test/Utilities/EmbeddedResourceReader.cs new file mode 100644 index 000000000000..a9a5612ea6bb --- /dev/null +++ b/test/Billing.Test/Utilities/EmbeddedResourceReader.cs @@ -0,0 +1,22 @@ +using System.Reflection; + +namespace Bit.Billing.Test.Utilities; + +public static class EmbeddedResourceReader +{ + public static async Task ReadAsync(string resourceType, string fileName) + { + var assembly = Assembly.GetExecutingAssembly(); + + await using var stream = assembly.GetManifestResourceStream($"Bit.Billing.Test.Resources.{resourceType}.{fileName}"); + + if (stream == null) + { + throw new Exception($"Failed to retrieve manifest resource stream for file: {fileName}."); + } + + using var reader = new StreamReader(stream); + + return await reader.ReadToEndAsync(); + } +} diff --git a/test/Billing.Test/Utilities/StripeTestEvents.cs b/test/Billing.Test/Utilities/StripeTestEvents.cs new file mode 100644 index 000000000000..eb1095bc2330 --- /dev/null +++ b/test/Billing.Test/Utilities/StripeTestEvents.cs @@ -0,0 +1,33 @@ +using Stripe; + +namespace Bit.Billing.Test.Utilities; + +public enum StripeEventType +{ + ChargeSucceeded, + CustomerSubscriptionUpdated, + CustomerUpdated, + InvoiceCreated, + InvoiceUpcoming, + PaymentMethodAttached +} + +public static class StripeTestEvents +{ + public static async Task GetAsync(StripeEventType eventType) + { + var fileName = eventType switch + { + StripeEventType.ChargeSucceeded => "charge.succeeded.json", + StripeEventType.CustomerSubscriptionUpdated => "customer.subscription.updated.json", + StripeEventType.CustomerUpdated => "customer.updated.json", + StripeEventType.InvoiceCreated => "invoice.created.json", + StripeEventType.InvoiceUpcoming => "invoice.upcoming.json", + StripeEventType.PaymentMethodAttached => "payment_method.attached.json" + }; + + var resource = await EmbeddedResourceReader.ReadAsync("Events", fileName); + + return EventUtility.ParseEvent(resource); + } +} diff --git a/test/Billing.Test/packages.lock.json b/test/Billing.Test/packages.lock.json index 828483938525..32f0b9630119 100644 --- a/test/Billing.Test/packages.lock.json +++ b/test/Billing.Test/packages.lock.json @@ -18,6 +18,15 @@ "resolved": "3.1.2", "contentHash": "wuLDIDKD5XMt0A7lE31JPenT7QQwZPFkP5rRpdJeblyXZ9MGLI8rYjvm5fvAKln+2/X+4IxxQDxBtwdrqKNLZw==" }, + "FluentAssertions": { + "type": "Direct", + "requested": "[6.12.0, )", + "resolved": "6.12.0", + "contentHash": "ZXhHT2YwP9lajrwSKbLlFqsmCCvFJMoRSK9t7sImfnCyd0OB3MhgxdoMcVqxbq1iyxD6mD2fiackWmBb7ayiXQ==", + "dependencies": { + "System.Configuration.ConfigurationManager": "4.4.0" + } + }, "Microsoft.NET.Test.Sdk": { "type": "Direct", "requested": "[17.1.0, )", From 79648b311ef6d503286905ef27d0f3aac5b78446 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 12 Oct 2023 05:15:02 -0400 Subject: [PATCH 2/4] [PM-3555] Remove `ClearTracker()` (#3213) * Remove ClearTracker * Remove from CipherRepositoryTests --- .../AuthRequestRepositoryTests.cs | 5 +-- .../EmergencyAccessRepositoryTests.cs | 5 +-- .../DatabaseDataAttribute.cs | 5 +-- .../OrganizationUserRepositoryTests.cs | 10 +---- .../TestDatabaseHelpers.cs | 42 ------------------- .../Repositories/CipherRepositoryTests.cs | 17 ++------ 6 files changed, 9 insertions(+), 75 deletions(-) delete mode 100644 test/Infrastructure.IntegrationTest/TestDatabaseHelpers.cs diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/AuthRequestRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Auth/Repositories/AuthRequestRepositoryTests.cs index b23b8ce4b877..6c5bf135e144 100644 --- a/test/Infrastructure.IntegrationTest/Auth/Repositories/AuthRequestRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Auth/Repositories/AuthRequestRepositoryTests.cs @@ -15,8 +15,7 @@ public class AuthRequestRepositoryTests [DatabaseTheory, DatabaseData] public async Task DeleteExpiredAsync_Works( IAuthRequestRepository authRequestRepository, - IUserRepository userRepository, - ITestDatabaseHelper helper) + IUserRepository userRepository) { var user = await userRepository.CreateAsync(new User { @@ -54,8 +53,6 @@ public async Task DeleteExpiredAsync_Works( var notExpiredApprovedAdminApprovalRequest = await authRequestRepository.CreateAsync( CreateAuthRequest(user.Id, AuthRequestType.AdminApproval, DateTime.UtcNow.AddDays(7), true, DateTime.UtcNow.AddHours(11))); - helper.ClearTracker(); - var numberOfDeleted = await authRequestRepository.DeleteExpiredAsync(_userRequestExpiration, _adminRequestExpiration, _afterAdminApprovalExpiration); // Ensure all the AuthRequests that should have been deleted, have been deleted. diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/EmergencyAccessRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Auth/Repositories/EmergencyAccessRepositoryTests.cs index 6454c01ea9ec..def3dcb1e76b 100644 --- a/test/Infrastructure.IntegrationTest/Auth/Repositories/EmergencyAccessRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Auth/Repositories/EmergencyAccessRepositoryTests.cs @@ -10,8 +10,7 @@ public class EmergencyAccessRepositoriesTests { [DatabaseTheory, DatabaseData] public async Task DeleteAsync_UpdatesRevisionDate(IUserRepository userRepository, - IEmergencyAccessRepository emergencyAccessRepository, - ITestDatabaseHelper helper) + IEmergencyAccessRepository emergencyAccessRepository) { var grantorUser = await userRepository.CreateAsync(new User { @@ -36,8 +35,6 @@ public async Task DeleteAsync_UpdatesRevisionDate(IUserRepository userRepository Status = EmergencyAccessStatusType.Confirmed, }); - helper.ClearTracker(); - await emergencyAccessRepository.DeleteAsync(emergencyAccess); var updatedGrantee = await userRepository.GetByIdAsync(granteeUser.Id); diff --git a/test/Infrastructure.IntegrationTest/DatabaseDataAttribute.cs b/test/Infrastructure.IntegrationTest/DatabaseDataAttribute.cs index 3bf2211020b8..6433fcb207c1 100644 --- a/test/Infrastructure.IntegrationTest/DatabaseDataAttribute.cs +++ b/test/Infrastructure.IntegrationTest/DatabaseDataAttribute.cs @@ -3,7 +3,6 @@ using Bit.Core.Settings; using Bit.Infrastructure.Dapper; using Bit.Infrastructure.EntityFramework; -using Bit.Infrastructure.EntityFramework.Repositories; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -65,7 +64,7 @@ private IEnumerable GetDatabaseProviders(IConfiguration config }; dapperSqlServerCollection.AddSingleton(globalSettings); dapperSqlServerCollection.AddSingleton(globalSettings); - dapperSqlServerCollection.AddSingleton(_ => new DapperSqlServerTestDatabaseHelper(database)); + dapperSqlServerCollection.AddSingleton(database); dapperSqlServerCollection.AddDataProtection(); yield return dapperSqlServerCollection.BuildServiceProvider(); } @@ -75,7 +74,7 @@ private IEnumerable GetDatabaseProviders(IConfiguration config efCollection.AddLogging(configureLogging); efCollection.SetupEntityFramework(database.ConnectionString, database.Type); efCollection.AddPasswordManagerEFRepositories(SelfHosted); - efCollection.AddTransient(sp => new EfTestDatabaseHelper(sp.GetRequiredService(), database)); + efCollection.AddSingleton(database); efCollection.AddDataProtection(); yield return efCollection.BuildServiceProvider(); } diff --git a/test/Infrastructure.IntegrationTest/Repositories/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Repositories/OrganizationUserRepositoryTests.cs index 1b780f114dd3..e07b3ba72069 100644 --- a/test/Infrastructure.IntegrationTest/Repositories/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Repositories/OrganizationUserRepositoryTests.cs @@ -10,8 +10,7 @@ public class OrganizationUserRepositoryTests [DatabaseTheory, DatabaseData] public async Task DeleteAsync_Works(IUserRepository userRepository, IOrganizationRepository organizationRepository, - IOrganizationUserRepository organizationUserRepository, - ITestDatabaseHelper helper) + IOrganizationUserRepository organizationUserRepository) { var user = await userRepository.CreateAsync(new User { @@ -35,8 +34,6 @@ public async Task DeleteAsync_Works(IUserRepository userRepository, Status = OrganizationUserStatusType.Confirmed, }); - helper.ClearTracker(); - await organizationUserRepository.DeleteAsync(orgUser); var newUser = await userRepository.GetByIdAsync(user.Id); @@ -46,8 +43,7 @@ public async Task DeleteAsync_Works(IUserRepository userRepository, [DatabaseTheory, DatabaseData] public async Task DeleteManyAsync_Works(IUserRepository userRepository, IOrganizationRepository organizationRepository, - IOrganizationUserRepository organizationUserRepository, - ITestDatabaseHelper helper) + IOrganizationUserRepository organizationUserRepository) { var user1 = await userRepository.CreateAsync(new User { @@ -86,8 +82,6 @@ public async Task DeleteManyAsync_Works(IUserRepository userRepository, Status = OrganizationUserStatusType.Confirmed, }); - helper.ClearTracker(); - await organizationUserRepository.DeleteManyAsync(new List { orgUser1.Id, diff --git a/test/Infrastructure.IntegrationTest/TestDatabaseHelpers.cs b/test/Infrastructure.IntegrationTest/TestDatabaseHelpers.cs deleted file mode 100644 index 5cf8c73a8a7b..000000000000 --- a/test/Infrastructure.IntegrationTest/TestDatabaseHelpers.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Bit.Infrastructure.EntityFramework.Repositories; - -namespace Bit.Infrastructure.IntegrationTest; - -public interface ITestDatabaseHelper -{ - Database Info { get; } - void ClearTracker(); -} - -public class EfTestDatabaseHelper : ITestDatabaseHelper -{ - private readonly DatabaseContext _databaseContext; - - public EfTestDatabaseHelper(DatabaseContext databaseContext, Database database) - { - _databaseContext = databaseContext; - Info = database; - } - - public Database Info { get; } - - public void ClearTracker() - { - _databaseContext.ChangeTracker.Clear(); - } -} - -public class DapperSqlServerTestDatabaseHelper : ITestDatabaseHelper -{ - public DapperSqlServerTestDatabaseHelper(Database database) - { - Info = database; - } - - public Database Info { get; } - - public void ClearTracker() - { - // There are no tracked entities in Dapper SQL Server - } -} diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs index d9cc228c203b..1712313d3765 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs @@ -16,8 +16,7 @@ public class CipherRepositoryTests [DatabaseTheory, DatabaseData] public async Task DeleteAsync_UpdatesUserRevisionDate( IUserRepository userRepository, - ICipherRepository cipherRepository, - ITestDatabaseHelper helper) + ICipherRepository cipherRepository) { var user = await userRepository.CreateAsync(new User { @@ -34,8 +33,6 @@ public async Task DeleteAsync_UpdatesUserRevisionDate( Data = "", // TODO: EF does not enforce this as NOT NULL }); - helper.ClearTracker(); - await cipherRepository.DeleteAsync(cipher); var deletedCipher = await cipherRepository.GetByIdAsync(cipher.Id); @@ -52,8 +49,7 @@ public async Task CreateAsync_UpdateWithCollections_Works( IOrganizationUserRepository organizationUserRepository, ICollectionRepository collectionRepository, ICipherRepository cipherRepository, - ICollectionCipherRepository collectionCipherRepository, - ITestDatabaseHelper helper) + ICollectionCipherRepository collectionCipherRepository) { var user = await userRepository.CreateAsync(new User { @@ -63,8 +59,6 @@ public async Task CreateAsync_UpdateWithCollections_Works( SecurityStamp = "stamp", }); - helper.ClearTracker(); - user = await userRepository.GetByIdAsync(user.Id); var organization = await organizationRepository.CreateAsync(new Organization @@ -100,8 +94,6 @@ await collectionRepository.UpdateUsersAsync(collection.Id, new[] }, }); - helper.ClearTracker(); - await Task.Delay(100); await cipherRepository.CreateAsync(new CipherDetails @@ -128,8 +120,7 @@ public async Task ReplaceAsync_SuccessfullyMovesCipherToOrganization(IUserReposi ICipherRepository cipherRepository, IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, - IFolderRepository folderRepository, - ITestDatabaseHelper helper) + IFolderRepository folderRepository) { // This tests what happens when a cipher is moved into an organizations var user = await userRepository.CreateAsync(new User @@ -171,8 +162,6 @@ public async Task ReplaceAsync_SuccessfullyMovesCipherToOrganization(IUserReposi UserId = user.Id, }); - helper.ClearTracker(); - // Move cipher to organization vault await cipherRepository.ReplaceAsync(new CipherDetails { From 53f5eee215c7c66dde29527c39d457bab819f78d Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Fri, 13 Oct 2023 00:56:50 +1000 Subject: [PATCH 3/4] [AC-1638] Disallow Secrets Manager for MSP-managed organizations (#3297) * Block MSPs from creating orgs with SM * Block MSPs from adding SM to a managed org * Prevent manually adding SM to an MSP-managed org * Revert "Prevent manually adding SM to an MSP-managed org" This change is no longer required This reverts commit 51b086243bf7ab63897a904b6b14fa1077a2bc6e. * Block provider from adding org with SM * Update error message when adding existing org with SM to provider * Update check to match client * Revert "Update check to match client" This reverts commit f195c1c1f6546757a5d591068a0a650ef0d8dceb. --- .../Services/ProviderService.cs | 6 +++++ .../Services/ProviderServiceTests.cs | 17 ++++++++++++++ .../AddSecretsManagerSubscriptionCommand.cs | 19 +++++++++++++--- .../Implementations/OrganizationService.cs | 5 +++++ ...dSecretsManagerSubscriptionCommandTests.cs | 22 +++++++++++++++++++ .../Services/OrganizationServiceTests.cs | 16 ++++++++++++++ 6 files changed, 82 insertions(+), 3 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/Services/ProviderService.cs index 9a5f924246c0..d186eb6d4153 100644 --- a/bitwarden_license/src/Commercial.Core/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/Services/ProviderService.cs @@ -354,6 +354,12 @@ public async Task AddOrganization(Guid providerId, Guid organizationId, string k var organization = await _organizationRepository.GetByIdAsync(organizationId); ThrowOnInvalidPlanType(organization.PlanType); + if (organization.UseSecretsManager) + { + throw new BadRequestException( + "The organization is subscribed to Secrets Manager. Please contact Customer Support to manage the subscription."); + } + var providerOrganization = new ProviderOrganization { ProviderId = providerId, diff --git a/bitwarden_license/test/Commercial.Core.Test/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Services/ProviderServiceTests.cs index babfa9c0741a..2a6e68bfb422 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Services/ProviderServiceTests.cs @@ -431,6 +431,23 @@ public async Task AddOrganization_OrganizationAlreadyBelongsToAProvider_Throws(P Assert.Equal("Organization already belongs to a provider.", exception.Message); } + [Theory, BitAutoData] + public async Task AddOrganization_OrganizationHasSecretsManager_Throws(Provider provider, Organization organization, string key, + SutProvider sutProvider) + { + organization.PlanType = PlanType.EnterpriseAnnually; + organization.UseSecretsManager = true; + + sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); + var providerOrganizationRepository = sutProvider.GetDependency(); + providerOrganizationRepository.GetByOrganizationId(organization.Id).ReturnsNull(); + sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key)); + Assert.Equal("The organization is subscribed to Secrets Manager. Please contact Customer Support to manage the subscription.", exception.Message); + } + [Theory, BitAutoData] public async Task AddOrganization_Success(Provider provider, Organization organization, string key, SutProvider sutProvider) diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/AddSecretsManagerSubscriptionCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/AddSecretsManagerSubscriptionCommand.cs index 3741148af450..52136bd1b53c 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/AddSecretsManagerSubscriptionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/AddSecretsManagerSubscriptionCommand.cs @@ -1,8 +1,10 @@ using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Enums.Provider; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; +using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; @@ -12,17 +14,21 @@ public class AddSecretsManagerSubscriptionCommand : IAddSecretsManagerSubscripti { private readonly IPaymentService _paymentService; private readonly IOrganizationService _organizationService; + private readonly IProviderRepository _providerRepository; + public AddSecretsManagerSubscriptionCommand( IPaymentService paymentService, - IOrganizationService organizationService) + IOrganizationService organizationService, + IProviderRepository providerRepository) { _paymentService = paymentService; _organizationService = organizationService; + _providerRepository = providerRepository; } public async Task SignUpAsync(Organization organization, int additionalSmSeats, int additionalServiceAccounts) { - ValidateOrganization(organization); + await ValidateOrganization(organization); var plan = StaticStore.GetSecretsManagerPlan(organization.PlanType); var signup = SetOrganizationUpgrade(organization, additionalSmSeats, additionalServiceAccounts); @@ -55,7 +61,7 @@ private static OrganizationUpgrade SetOrganizationUpgrade(Organization organizat return signup; } - private static void ValidateOrganization(Organization organization) + private async Task ValidateOrganization(Organization organization) { if (organization == null) { @@ -83,5 +89,12 @@ private static void ValidateOrganization(Organization organization) { throw new BadRequestException("No subscription found."); } + + var provider = await _providerRepository.GetByOrganizationIdAsync(organization.Id); + if (provider is { Type: ProviderType.Msp }) + { + throw new BadRequestException( + "Organizations with a Managed Service Provider do not support Secrets Manager."); + } } } diff --git a/src/Core/Services/Implementations/OrganizationService.cs b/src/Core/Services/Implementations/OrganizationService.cs index 10dc2a205608..2388faded2b4 100644 --- a/src/Core/Services/Implementations/OrganizationService.cs +++ b/src/Core/Services/Implementations/OrganizationService.cs @@ -410,6 +410,11 @@ public async Task> SignUpAsync(Organizatio var secretsManagerPlan = StaticStore.SecretManagerPlans.FirstOrDefault(p => p.Type == signup.Plan); if (signup.UseSecretsManager) { + if (provider) + { + throw new BadRequestException( + "Organizations with a Managed Service Provider do not support Secrets Manager."); + } ValidateSecretsManagerPlan(secretsManagerPlan, signup); } diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/AddSecretsManagerSubscriptionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/AddSecretsManagerSubscriptionCommandTests.cs index a09500cf67c7..ec83fa1022a4 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/AddSecretsManagerSubscriptionCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/AddSecretsManagerSubscriptionCommandTests.cs @@ -1,9 +1,12 @@ using Bit.Core.Entities; +using Bit.Core.Entities.Provider; using Bit.Core.Enums; +using Bit.Core.Enums.Provider; using Bit.Core.Exceptions; using Bit.Core.Models.Business; using Bit.Core.Models.StaticStore; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions; +using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; @@ -127,6 +130,25 @@ public async Task SignUpAsync_ThrowsException_WhenOrganizationAlreadyHasSecretsM await VerifyDependencyNotCalledAsync(sutProvider); } + [Theory] + [BitAutoData] + public async Task SignUpAsync_ThrowsException_WhenOrganizationIsManagedByMSP( + SutProvider sutProvider, + Organization organization, + Provider provider) + { + organization.UseSecretsManager = false; + organization.SecretsManagerBeta = false; + provider.Type = ProviderType.Msp; + sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id).Returns(provider); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.SignUpAsync(organization, 10, 10)); + + Assert.Contains("Organizations with a Managed Service Provider do not support Secrets Manager.", exception.Message); + await VerifyDependencyNotCalledAsync(sutProvider); + } + private static async Task VerifyDependencyNotCalledAsync(SutProvider sutProvider) { await sutProvider.GetDependency().DidNotReceive() diff --git a/test/Core.Test/Services/OrganizationServiceTests.cs b/test/Core.Test/Services/OrganizationServiceTests.cs index 4907efafcb75..a4974d39af38 100644 --- a/test/Core.Test/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/Services/OrganizationServiceTests.cs @@ -263,6 +263,22 @@ await sutProvider.GetDependency().Received(1).PurchaseOrganizat ); } + [Theory] + [BitAutoData(PlanType.EnterpriseAnnually)] + public async Task SignUp_SM_Throws_WhenManagedByMSP(PlanType planType, OrganizationSignup signup, SutProvider sutProvider) + { + signup.Plan = planType; + signup.UseSecretsManager = true; + signup.AdditionalSeats = 15; + signup.AdditionalSmSeats = 10; + signup.AdditionalServiceAccounts = 20; + signup.PaymentMethodType = PaymentMethodType.Card; + signup.PremiumAccessAddon = false; + + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.SignUpAsync(signup, true)); + Assert.Contains("Organizations with a Managed Service Provider do not support Secrets Manager.", exception.Message); + } + [Theory] [BitAutoData] public async Task SignUpAsync_SecretManager_AdditionalServiceAccounts_NotAllowedByPlan_ShouldThrowException(OrganizationSignup signup, SutProvider sutProvider) From f228dcd668266e01c85b47d176650adabcdffc94 Mon Sep 17 00:00:00 2001 From: MtnBurrit0 <77340197+mimartin12@users.noreply.github.com> Date: Thu, 12 Oct 2023 12:42:28 -0600 Subject: [PATCH 4/4] Rename DbScripts_future and DbScripts_data_migrations (#3192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rename DbScripts_future and DbScripts_data_migrations * Rename embeded folder name * Remove new files from stale PR --------- Co-authored-by: Michał Chęciński Co-authored-by: Michał Chęciński --- .../2023-01-FutureMigration.sql | 0 .../2023-02-FutureMigration.sql | 0 util/Migrator/Migrator.csproj | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) rename util/Migrator/{DbScripts_future => DbScripts_finalization}/2023-01-FutureMigration.sql (100%) rename util/Migrator/{DbScripts_future => DbScripts_finalization}/2023-02-FutureMigration.sql (100%) diff --git a/util/Migrator/DbScripts_future/2023-01-FutureMigration.sql b/util/Migrator/DbScripts_finalization/2023-01-FutureMigration.sql similarity index 100% rename from util/Migrator/DbScripts_future/2023-01-FutureMigration.sql rename to util/Migrator/DbScripts_finalization/2023-01-FutureMigration.sql diff --git a/util/Migrator/DbScripts_future/2023-02-FutureMigration.sql b/util/Migrator/DbScripts_finalization/2023-02-FutureMigration.sql similarity index 100% rename from util/Migrator/DbScripts_future/2023-02-FutureMigration.sql rename to util/Migrator/DbScripts_finalization/2023-02-FutureMigration.sql diff --git a/util/Migrator/Migrator.csproj b/util/Migrator/Migrator.csproj index e18f5cc1f96c..017e339acbaa 100644 --- a/util/Migrator/Migrator.csproj +++ b/util/Migrator/Migrator.csproj @@ -2,7 +2,7 @@ - +