diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index aa19ad5382..aaf0050b63 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -12,7 +12,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; @@ -90,7 +90,7 @@ public class ProviderService : IProviderService _providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand; } - public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null) + public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress) { var owner = await _userService.GetUserByIdAsync(ownerUserId); if (owner == null) @@ -115,21 +115,7 @@ public class ProviderService : IProviderService throw new BadRequestException("Invalid owner."); } - if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode)) - { - throw new BadRequestException("Both address and postal code are required to set up your provider."); - } - - if (tokenizedPaymentSource is not - { - Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal, - Token: not null and not "" - }) - { - throw new BadRequestException("A payment method is required to set up your provider."); - } - - var customer = await _providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + var customer = await _providerBillingService.SetupCustomer(provider, paymentMethod, billingAddress); provider.GatewayCustomerId = customer.Id; var subscription = await _providerBillingService.SetupSubscription(provider); provider.GatewaySubscriptionId = subscription.Id; diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs index 398674c7b6..c9851eb403 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs @@ -14,6 +14,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Models; @@ -21,10 +22,8 @@ using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Models; -using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -38,6 +37,9 @@ using Subscription = Stripe.Subscription; namespace Bit.Commercial.Core.Billing.Providers.Services; +using static Constants; +using static StripeConstants; + public class ProviderBillingService( IBraintreeGateway braintreeGateway, IEventService eventService, @@ -51,8 +53,7 @@ public class ProviderBillingService( IProviderUserRepository providerUserRepository, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, - ISubscriberService subscriberService, - ITaxService taxService) + ISubscriberService subscriberService) : IProviderBillingService { public async Task AddExistingOrganization( @@ -61,10 +62,7 @@ public class ProviderBillingService( string key) { await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, - new SubscriptionUpdateOptions - { - CancelAtPeriodEnd = false - }); + new SubscriptionUpdateOptions { CancelAtPeriodEnd = false }); var subscription = await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId, @@ -83,7 +81,7 @@ public class ProviderBillingService( var wasTrialing = subscription.TrialEnd.HasValue && subscription.TrialEnd.Value > now; - if (!wasTrialing && subscription.LatestInvoice.Status == StripeConstants.InvoiceStatus.Draft) + if (!wasTrialing && subscription.LatestInvoice.Status == InvoiceStatus.Draft) { await stripeAdapter.InvoiceFinalizeInvoiceAsync(subscription.LatestInvoiceId, new InvoiceFinalizeOptions { AutoAdvance = true }); @@ -184,16 +182,8 @@ public class ProviderBillingService( { Items = [ - new SubscriptionItemOptions - { - Price = newPriceId, - Quantity = oldSubscriptionItem!.Quantity - }, - new SubscriptionItemOptions - { - Id = oldSubscriptionItem.Id, - Deleted = true - } + new SubscriptionItemOptions { Price = newPriceId, Quantity = oldSubscriptionItem!.Quantity }, + new SubscriptionItemOptions { Id = oldSubscriptionItem.Id, Deleted = true } ] }; @@ -202,7 +192,8 @@ public class ProviderBillingService( // Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId) // 1. Retrieve PlanType and PlanName for ProviderPlan // 2. Assign PlanType & PlanName to Organization - var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerPlan.ProviderId); + var providerOrganizations = + await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerPlan.ProviderId); var newPlan = await pricingClient.GetPlanOrThrow(newPlanType); @@ -213,6 +204,7 @@ public class ProviderBillingService( { throw new ConflictException($"Organization '{providerOrganization.Id}' not found."); } + organization.PlanType = newPlanType; organization.Plan = newPlan.Name; await organizationRepository.ReplaceAsync(organization); @@ -228,15 +220,15 @@ public class ProviderBillingService( if (!string.IsNullOrEmpty(organization.GatewayCustomerId)) { - logger.LogWarning("Client organization ({ID}) already has a populated {FieldName}", organization.Id, nameof(organization.GatewayCustomerId)); + logger.LogWarning("Client organization ({ID}) already has a populated {FieldName}", organization.Id, + nameof(organization.GatewayCustomerId)); return; } - var providerCustomer = await subscriberService.GetCustomerOrThrow(provider, new CustomerGetOptions - { - Expand = ["tax", "tax_ids"] - }); + var providerCustomer = + await subscriberService.GetCustomerOrThrow(provider, + new CustomerGetOptions { Expand = ["tax", "tax_ids"] }); var providerTaxId = providerCustomer.TaxIds.FirstOrDefault(); @@ -269,23 +261,18 @@ public class ProviderBillingService( } ] }, - Metadata = new Dictionary - { - { "region", globalSettings.BaseServiceUri.CloudRegion } - }, - TaxIdData = providerTaxId == null ? null : - [ - new CustomerTaxIdDataOptions - { - Type = providerTaxId.Type, - Value = providerTaxId.Value - } - ] + Metadata = new Dictionary { { "region", globalSettings.BaseServiceUri.CloudRegion } }, + TaxIdData = providerTaxId == null + ? null + : + [ + new CustomerTaxIdDataOptions { Type = providerTaxId.Type, Value = providerTaxId.Value } + ] }; - if (providerCustomer.Address is not { Country: Constants.CountryAbbreviations.UnitedStates }) + if (providerCustomer.Address is not { Country: CountryAbbreviations.UnitedStates }) { - customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse; + customerCreateOptions.TaxExempt = TaxExempt.Reverse; } var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions); @@ -347,9 +334,9 @@ public class ProviderBillingService( .Where(pair => pair.subscription is { Status: - StripeConstants.SubscriptionStatus.Active or - StripeConstants.SubscriptionStatus.Trialing or - StripeConstants.SubscriptionStatus.PastDue + SubscriptionStatus.Active or + SubscriptionStatus.Trialing or + SubscriptionStatus.PastDue }).ToList(); if (active.Count == 0) @@ -474,37 +461,27 @@ public class ProviderBillingService( // Below the limit to above the limit (currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) || // Above the limit to further above the limit - (currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > currentlyAssignedSeatTotal); + (currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && + newlyAssignedSeatTotal > currentlyAssignedSeatTotal); } public async Task SetupCustomer( Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource) + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) { - ArgumentNullException.ThrowIfNull(tokenizedPaymentSource); - - if (taxInfo is not - { - BillingAddressCountry: not null and not "", - BillingAddressPostalCode: not null and not "" - }) - { - logger.LogError("Cannot create customer for provider ({ProviderID}) without both a country and postal code", provider.Id); - throw new BillingException(); - } - var options = new CustomerCreateOptions { Address = new AddressOptions { - Country = taxInfo.BillingAddressCountry, - PostalCode = taxInfo.BillingAddressPostalCode, - Line1 = taxInfo.BillingAddressLine1, - Line2 = taxInfo.BillingAddressLine2, - City = taxInfo.BillingAddressCity, - State = taxInfo.BillingAddressState + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode, + Line1 = billingAddress.Line1, + Line2 = billingAddress.Line2, + City = billingAddress.City, + State = billingAddress.State }, + Coupon = !string.IsNullOrEmpty(provider.DiscountId) ? provider.DiscountId : null, Description = provider.DisplayBusinessName(), Email = provider.BillingEmail, InvoiceSettings = new CustomerInvoiceSettingsOptions @@ -520,93 +497,61 @@ public class ProviderBillingService( } ] }, - Metadata = new Dictionary - { - { "region", globalSettings.BaseServiceUri.CloudRegion } - } + Metadata = new Dictionary { { "region", globalSettings.BaseServiceUri.CloudRegion } }, + TaxExempt = billingAddress.Country != CountryAbbreviations.UnitedStates ? TaxExempt.Reverse : TaxExempt.None }; - if (taxInfo.BillingAddressCountry is not Constants.CountryAbbreviations.UnitedStates) + if (billingAddress.TaxId != null) { - options.TaxExempt = StripeConstants.TaxExempt.Reverse; - } - - if (!string.IsNullOrEmpty(taxInfo.TaxIdNumber)) - { - var taxIdType = taxService.GetStripeTaxCode( - taxInfo.BillingAddressCountry, - taxInfo.TaxIdNumber); - - if (taxIdType == null) - { - logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", - taxInfo.BillingAddressCountry, - taxInfo.TaxIdNumber); - - throw new BadRequestException("billingTaxIdTypeInferenceError"); - } - options.TaxIdData = [ - new CustomerTaxIdDataOptions { Type = taxIdType, Value = taxInfo.TaxIdNumber } + new CustomerTaxIdDataOptions { Type = billingAddress.TaxId.Code, Value = billingAddress.TaxId.Value } ]; - if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) + if (billingAddress.TaxId.Code == TaxIdType.SpanishNIF) { options.TaxIdData.Add(new CustomerTaxIdDataOptions { - Type = StripeConstants.TaxIdType.EUVAT, - Value = $"ES{taxInfo.TaxIdNumber}" + Type = TaxIdType.EUVAT, + Value = $"ES{billingAddress.TaxId.Value}" }); } } - if (!string.IsNullOrEmpty(provider.DiscountId)) - { - options.Coupon = provider.DiscountId; - } - var braintreeCustomerId = ""; - if (tokenizedPaymentSource is not - { - Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal, - Token: not null and not "" - }) - { - logger.LogError("Cannot create customer for provider ({ProviderID}) with invalid payment method", provider.Id); - throw new BillingException(); - } - - var (type, token) = tokenizedPaymentSource; - // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault - switch (type) + switch (paymentMethod.Type) { - case PaymentMethodType.BankAccount: + case TokenizablePaymentMethodType.BankAccount: { var setupIntent = - (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token })) + (await stripeAdapter.SetupIntentList(new SetupIntentListOptions + { + PaymentMethod = paymentMethod.Token + })) .FirstOrDefault(); if (setupIntent == null) { - logger.LogError("Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account", provider.Id); + logger.LogError( + "Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account", + provider.Id); throw new BillingException(); } await setupIntentCache.Set(provider.Id, setupIntent.Id); break; } - case PaymentMethodType.Card: + case TokenizablePaymentMethodType.Card: { - options.PaymentMethod = token; - options.InvoiceSettings.DefaultPaymentMethod = token; + options.PaymentMethod = paymentMethod.Token; + options.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token; break; } - case PaymentMethodType.PayPal: + case TokenizablePaymentMethodType.PayPal: { - braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, token); + braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, paymentMethod.Token); options.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId; break; } @@ -616,8 +561,7 @@ public class ProviderBillingService( { return await stripeAdapter.CustomerCreateAsync(options); } - catch (StripeException stripeException) when (stripeException.StripeError?.Code == - StripeConstants.ErrorCodes.TaxIdInvalid) + catch (StripeException stripeException) when (stripeException.StripeError?.Code == ErrorCodes.TaxIdInvalid) { await Revert(); throw new BadRequestException( @@ -632,9 +576,9 @@ public class ProviderBillingService( async Task Revert() { // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault - switch (tokenizedPaymentSource.Type) + switch (paymentMethod.Type) { - case PaymentMethodType.BankAccount: + case TokenizablePaymentMethodType.BankAccount: { var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id); await stripeAdapter.SetupIntentCancel(setupIntentId, @@ -642,7 +586,7 @@ public class ProviderBillingService( await setupIntentCache.RemoveSetupIntentForSubscriber(provider.Id); break; } - case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): + case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): { await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); break; @@ -661,9 +605,10 @@ public class ProviderBillingService( var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); - if (providerPlans == null || providerPlans.Count == 0) + if (providerPlans.Count == 0) { - logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured plans", provider.Id); + logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured plans", + provider.Id); throw new BillingException(); } @@ -676,7 +621,9 @@ public class ProviderBillingService( if (!providerPlan.IsConfigured()) { - logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured {ProviderName} plan", provider.Id, plan.Name); + logger.LogError( + "Cannot start subscription for provider ({ProviderID}) that has no configured {ProviderName} plan", + provider.Id, plan.Name); throw new BillingException(); } @@ -692,16 +639,14 @@ public class ProviderBillingService( var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id); var setupIntent = !string.IsNullOrEmpty(setupIntentId) - ? await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions - { - Expand = ["payment_method"] - }) + ? await stripeAdapter.SetupIntentGet(setupIntentId, + new SetupIntentGetOptions { Expand = ["payment_method"] }) : null; var usePaymentMethod = !string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) || - (customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) == true) || - (setupIntent?.IsUnverifiedBankAccount() == true); + customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) == true || + setupIntent?.IsUnverifiedBankAccount() == true; int? trialPeriodDays = provider.Type switch { @@ -712,30 +657,28 @@ public class ProviderBillingService( var subscriptionCreateOptions = new SubscriptionCreateOptions { - CollectionMethod = usePaymentMethod ? - StripeConstants.CollectionMethod.ChargeAutomatically : StripeConstants.CollectionMethod.SendInvoice, + CollectionMethod = + usePaymentMethod + ? CollectionMethod.ChargeAutomatically + : CollectionMethod.SendInvoice, Customer = customer.Id, DaysUntilDue = usePaymentMethod ? null : 30, Items = subscriptionItemOptionsList, - Metadata = new Dictionary - { - { "providerId", provider.Id.ToString() } - }, + Metadata = new Dictionary { { "providerId", provider.Id.ToString() } }, OffSession = true, - ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, - TrialPeriodDays = trialPeriodDays + ProrationBehavior = ProrationBehavior.CreateProrations, + TrialPeriodDays = trialPeriodDays, + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } }; - subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; - try { var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); if (subscription is { - Status: StripeConstants.SubscriptionStatus.Active or StripeConstants.SubscriptionStatus.Trialing + Status: SubscriptionStatus.Active or SubscriptionStatus.Trialing }) { return subscription; @@ -749,9 +692,11 @@ public class ProviderBillingService( throw new BillingException(); } - catch (StripeException stripeException) when (stripeException.StripeError?.Code == StripeConstants.ErrorCodes.CustomerTaxLocationInvalid) + catch (StripeException stripeException) when (stripeException.StripeError?.Code == + ErrorCodes.CustomerTaxLocationInvalid) { - throw new BadRequestException("Your location wasn't recognized. Please ensure your country and postal code are valid."); + throw new BadRequestException( + "Your location wasn't recognized. Please ensure your country and postal code are valid."); } } @@ -765,7 +710,7 @@ public class ProviderBillingService( subscriberService.UpdateTaxInformation(provider, taxInformation)); await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, - new SubscriptionUpdateOptions { CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically }); + new SubscriptionUpdateOptions { CollectionMethod = CollectionMethod.ChargeAutomatically }); } public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command) @@ -865,13 +810,9 @@ public class ProviderBillingService( await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions { - Items = [ - new SubscriptionItemOptions - { - Id = item.Id, - Price = priceId, - Quantity = newlySubscribedSeats - } + Items = + [ + new SubscriptionItemOptions { Id = item.Id, Price = priceId, Quantity = newlySubscribedSeats } ] }); @@ -894,7 +835,8 @@ public class ProviderBillingService( var plan = await pricingClient.GetPlanOrThrow(planType); return providerOrganizations - .Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed) + .Where(providerOrganization => providerOrganization.Plan == plan.Name && + providerOrganization.Status == OrganizationStatusType.Managed) .Sum(providerOrganization => providerOrganization.Seats ?? 0); } diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index f2ba2fab8f..e61cf5f97e 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -9,7 +9,7 @@ using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; @@ -41,7 +41,7 @@ public class ProviderServiceTests public async Task CompleteSetupAsync_UserIdIsInvalid_Throws(SutProvider sutProvider) { var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.CompleteSetupAsync(default, default, default, default, null)); + () => sutProvider.Sut.CompleteSetupAsync(default, default, default, default, null, null)); Assert.Contains("Invalid owner.", exception.Message); } @@ -53,83 +53,12 @@ public class ProviderServiceTests userService.GetUserByIdAsync(user.Id).Returns(user); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null)); + () => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null, null)); Assert.Contains("Invalid token.", exception.Message); } [Theory, BitAutoData] - public async Task CompleteSetupAsync_InvalidTaxInfo_ThrowsBadRequestException( - User user, - Provider provider, - string key, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource, - [ProviderUser] ProviderUser providerUser, - SutProvider sutProvider) - { - providerUser.ProviderId = provider.Id; - providerUser.UserId = user.Id; - var userService = sutProvider.GetDependency(); - userService.GetUserByIdAsync(user.Id).Returns(user); - - var providerUserRepository = sutProvider.GetDependency(); - providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser); - - var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName"); - var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); - sutProvider.GetDependency().CreateProtector("ProviderServiceDataProtector") - .Returns(protector); - - sutProvider.Create(); - - var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); - - taxInfo.BillingAddressCountry = null; - - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource)); - - Assert.Equal("Both address and postal code are required to set up your provider.", exception.Message); - } - - [Theory, BitAutoData] - public async Task CompleteSetupAsync_InvalidTokenizedPaymentSource_ThrowsBadRequestException( - User user, - Provider provider, - string key, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource, - [ProviderUser] ProviderUser providerUser, - SutProvider sutProvider) - { - providerUser.ProviderId = provider.Id; - providerUser.UserId = user.Id; - var userService = sutProvider.GetDependency(); - userService.GetUserByIdAsync(user.Id).Returns(user); - - var providerUserRepository = sutProvider.GetDependency(); - providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser); - - var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName"); - var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); - sutProvider.GetDependency().CreateProtector("ProviderServiceDataProtector") - .Returns(protector); - - sutProvider.Create(); - - var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); - - - tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay }; - - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource)); - - Assert.Equal("A payment method is required to set up your provider.", exception.Message); - } - - [Theory, BitAutoData] - public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource, + public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TokenizedPaymentMethod tokenizedPaymentMethod, BillingAddress billingAddress, [ProviderUser] ProviderUser providerUser, SutProvider sutProvider) { @@ -149,7 +78,7 @@ public class ProviderServiceTests var providerBillingService = sutProvider.GetDependency(); var customer = new Customer { Id = "customer_id" }; - providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource).Returns(customer); + providerBillingService.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress).Returns(customer); var subscription = new Subscription { Id = "subscription_id" }; providerBillingService.SetupSubscription(provider).Returns(subscription); @@ -158,7 +87,7 @@ public class ProviderServiceTests var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); - await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource); + await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, tokenizedPaymentMethod, billingAddress); await sutProvider.GetDependency().Received().UpsertAsync(Arg.Is( p => diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs index 54c0b82aa9..18c71364e6 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Net; using Bit.Commercial.Core.Billing.Providers.Models; using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core.AdminConsole.Entities; @@ -10,18 +9,16 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Tax.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -895,118 +892,53 @@ public class ProviderBillingServiceTests #region SetupCustomer [Theory, BitAutoData] - public async Task SetupCustomer_MissingCountry_ContactSupport( + public async Task SetupCustomer_NullPaymentMethod_ThrowsNullReferenceException( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource) + BillingAddress billingAddress) { - taxInfo.BillingAddressCountry = null; - - await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .CustomerGetAsync(Arg.Any(), Arg.Any()); - } - - [Theory, BitAutoData] - public async Task SetupCustomer_MissingPostalCode_ContactSupport( - SutProvider sutProvider, - Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource) - { - taxInfo.BillingAddressCountry = null; - - await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .CustomerGetAsync(Arg.Any(), Arg.Any()); - } - - - [Theory, BitAutoData] - public async Task SetupCustomer_NullPaymentSource_ThrowsArgumentNullException( - SutProvider sutProvider, - Provider provider, - TaxInfo taxInfo) - { - await Assert.ThrowsAsync(() => - sutProvider.Sut.SetupCustomer(provider, taxInfo, null)); - } - - [Theory, BitAutoData] - public async Task SetupCustomer_InvalidRequiredPaymentMethod_ThrowsBillingException( - SutProvider sutProvider, - Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource) - { - provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; - - - tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay }; - - await ThrowsBillingExceptionAsync(() => - sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); + await Assert.ThrowsAsync(() => + sutProvider.Sut.SetupCustomer(provider, null, billingAddress)); } [Theory, BitAutoData] public async Task SetupCustomer_WithBankAccount_Error_Reverts( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "12345678Z"); var stripeAdapter = sutProvider.GetDependency(); - - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token"); - + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" }; stripeAdapter.SetupIntentList(Arg.Is(options => - options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([ + options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([ new SetupIntent { Id = "setup_intent_id" } ]); stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value)) .Throws(); sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns("setup_intent_id"); await Assert.ThrowsAsync(() => - sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); + sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress)); await sutProvider.GetDependency().Received(1).Set(provider.Id, "setup_intent_id"); @@ -1020,45 +952,37 @@ public class ProviderBillingServiceTests public async Task SetupCustomer_WithPayPal_Error_Reverts( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "12345678Z"); var stripeAdapter = sutProvider.GetDependency(); + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.PayPal, Token = "token" }; - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token"); - - - sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token) + sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token) .Returns("braintree_customer_id"); stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && o.Metadata["btCustomerId"] == "braintree_customer_id" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value)) .Throws(); await Assert.ThrowsAsync(() => - sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); + sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress)); await sutProvider.GetDependency().Customer.Received(1).DeleteAsync("braintree_customer_id"); } @@ -1067,17 +991,11 @@ public class ProviderBillingServiceTests public async Task SetupCustomer_WithBankAccount_Success( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "12345678Z"); var stripeAdapter = sutProvider.GetDependency(); @@ -1087,31 +1005,30 @@ public class ProviderBillingServiceTests Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }; - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token"); - + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" }; stripeAdapter.SetupIntentList(Arg.Is(options => - options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([ + options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([ new SetupIntent { Id = "setup_intent_id" } ]); stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value)) .Returns(expected); - var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress); Assert.Equivalent(expected, actual); @@ -1122,17 +1039,11 @@ public class ProviderBillingServiceTests public async Task SetupCustomer_WithPayPal_Success( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "12345678Z"); var stripeAdapter = sutProvider.GetDependency(); @@ -1142,30 +1053,29 @@ public class ProviderBillingServiceTests Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }; - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token"); + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.PayPal, Token = "token" }; - - sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token) + sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token) .Returns("braintree_customer_id"); stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && o.Metadata["btCustomerId"] == "braintree_customer_id" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value)) .Returns(expected); - var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress); Assert.Equivalent(expected, actual); } @@ -1174,17 +1084,11 @@ public class ProviderBillingServiceTests public async Task SetupCustomer_WithCard_Success( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "12345678Z"); var stripeAdapter = sutProvider.GetDependency(); @@ -1194,28 +1098,26 @@ public class ProviderBillingServiceTests Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }; - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token"); - + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" }; stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.PaymentMethod == tokenizedPaymentSource.Token && - o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentMethod.Token && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value)) .Returns(expected); - var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress); Assert.Equivalent(expected, actual); } @@ -1224,17 +1126,11 @@ public class ProviderBillingServiceTests public async Task SetupCustomer_WithCard_ReverseCharge_Success( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "FR"; // Non-US country to trigger reverse charge + billingAddress.TaxId = new TaxID("fr_siren", "123456789"); var stripeAdapter = sutProvider.GetDependency(); @@ -1244,55 +1140,51 @@ public class ProviderBillingServiceTests Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }; - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token"); - + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" }; stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.PaymentMethod == tokenizedPaymentSource.Token && - o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentMethod.Token && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber && + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value && o.TaxExempt == StripeConstants.TaxExempt.Reverse)) .Returns(expected); - var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress); Assert.Equivalent(expected, actual); } [Theory, BitAutoData] - public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid( + public async Task SetupCustomer_WithInvalidTaxId_ThrowsBadRequestException( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource) + BillingAddress billingAddress) { provider.Name = "MSP"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "invalid_tax_id"); - taxInfo.BillingAddressCountry = "AD"; + var stripeAdapter = sutProvider.GetDependency(); + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" }; - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns((string)null); + stripeAdapter.CustomerCreateAsync(Arg.Any()) + .Throws(new StripeException("Invalid tax ID") { StripeError = new StripeError { Code = "tax_id_invalid" } }); var actual = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); + await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress)); - Assert.IsType(actual); - Assert.Equal("billingTaxIdTypeInferenceError", actual.Message); + Assert.Equal("Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.", actual.Message); } #endregion diff --git a/src/Api/AdminConsole/Controllers/ProvidersController.cs b/src/Api/AdminConsole/Controllers/ProvidersController.cs index a1815fd3bf..aa87bf9c74 100644 --- a/src/Api/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Api/AdminConsole/Controllers/ProvidersController.cs @@ -7,7 +7,6 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.AspNetCore.Authorization; @@ -93,22 +92,12 @@ public class ProvidersController : Controller var userId = _userService.GetProperUserId(User).Value; - var taxInfo = new TaxInfo - { - BillingAddressCountry = model.TaxInfo.Country, - BillingAddressPostalCode = model.TaxInfo.PostalCode, - TaxIdNumber = model.TaxInfo.TaxId, - BillingAddressLine1 = model.TaxInfo.Line1, - BillingAddressLine2 = model.TaxInfo.Line2, - BillingAddressCity = model.TaxInfo.City, - BillingAddressState = model.TaxInfo.State - }; - - var tokenizedPaymentSource = model.PaymentSource?.ToDomain(); + var paymentMethod = model.PaymentMethod.ToDomain(); + var billingAddress = model.BillingAddress.ToDomain(); var response = await _providerService.CompleteSetupAsync(model.ToProvider(provider), userId, model.Token, model.Key, - taxInfo, tokenizedPaymentSource); + paymentMethod, billingAddress); return new ProviderResponseModel(response); } diff --git a/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs b/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs index 1f50c384a3..41cebe8b9b 100644 --- a/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs @@ -3,8 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; -using Bit.Api.Billing.Models.Requests; -using Bit.Api.Models.Request; +using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Utilities; @@ -28,8 +27,9 @@ public class ProviderSetupRequestModel [Required] public string Key { get; set; } [Required] - public ExpandedTaxInfoUpdateRequestModel TaxInfo { get; set; } - public TokenizedPaymentSourceRequestBody PaymentSource { get; set; } + public MinimalTokenizedPaymentMethodRequest PaymentMethod { get; set; } + [Required] + public BillingAddressRequest BillingAddress { get; set; } public virtual Provider ToProvider(Provider provider) { diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 21b17bff67..1d6bf51661 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -1,16 +1,8 @@ -#nullable enable -using System.Diagnostics; -using Bit.Api.AdminConsole.Models.Request.Organizations; -using Bit.Api.Billing.Models.Requests; +using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models; -using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Services; -using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; using Bit.Core.Repositories; using Bit.Core.Services; @@ -28,10 +20,8 @@ public class OrganizationBillingController( IOrganizationBillingService organizationBillingService, IOrganizationRepository organizationRepository, IPaymentService paymentService, - IPricingClient pricingClient, ISubscriberService subscriberService, - IPaymentHistoryService paymentHistoryService, - IUserService userService) : BaseBillingController + IPaymentHistoryService paymentHistoryService) : BaseBillingController { [HttpGet("metadata")] public async Task GetMetadataAsync([FromRoute] Guid organizationId) @@ -264,71 +254,6 @@ public class OrganizationBillingController( return TypedResults.Ok(); } - [HttpPost("restart-subscription")] - public async Task RestartSubscriptionAsync([FromRoute] Guid organizationId, - [FromBody] OrganizationCreateRequestModel model) - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - if (!await currentContext.EditPaymentMethods(organizationId)) - { - return Error.Unauthorized(); - } - - var organization = await organizationRepository.GetByIdAsync(organizationId); - if (organization == null) - { - return Error.NotFound(); - } - var existingPlan = organization.PlanType; - var organizationSignup = model.ToOrganizationSignup(user); - var sale = OrganizationSale.From(organization, organizationSignup); - var plan = await pricingClient.GetPlanOrThrow(model.PlanType); - sale.Organization.PlanType = plan.Type; - sale.Organization.Plan = plan.Name; - sale.SubscriptionSetup.SkipTrial = true; - if (existingPlan == PlanType.Free && organization.GatewaySubscriptionId is not null) - { - sale.Organization.UseTotp = plan.HasTotp; - sale.Organization.UseGroups = plan.HasGroups; - sale.Organization.UseDirectory = plan.HasDirectory; - sale.Organization.SelfHost = plan.HasSelfHost; - sale.Organization.UsersGetPremium = plan.UsersGetPremium; - sale.Organization.UseEvents = plan.HasEvents; - sale.Organization.Use2fa = plan.Has2fa; - sale.Organization.UseApi = plan.HasApi; - sale.Organization.UsePolicies = plan.HasPolicies; - sale.Organization.UseSso = plan.HasSso; - sale.Organization.UseResetPassword = plan.HasResetPassword; - sale.Organization.UseKeyConnector = plan.HasKeyConnector ? organization.UseKeyConnector : false; - sale.Organization.UseScim = plan.HasScim; - sale.Organization.UseCustomPermissions = plan.HasCustomPermissions; - sale.Organization.UseOrganizationDomains = plan.HasOrganizationDomains; - sale.Organization.MaxCollections = plan.PasswordManager.MaxCollections; - } - - if (organizationSignup.PaymentMethodType == null || string.IsNullOrEmpty(organizationSignup.PaymentToken)) - { - return Error.BadRequest("A payment method is required to restart the subscription."); - } - var org = await organizationRepository.GetByIdAsync(organizationId); - Debug.Assert(org is not null, "This organization has already been found via this same ID, this should be fine."); - var paymentSource = new TokenizedPaymentSource(organizationSignup.PaymentMethodType.Value, organizationSignup.PaymentToken); - var taxInformation = TaxInformation.From(organizationSignup.TaxInfo); - await organizationBillingService.Finalize(sale); - var updatedOrg = await organizationRepository.GetByIdAsync(organizationId); - if (updatedOrg != null) - { - await organizationBillingService.UpdatePaymentMethod(updatedOrg, paymentSource, taxInformation); - } - - return TypedResults.Ok(); - } - [HttpPost("setup-business-unit")] [SelfHosted(NotSelfHostedOnly = true)] public async Task SetupBusinessUnitAsync( diff --git a/src/Api/Billing/Controllers/TaxController.cs b/src/Api/Billing/Controllers/TaxController.cs index d2c1c36726..4ead414589 100644 --- a/src/Api/Billing/Controllers/TaxController.cs +++ b/src/Api/Billing/Controllers/TaxController.cs @@ -1,33 +1,73 @@ -using Bit.Api.Billing.Models.Requests; -using Bit.Core.Billing.Tax.Commands; +using Bit.Api.Billing.Attributes; +using Bit.Api.Billing.Models.Requests.Tax; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Premium.Commands; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Bit.Api.Billing.Controllers; [Authorize("Application")] -[Route("tax")] +[Route("billing/tax")] public class TaxController( - IPreviewTaxAmountCommand previewTaxAmountCommand) : BaseBillingController + IPreviewOrganizationTaxCommand previewOrganizationTaxCommand, + IPreviewPremiumTaxCommand previewPremiumTaxCommand) : BaseBillingController { - [HttpPost("preview-amount/organization-trial")] - public async Task PreviewTaxAmountForOrganizationTrialAsync( - [FromBody] PreviewTaxAmountForOrganizationTrialRequestBody requestBody) + [HttpPost("organizations/subscriptions/purchase")] + public async Task PreviewOrganizationSubscriptionPurchaseTaxAsync( + [FromBody] PreviewOrganizationSubscriptionPurchaseTaxRequest request) { - var parameters = new OrganizationTrialParameters + var (purchase, billingAddress) = request.ToDomain(); + var result = await previewOrganizationTaxCommand.Run(purchase, billingAddress); + return Handle(result.Map(pair => new { - PlanType = requestBody.PlanType, - ProductType = requestBody.ProductType, - TaxInformation = new OrganizationTrialParameters.TaxInformationDTO - { - Country = requestBody.TaxInformation.Country, - PostalCode = requestBody.TaxInformation.PostalCode, - TaxId = requestBody.TaxInformation.TaxId - } - }; + pair.Tax, + pair.Total + })); + } - var result = await previewTaxAmountCommand.Run(parameters); + [HttpPost("organizations/{organizationId:guid}/subscription/plan-change")] + [InjectOrganization] + public async Task PreviewOrganizationSubscriptionPlanChangeTaxAsync( + [BindNever] Organization organization, + [FromBody] PreviewOrganizationSubscriptionPlanChangeTaxRequest request) + { + var (planChange, billingAddress) = request.ToDomain(); + var result = await previewOrganizationTaxCommand.Run(organization, planChange, billingAddress); + return Handle(result.Map(pair => new + { + pair.Tax, + pair.Total + })); + } - return Handle(result); + [HttpPut("organizations/{organizationId:guid}/subscription/update")] + [InjectOrganization] + public async Task PreviewOrganizationSubscriptionUpdateTaxAsync( + [BindNever] Organization organization, + [FromBody] PreviewOrganizationSubscriptionUpdateTaxRequest request) + { + var update = request.ToDomain(); + var result = await previewOrganizationTaxCommand.Run(organization, update); + return Handle(result.Map(pair => new + { + pair.Tax, + pair.Total + })); + } + + [HttpPost("premium/subscriptions/purchase")] + public async Task PreviewPremiumSubscriptionPurchaseTaxAsync( + [FromBody] PreviewPremiumSubscriptionPurchaseTaxRequest request) + { + var (purchase, billingAddress) = request.ToDomain(); + var result = await previewPremiumTaxCommand.Run(purchase, billingAddress); + return Handle(result.Map(pair => new + { + pair.Tax, + pair.Total + })); } } diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index a996290507..97f2003d29 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -1,5 +1,4 @@ -#nullable enable -using Bit.Api.Billing.Attributes; +using Bit.Api.Billing.Attributes; using Bit.Api.Billing.Models.Requests.Payment; using Bit.Api.Billing.Models.Requests.Premium; using Bit.Core; diff --git a/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs index ee98031dbc..2f825f2cb9 100644 --- a/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs @@ -2,11 +2,14 @@ using Bit.Api.AdminConsole.Authorization.Requirements; using Bit.Api.Billing.Attributes; using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Api.Billing.Models.Requests.Subscriptions; using Bit.Api.Billing.Models.Requirements; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Commands; using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Queries; +using Bit.Core.Billing.Subscriptions.Commands; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -24,6 +27,7 @@ public class OrganizationBillingVNextController( IGetCreditQuery getCreditQuery, IGetOrganizationWarningsQuery getOrganizationWarningsQuery, IGetPaymentMethodQuery getPaymentMethodQuery, + IRestartSubscriptionCommand restartSubscriptionCommand, IUpdateBillingAddressCommand updateBillingAddressCommand, IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController { @@ -95,6 +99,20 @@ public class OrganizationBillingVNextController( return Handle(result); } + [Authorize] + [HttpPost("subscription/restart")] + [InjectOrganization] + public async Task RestartSubscriptionAsync( + [BindNever] Organization organization, + [FromBody] RestartSubscriptionRequest request) + { + var (paymentMethod, billingAddress) = request.ToDomain(); + var result = await updatePaymentMethodCommand.Run(organization, paymentMethod, null) + .AndThenAsync(_ => updateBillingAddressCommand.Run(organization, billingAddress)) + .AndThenAsync(_ => restartSubscriptionCommand.Run(organization)); + return Handle(result); + } + [Authorize] [HttpGet("warnings")] [InjectOrganization] diff --git a/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPlanChangeRequest.cs b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPlanChangeRequest.cs new file mode 100644 index 0000000000..a3856bf173 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPlanChangeRequest.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Models; + +namespace Bit.Api.Billing.Models.Requests.Organizations; + +public record OrganizationSubscriptionPlanChangeRequest : IValidatableObject +{ + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public ProductTierType Tier { get; set; } + + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public PlanCadenceType Cadence { get; set; } + + public OrganizationSubscriptionPlanChange ToDomain() => new() + { + Tier = Tier, + Cadence = Cadence + }; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Tier == ProductTierType.Families && Cadence == PlanCadenceType.Monthly) + { + yield return new ValidationResult("Monthly billing cadence is not available for the Families plan."); + } + } +} diff --git a/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs new file mode 100644 index 0000000000..c678b1966c --- /dev/null +++ b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs @@ -0,0 +1,84 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Models; + +namespace Bit.Api.Billing.Models.Requests.Organizations; + +public record OrganizationSubscriptionPurchaseRequest : IValidatableObject +{ + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public ProductTierType Tier { get; set; } + + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public PlanCadenceType Cadence { get; set; } + + [Required] + public required PasswordManagerPurchaseSelections PasswordManager { get; set; } + + public SecretsManagerPurchaseSelections? SecretsManager { get; set; } + + public OrganizationSubscriptionPurchase ToDomain() => new() + { + Tier = Tier, + Cadence = Cadence, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = PasswordManager.Seats, + AdditionalStorage = PasswordManager.AdditionalStorage, + Sponsored = PasswordManager.Sponsored + }, + SecretsManager = SecretsManager != null ? new OrganizationSubscriptionPurchase.SecretsManagerSelections + { + Seats = SecretsManager.Seats, + AdditionalServiceAccounts = SecretsManager.AdditionalServiceAccounts, + Standalone = SecretsManager.Standalone + } : null + }; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Tier != ProductTierType.Families) + { + yield break; + } + + if (Cadence == PlanCadenceType.Monthly) + { + yield return new ValidationResult("Monthly cadence is not available on the Families plan."); + } + + if (SecretsManager != null) + { + yield return new ValidationResult("Secrets Manager is not available on the Families plan."); + } + } + + public record PasswordManagerPurchaseSelections + { + [Required] + [Range(1, 100000, ErrorMessage = "Password Manager seats must be between 1 and 100,000")] + public int Seats { get; set; } + + [Required] + [Range(0, 99, ErrorMessage = "Additional storage must be between 0 and 99 GB")] + public int AdditionalStorage { get; set; } + + public bool Sponsored { get; set; } = false; + } + + public record SecretsManagerPurchaseSelections + { + [Required] + [Range(1, 100000, ErrorMessage = "Secrets Manager seats must be between 1 and 100,000")] + public int Seats { get; set; } + + [Required] + [Range(0, 100000, ErrorMessage = "Additional service accounts must be between 0 and 100,000")] + public int AdditionalServiceAccounts { get; set; } + + public bool Standalone { get; set; } = false; + } +} diff --git a/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionUpdateRequest.cs b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionUpdateRequest.cs new file mode 100644 index 0000000000..ad5c3bd609 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionUpdateRequest.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Billing.Organizations.Models; + +namespace Bit.Api.Billing.Models.Requests.Organizations; + +public record OrganizationSubscriptionUpdateRequest +{ + public PasswordManagerUpdateSelections? PasswordManager { get; set; } + public SecretsManagerUpdateSelections? SecretsManager { get; set; } + + public OrganizationSubscriptionUpdate ToDomain() => new() + { + PasswordManager = + PasswordManager != null + ? new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = PasswordManager.Seats, + AdditionalStorage = PasswordManager.AdditionalStorage + } + : null, + SecretsManager = + SecretsManager != null + ? new OrganizationSubscriptionUpdate.SecretsManagerSelections + { + Seats = SecretsManager.Seats, + AdditionalServiceAccounts = SecretsManager.AdditionalServiceAccounts + } + : null + }; + + public record PasswordManagerUpdateSelections + { + [Range(1, 100000, ErrorMessage = "Password Manager seats must be between 1 and 100,000")] + public int? Seats { get; set; } + + [Range(0, 99, ErrorMessage = "Additional storage must be between 0 and 99 GB")] + public int? AdditionalStorage { get; set; } + } + + public record SecretsManagerUpdateSelections + { + [Range(0, 100000, ErrorMessage = "Secrets Manager seats must be between 0 and 100,000")] + public int? Seats { get; set; } + + [Range(0, 100000, ErrorMessage = "Additional service accounts must be between 0 and 100,000")] + public int? AdditionalServiceAccounts { get; set; } + } +} diff --git a/src/Api/Billing/Models/Requests/Payment/BillingAddressRequest.cs b/src/Api/Billing/Models/Requests/Payment/BillingAddressRequest.cs index 5c3c47f585..0426a51f10 100644 --- a/src/Api/Billing/Models/Requests/Payment/BillingAddressRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/BillingAddressRequest.cs @@ -1,5 +1,4 @@ -#nullable enable -using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Payment.Models; namespace Bit.Api.Billing.Models.Requests.Payment; diff --git a/src/Api/Billing/Models/Requests/Payment/BitPayCreditRequest.cs b/src/Api/Billing/Models/Requests/Payment/BitPayCreditRequest.cs index bb6e7498d7..ec1405c566 100644 --- a/src/Api/Billing/Models/Requests/Payment/BitPayCreditRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/BitPayCreditRequest.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Billing.Models.Requests.Payment; diff --git a/src/Api/Billing/Models/Requests/Payment/CheckoutBillingAddressRequest.cs b/src/Api/Billing/Models/Requests/Payment/CheckoutBillingAddressRequest.cs index 54116e897d..ccf2b30b50 100644 --- a/src/Api/Billing/Models/Requests/Payment/CheckoutBillingAddressRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/CheckoutBillingAddressRequest.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Payment.Models; namespace Bit.Api.Billing.Models.Requests.Payment; diff --git a/src/Api/Billing/Models/Requests/Payment/MinimalBillingAddressRequest.cs b/src/Api/Billing/Models/Requests/Payment/MinimalBillingAddressRequest.cs index b4d28017d5..29c10e6631 100644 --- a/src/Api/Billing/Models/Requests/Payment/MinimalBillingAddressRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/MinimalBillingAddressRequest.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Payment.Models; namespace Bit.Api.Billing.Models.Requests.Payment; diff --git a/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs b/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs index 3b50d2bf63..b0e415c262 100644 --- a/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Bit.Api.Billing.Attributes; using Bit.Core.Billing.Payment.Models; @@ -14,12 +13,9 @@ public class MinimalTokenizedPaymentMethodRequest [Required] public required string Token { get; set; } - public TokenizedPaymentMethod ToDomain() + public TokenizedPaymentMethod ToDomain() => new() { - return new TokenizedPaymentMethod - { - Type = TokenizablePaymentMethodTypeExtensions.From(Type), - Token = Token - }; - } + Type = TokenizablePaymentMethodTypeExtensions.From(Type), + Token = Token + }; } diff --git a/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs b/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs index f540957a1a..2a54313421 100644 --- a/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs @@ -1,31 +1,15 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; -using Bit.Api.Billing.Attributes; -using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Payment.Models; namespace Bit.Api.Billing.Models.Requests.Payment; -public class TokenizedPaymentMethodRequest +public class TokenizedPaymentMethodRequest : MinimalTokenizedPaymentMethodRequest { - [Required] - [PaymentMethodTypeValidation] - public required string Type { get; set; } - - [Required] - public required string Token { get; set; } - public MinimalBillingAddressRequest? BillingAddress { get; set; } - public (TokenizedPaymentMethod, BillingAddress?) ToDomain() + public new (TokenizedPaymentMethod, BillingAddress?) ToDomain() { - var paymentMethod = new TokenizedPaymentMethod - { - Type = TokenizablePaymentMethodTypeExtensions.From(Type), - Token = Token - }; - + var paymentMethod = base.ToDomain(); var billingAddress = BillingAddress?.ToDomain(); - return (paymentMethod, billingAddress); } } diff --git a/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs index b958057f5b..03f20ec9c1 100644 --- a/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs +++ b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.Billing.Payment.Models; diff --git a/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs b/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs deleted file mode 100644 index a3fda0fd6c..0000000000 --- a/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs +++ /dev/null @@ -1,27 +0,0 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; -using Bit.Core.Billing.Enums; - -namespace Bit.Api.Billing.Models.Requests; - -public class PreviewTaxAmountForOrganizationTrialRequestBody -{ - [Required] - public PlanType PlanType { get; set; } - - [Required] - public ProductType ProductType { get; set; } - - [Required] public TaxInformationDTO TaxInformation { get; set; } = null!; - - public class TaxInformationDTO - { - [Required] - public string Country { get; set; } = null!; - - [Required] - public string PostalCode { get; set; } = null!; - - public string? TaxId { get; set; } - } -} diff --git a/src/Api/Billing/Models/Requests/Subscriptions/RestartSubscriptionRequest.cs b/src/Api/Billing/Models/Requests/Subscriptions/RestartSubscriptionRequest.cs new file mode 100644 index 0000000000..ac66270427 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Subscriptions/RestartSubscriptionRequest.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Subscriptions; + +public class RestartSubscriptionRequest +{ + [Required] + public required MinimalTokenizedPaymentMethodRequest PaymentMethod { get; set; } + [Required] + public required CheckoutBillingAddressRequest BillingAddress { get; set; } + + public (TokenizedPaymentMethod, BillingAddress) ToDomain() + => (PaymentMethod.ToDomain(), BillingAddress.ToDomain()); +} diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs new file mode 100644 index 0000000000..9233a53c85 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Organizations; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Tax; + +public record PreviewOrganizationSubscriptionPlanChangeTaxRequest +{ + [Required] + public required OrganizationSubscriptionPlanChangeRequest Plan { get; set; } + + [Required] + public required CheckoutBillingAddressRequest BillingAddress { get; set; } + + public (OrganizationSubscriptionPlanChange, BillingAddress) ToDomain() => + (Plan.ToDomain(), BillingAddress.ToDomain()); +} diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs new file mode 100644 index 0000000000..dcc5911f3d --- /dev/null +++ b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Organizations; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Tax; + +public record PreviewOrganizationSubscriptionPurchaseTaxRequest +{ + [Required] + public required OrganizationSubscriptionPurchaseRequest Purchase { get; set; } + + [Required] + public required CheckoutBillingAddressRequest BillingAddress { get; set; } + + public (OrganizationSubscriptionPurchase, BillingAddress) ToDomain() => + (Purchase.ToDomain(), BillingAddress.ToDomain()); +} diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs new file mode 100644 index 0000000000..ae96214ae3 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs @@ -0,0 +1,11 @@ +using Bit.Api.Billing.Models.Requests.Organizations; +using Bit.Core.Billing.Organizations.Models; + +namespace Bit.Api.Billing.Models.Requests.Tax; + +public class PreviewOrganizationSubscriptionUpdateTaxRequest +{ + public required OrganizationSubscriptionUpdateRequest Update { get; set; } + + public OrganizationSubscriptionUpdate ToDomain() => Update.ToDomain(); +} diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs b/src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs new file mode 100644 index 0000000000..76b8a5a444 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Tax; + +public record PreviewPremiumSubscriptionPurchaseTaxRequest +{ + [Required] + [Range(0, 99, ErrorMessage = "Additional storage must be between 0 and 99 GB.")] + public short AdditionalStorage { get; set; } + + [Required] + public required MinimalBillingAddressRequest BillingAddress { get; set; } + + public (short, BillingAddress) ToDomain() => (AdditionalStorage, BillingAddress.ToDomain()); +} diff --git a/src/Core/AdminConsole/Services/IProviderService.cs b/src/Core/AdminConsole/Services/IProviderService.cs index 66c49d90c6..2b954346ae 100644 --- a/src/Core/AdminConsole/Services/IProviderService.cs +++ b/src/Core/AdminConsole/Services/IProviderService.cs @@ -3,7 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Entities; using Bit.Core.Models.Business; @@ -11,8 +11,7 @@ namespace Bit.Core.AdminConsole.Services; public interface IProviderService { - Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource = null); + Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress); Task UpdateAsync(Provider provider, bool updateBilling = false); Task> InviteUserAsync(ProviderUserInvite invite); diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs b/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs index 2bf4a54a87..3782b30e3f 100644 --- a/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs +++ b/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs @@ -3,7 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Entities; using Bit.Core.Models.Business; @@ -11,7 +11,7 @@ namespace Bit.Core.AdminConsole.Services.NoopImplementations; public class NoopProviderService : IProviderService { - public Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null) => throw new NotImplementedException(); + public Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress) => throw new NotImplementedException(); public Task UpdateAsync(Provider provider, bool updateBilling = false) => throw new NotImplementedException(); diff --git a/src/Core/Billing/Commands/BillingCommandResult.cs b/src/Core/Billing/Commands/BillingCommandResult.cs index 3238ab4107..db260e7038 100644 --- a/src/Core/Billing/Commands/BillingCommandResult.cs +++ b/src/Core/Billing/Commands/BillingCommandResult.cs @@ -1,5 +1,4 @@ -#nullable enable -using OneOf; +using OneOf; namespace Bit.Core.Billing.Commands; @@ -20,18 +19,38 @@ public record Unhandled(Exception? Exception = null, string Response = "Somethin /// /// /// The successful result type of the operation. -public class BillingCommandResult : OneOfBase +public class BillingCommandResult(OneOf input) + : OneOfBase(input) { - private BillingCommandResult(OneOf input) : base(input) { } - public static implicit operator BillingCommandResult(T output) => new(output); public static implicit operator BillingCommandResult(BadRequest badRequest) => new(badRequest); public static implicit operator BillingCommandResult(Conflict conflict) => new(conflict); public static implicit operator BillingCommandResult(Unhandled unhandled) => new(unhandled); + public BillingCommandResult Map(Func f) + => Match( + value => new BillingCommandResult(f(value)), + badRequest => new BillingCommandResult(badRequest), + conflict => new BillingCommandResult(conflict), + unhandled => new BillingCommandResult(unhandled)); + public Task TapAsync(Func f) => Match( f, _ => Task.CompletedTask, _ => Task.CompletedTask, _ => Task.CompletedTask); } + +public static class BillingCommandResultExtensions +{ + public static async Task> AndThenAsync( + this Task> task, Func>> binder) + { + var result = await task; + return await result.Match( + binder, + badRequest => Task.FromResult(new BillingCommandResult(badRequest)), + conflict => Task.FromResult(new BillingCommandResult(conflict)), + unhandled => Task.FromResult(new BillingCommandResult(unhandled))); + } +} diff --git a/src/Core/Billing/Enums/PlanCadenceType.cs b/src/Core/Billing/Enums/PlanCadenceType.cs new file mode 100644 index 0000000000..9e6fa69832 --- /dev/null +++ b/src/Core/Billing/Enums/PlanCadenceType.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Billing.Enums; + +public enum PlanCadenceType +{ + Annually, + Monthly +} diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index b4e37f0151..7aec422a4b 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -9,7 +9,7 @@ using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; -using Bit.Core.Billing.Tax.Commands; +using Bit.Core.Billing.Subscriptions.Commands; using Bit.Core.Billing.Tax.Services; using Bit.Core.Billing.Tax.Services.Implementations; @@ -28,11 +28,12 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddLicenseServices(); services.AddPricingClient(); - services.AddTransient(); services.AddPaymentOperations(); services.AddOrganizationLicenseCommandsQueries(); services.AddPremiumCommands(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); } private static void AddOrganizationLicenseCommandsQueries(this IServiceCollection services) @@ -46,5 +47,6 @@ public static class ServiceCollectionExtensions { services.AddScoped(); services.AddScoped(); + services.AddTransient(); } } diff --git a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs new file mode 100644 index 0000000000..041e9bdbad --- /dev/null +++ b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs @@ -0,0 +1,383 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Pricing; +using Bit.Core.Enums; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; +using OneOf; +using Stripe; + +namespace Bit.Core.Billing.Organizations.Commands; + +using static Core.Constants; +using static StripeConstants; + +public interface IPreviewOrganizationTaxCommand +{ + Task> Run( + OrganizationSubscriptionPurchase purchase, + BillingAddress billingAddress); + + Task> Run( + Organization organization, + OrganizationSubscriptionPlanChange planChange, + BillingAddress billingAddress); + + Task> Run( + Organization organization, + OrganizationSubscriptionUpdate update); +} + +public class PreviewOrganizationTaxCommand( + ILogger logger, + IPricingClient pricingClient, + IStripeAdapter stripeAdapter) + : BaseBillingCommand(logger), IPreviewOrganizationTaxCommand +{ + public Task> Run( + OrganizationSubscriptionPurchase purchase, + BillingAddress billingAddress) + => HandleAsync<(decimal, decimal)>(async () => + { + var plan = await pricingClient.GetPlanOrThrow(purchase.PlanType); + + var options = GetBaseOptions(billingAddress, purchase.Tier != ProductTierType.Families); + + var items = new List(); + + switch (purchase) + { + case { PasswordManager.Sponsored: true }: + var sponsoredPlan = StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise); + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = sponsoredPlan.StripePlanId, + Quantity = 1 + }); + break; + + case { SecretsManager.Standalone: true }: + items.AddRange([ + new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.PasswordManager.StripeSeatPlanId, + Quantity = purchase.PasswordManager.Seats + }, + new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.SecretsManager.StripeSeatPlanId, + Quantity = purchase.SecretsManager.Seats + } + ]); + options.Coupon = CouponIDs.SecretsManagerStandalone; + break; + + default: + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.HasNonSeatBasedPasswordManagerPlan() + ? plan.PasswordManager.StripePlanId + : plan.PasswordManager.StripeSeatPlanId, + Quantity = purchase.PasswordManager.Seats + }); + + if (purchase.PasswordManager.AdditionalStorage > 0) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.PasswordManager.StripeStoragePlanId, + Quantity = purchase.PasswordManager.AdditionalStorage + }); + } + + if (purchase.SecretsManager is { Seats: > 0 }) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.SecretsManager.StripeSeatPlanId, + Quantity = purchase.SecretsManager.Seats + }); + + if (purchase.SecretsManager.AdditionalServiceAccounts > 0) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.SecretsManager.StripeServiceAccountPlanId, + Quantity = purchase.SecretsManager.AdditionalServiceAccounts + }); + } + } + + break; + } + + options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items }; + + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return GetAmounts(invoice); + }); + + public Task> Run( + Organization organization, + OrganizationSubscriptionPlanChange planChange, + BillingAddress billingAddress) + => HandleAsync<(decimal, decimal)>(async () => + { + if (organization.PlanType.GetProductTier() == ProductTierType.Free) + { + var options = GetBaseOptions(billingAddress, planChange.Tier != ProductTierType.Families); + + var newPlan = await pricingClient.GetPlanOrThrow(planChange.PlanType); + + var items = new List + { + new () + { + Price = newPlan.HasNonSeatBasedPasswordManagerPlan() + ? newPlan.PasswordManager.StripePlanId + : newPlan.PasswordManager.StripeSeatPlanId, + Quantity = 2 + } + }; + + if (organization.UseSecretsManager && planChange.Tier != ProductTierType.Families) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.SecretsManager.StripeSeatPlanId, + Quantity = 2 + }); + } + + options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items }; + + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return GetAmounts(invoice); + } + else + { + if (organization is not + { + GatewayCustomerId: not null, + GatewaySubscriptionId: not null + }) + { + return new BadRequest("Organization does not have a subscription."); + } + + var options = GetBaseOptions(billingAddress, planChange.Tier != ProductTierType.Families); + + var subscription = await stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId, + new SubscriptionGetOptions { Expand = ["customer"] }); + + if (subscription.Customer.Discount != null) + { + options.Coupon = subscription.Customer.Discount.Coupon.Id; + } + + var currentPlan = await pricingClient.GetPlanOrThrow(organization.PlanType); + var newPlan = await pricingClient.GetPlanOrThrow(planChange.PlanType); + + var subscriptionItemsByPriceId = + subscription.Items.ToDictionary(subscriptionItem => subscriptionItem.Price.Id); + + var items = new List(); + + var passwordManagerSeats = subscriptionItemsByPriceId[ + currentPlan.HasNonSeatBasedPasswordManagerPlan() + ? currentPlan.PasswordManager.StripePlanId + : currentPlan.PasswordManager.StripeSeatPlanId]; + + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.HasNonSeatBasedPasswordManagerPlan() + ? newPlan.PasswordManager.StripePlanId + : newPlan.PasswordManager.StripeSeatPlanId, + Quantity = passwordManagerSeats.Quantity + }); + + var hasStorage = + subscriptionItemsByPriceId.TryGetValue(newPlan.PasswordManager.StripeStoragePlanId, + out var storage); + + if (hasStorage && storage != null) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.PasswordManager.StripeStoragePlanId, + Quantity = storage.Quantity + }); + } + + var hasSecretsManagerSeats = subscriptionItemsByPriceId.TryGetValue( + newPlan.SecretsManager.StripeSeatPlanId, + out var secretsManagerSeats); + + if (hasSecretsManagerSeats && secretsManagerSeats != null) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.SecretsManager.StripeSeatPlanId, + Quantity = secretsManagerSeats.Quantity + }); + + var hasServiceAccounts = + subscriptionItemsByPriceId.TryGetValue(newPlan.SecretsManager.StripeServiceAccountPlanId, + out var serviceAccounts); + + if (hasServiceAccounts && serviceAccounts != null) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.SecretsManager.StripeServiceAccountPlanId, + Quantity = serviceAccounts.Quantity + }); + } + } + + options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items }; + + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return GetAmounts(invoice); + } + }); + + public Task> Run( + Organization organization, + OrganizationSubscriptionUpdate update) + => HandleAsync<(decimal, decimal)>(async () => + { + if (organization is not + { + GatewayCustomerId: not null, + GatewaySubscriptionId: not null + }) + { + return new BadRequest("Organization does not have a subscription."); + } + + var subscription = await stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId, + new SubscriptionGetOptions { Expand = ["customer.tax_ids"] }); + + var options = GetBaseOptions(subscription.Customer, + organization.GetProductUsageType() == ProductUsageType.Business); + + if (subscription.Customer.Discount != null) + { + options.Coupon = subscription.Customer.Discount.Coupon.Id; + } + + var currentPlan = await pricingClient.GetPlanOrThrow(organization.PlanType); + + var items = new List(); + + if (update.PasswordManager?.Seats != null) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = currentPlan.HasNonSeatBasedPasswordManagerPlan() + ? currentPlan.PasswordManager.StripePlanId + : currentPlan.PasswordManager.StripeSeatPlanId, + Quantity = update.PasswordManager.Seats + }); + } + + if (update.PasswordManager?.AdditionalStorage is > 0) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = currentPlan.PasswordManager.StripeStoragePlanId, + Quantity = update.PasswordManager.AdditionalStorage + }); + } + + if (update.SecretsManager?.Seats is > 0) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = currentPlan.SecretsManager.StripeSeatPlanId, + Quantity = update.SecretsManager.Seats + }); + + if (update.SecretsManager.AdditionalServiceAccounts is > 0) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = currentPlan.SecretsManager.StripeServiceAccountPlanId, + Quantity = update.SecretsManager.AdditionalServiceAccounts + }); + } + } + + options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items }; + + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return GetAmounts(invoice); + }); + + private static (decimal, decimal) GetAmounts(Invoice invoice) => ( + Convert.ToDecimal(invoice.Tax) / 100, + Convert.ToDecimal(invoice.Total) / 100); + + private static InvoiceCreatePreviewOptions GetBaseOptions( + OneOf addressChoice, + bool businessUse) + { + var country = addressChoice.Match( + customer => customer.Address.Country, + billingAddress => billingAddress.Country + ); + + var postalCode = addressChoice.Match( + customer => customer.Address.PostalCode, + billingAddress => billingAddress.PostalCode); + + var options = new InvoiceCreatePreviewOptions + { + AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }, + Currency = "usd", + CustomerDetails = new InvoiceCustomerDetailsOptions + { + Address = new AddressOptions { Country = country, PostalCode = postalCode }, + TaxExempt = businessUse && country != CountryAbbreviations.UnitedStates + ? TaxExempt.Reverse + : TaxExempt.None + } + }; + + var taxId = addressChoice.Match( + customer => + { + var taxId = customer.TaxIds?.FirstOrDefault(); + return taxId != null ? new TaxID(taxId.Type, taxId.Value) : null; + }, + billingAddress => billingAddress.TaxId); + + if (taxId == null) + { + return options; + } + + options.CustomerDetails.TaxIds = + [ + new InvoiceCustomerDetailsTaxIdOptions { Type = taxId.Code, Value = taxId.Value } + ]; + + if (taxId.Code == TaxIdType.SpanishNIF) + { + options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions + { + Type = TaxIdType.EUVAT, + Value = $"ES{taxId.Value}" + }); + } + + return options; + } +} diff --git a/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPlanChange.cs b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPlanChange.cs new file mode 100644 index 0000000000..7781f91960 --- /dev/null +++ b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPlanChange.cs @@ -0,0 +1,23 @@ +using Bit.Core.Billing.Enums; + +namespace Bit.Core.Billing.Organizations.Models; + +public record OrganizationSubscriptionPlanChange +{ + public ProductTierType Tier { get; init; } + public PlanCadenceType Cadence { get; init; } + + public PlanType PlanType => + // ReSharper disable once SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault + Tier switch + { + ProductTierType.Families => PlanType.FamiliesAnnually, + ProductTierType.Teams => Cadence == PlanCadenceType.Monthly + ? PlanType.TeamsMonthly + : PlanType.TeamsAnnually, + ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly + ? PlanType.EnterpriseMonthly + : PlanType.EnterpriseAnnually, + _ => throw new InvalidOperationException("Cannot change an Organization subscription to a tier that isn't Families, Teams or Enterprise.") + }; +} diff --git a/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs new file mode 100644 index 0000000000..6691d69848 --- /dev/null +++ b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs @@ -0,0 +1,39 @@ +using Bit.Core.Billing.Enums; + +namespace Bit.Core.Billing.Organizations.Models; + +public record OrganizationSubscriptionPurchase +{ + public ProductTierType Tier { get; init; } + public PlanCadenceType Cadence { get; init; } + public required PasswordManagerSelections PasswordManager { get; init; } + public SecretsManagerSelections? SecretsManager { get; init; } + + public PlanType PlanType => + // ReSharper disable once SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault + Tier switch + { + ProductTierType.Families => PlanType.FamiliesAnnually, + ProductTierType.Teams => Cadence == PlanCadenceType.Monthly + ? PlanType.TeamsMonthly + : PlanType.TeamsAnnually, + ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly + ? PlanType.EnterpriseMonthly + : PlanType.EnterpriseAnnually, + _ => throw new InvalidOperationException("Cannot purchase an Organization subscription that isn't Families, Teams or Enterprise.") + }; + + public record PasswordManagerSelections + { + public int Seats { get; init; } + public int AdditionalStorage { get; init; } + public bool Sponsored { get; init; } + } + + public record SecretsManagerSelections + { + public int Seats { get; init; } + public int AdditionalServiceAccounts { get; init; } + public bool Standalone { get; init; } + } +} diff --git a/src/Core/Billing/Organizations/Models/OrganizationSubscriptionUpdate.cs b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionUpdate.cs new file mode 100644 index 0000000000..810f292c81 --- /dev/null +++ b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionUpdate.cs @@ -0,0 +1,19 @@ +namespace Bit.Core.Billing.Organizations.Models; + +public record OrganizationSubscriptionUpdate +{ + public PasswordManagerSelections? PasswordManager { get; init; } + public SecretsManagerSelections? SecretsManager { get; init; } + + public record PasswordManagerSelections + { + public int? Seats { get; init; } + public int? AdditionalStorage { get; init; } + } + + public record SecretsManagerSelections + { + public int? Seats { get; init; } + public int? AdditionalServiceAccounts { get; init; } + } +} diff --git a/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs new file mode 100644 index 0000000000..a0b4fcabc2 --- /dev/null +++ b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs @@ -0,0 +1,65 @@ +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Core.Billing.Premium.Commands; + +using static StripeConstants; + +public interface IPreviewPremiumTaxCommand +{ + Task> Run( + int additionalStorage, + BillingAddress billingAddress); +} + +public class PreviewPremiumTaxCommand( + ILogger logger, + IStripeAdapter stripeAdapter) : BaseBillingCommand(logger), IPreviewPremiumTaxCommand +{ + public Task> Run( + int additionalStorage, + BillingAddress billingAddress) + => HandleAsync<(decimal, decimal)>(async () => + { + var options = new InvoiceCreatePreviewOptions + { + AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }, + CustomerDetails = new InvoiceCustomerDetailsOptions + { + Address = new AddressOptions + { + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode + } + }, + Currency = "usd", + SubscriptionDetails = new InvoiceSubscriptionDetailsOptions + { + Items = + [ + new InvoiceSubscriptionDetailsItemOptions { Price = Prices.PremiumAnnually, Quantity = 1 } + ] + } + }; + + if (additionalStorage > 0) + { + options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = Prices.StoragePlanPersonal, + Quantity = additionalStorage + }); + } + + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return GetAmounts(invoice); + }); + + private static (decimal, decimal) GetAmounts(Invoice invoice) => ( + Convert.ToDecimal(invoice.Tax) / 100, + Convert.ToDecimal(invoice.Total) / 100); +} diff --git a/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs index 07a057d40c..e155b427f1 100644 --- a/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs +++ b/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs @@ -258,7 +258,7 @@ public class ProviderMigrator( // Create dummy payment source for legacy migration - this migrator is deprecated and will be removed var dummyPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "migration_dummy_token"); - var customer = await providerBillingService.SetupCustomer(provider, taxInfo, dummyPaymentSource); + var customer = await providerBillingService.SetupCustomer(provider, null, null); await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { diff --git a/src/Core/Billing/Providers/Services/IProviderBillingService.cs b/src/Core/Billing/Providers/Services/IProviderBillingService.cs index 173249f79f..57d68db038 100644 --- a/src/Core/Billing/Providers/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Providers/Services/IProviderBillingService.cs @@ -5,10 +5,10 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Tax.Models; -using Bit.Core.Models.Business; using Stripe; namespace Bit.Core.Billing.Providers.Services; @@ -79,16 +79,16 @@ public interface IProviderBillingService int seatAdjustment); /// - /// For use during the provider setup process, this method creates a Stripe for the specified utilizing the provided . + /// For use during the provider setup process, this method creates a Stripe for the specified utilizing the provided and . /// /// The to create a Stripe customer for. - /// The to use for calculating the customer's automatic tax. - /// The (ex. Credit Card) to attach to the customer. + /// The (e.g., Credit Card, Bank Account, or PayPal) to attach to the customer. + /// The containing the customer's billing information including address and tax ID details. /// The newly created for the . Task SetupCustomer( Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource); + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress); /// /// For use during the provider setup process, this method starts a Stripe for the given . diff --git a/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs new file mode 100644 index 0000000000..351c75ace0 --- /dev/null +++ b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs @@ -0,0 +1,92 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using OneOf.Types; +using Stripe; + +namespace Bit.Core.Billing.Subscriptions.Commands; + +using static StripeConstants; + +public interface IRestartSubscriptionCommand +{ + Task> Run( + ISubscriber subscriber); +} + +public class RestartSubscriptionCommand( + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService, + IUserRepository userRepository) : IRestartSubscriptionCommand +{ + public async Task> Run( + ISubscriber subscriber) + { + var existingSubscription = await subscriberService.GetSubscription(subscriber); + + if (existingSubscription is not { Status: SubscriptionStatus.Canceled }) + { + return new BadRequest("Cannot restart a subscription that is not canceled."); + } + + var options = new SubscriptionCreateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }, + CollectionMethod = CollectionMethod.ChargeAutomatically, + Customer = existingSubscription.CustomerId, + Items = existingSubscription.Items.Select(subscriptionItem => new SubscriptionItemOptions + { + Price = subscriptionItem.Price.Id, + Quantity = subscriptionItem.Quantity + }).ToList(), + Metadata = existingSubscription.Metadata, + OffSession = true, + TrialPeriodDays = 0 + }; + + var subscription = await stripeAdapter.SubscriptionCreateAsync(options); + await EnableAsync(subscriber, subscription); + return new None(); + } + + private async Task EnableAsync(ISubscriber subscriber, Subscription subscription) + { + switch (subscriber) + { + case Organization organization: + { + organization.GatewaySubscriptionId = subscription.Id; + organization.Enabled = true; + organization.ExpirationDate = subscription.CurrentPeriodEnd; + organization.RevisionDate = DateTime.UtcNow; + await organizationRepository.ReplaceAsync(organization); + break; + } + case Provider provider: + { + provider.GatewaySubscriptionId = subscription.Id; + provider.Enabled = true; + provider.RevisionDate = DateTime.UtcNow; + await providerRepository.ReplaceAsync(provider); + break; + } + case User user: + { + user.GatewaySubscriptionId = subscription.Id; + user.Premium = true; + user.PremiumExpirationDate = subscription.CurrentPeriodEnd; + user.RevisionDate = DateTime.UtcNow; + await userRepository.ReplaceAsync(user); + break; + } + } + } +} diff --git a/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs b/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs deleted file mode 100644 index 94d3724d73..0000000000 --- a/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs +++ /dev/null @@ -1,136 +0,0 @@ -using Bit.Core.Billing.Commands; -using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Extensions; -using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Tax.Services; -using Bit.Core.Services; -using Microsoft.Extensions.Logging; -using Stripe; - -namespace Bit.Core.Billing.Tax.Commands; - -public interface IPreviewTaxAmountCommand -{ - Task> Run(OrganizationTrialParameters parameters); -} - -public class PreviewTaxAmountCommand( - ILogger logger, - IPricingClient pricingClient, - IStripeAdapter stripeAdapter, - ITaxService taxService) : BaseBillingCommand(logger), IPreviewTaxAmountCommand -{ - protected override Conflict DefaultConflict - => new("We had a problem calculating your tax obligation. Please contact support for assistance."); - - public Task> Run(OrganizationTrialParameters parameters) - => HandleAsync(async () => - { - var (planType, productType, taxInformation) = parameters; - - var plan = await pricingClient.GetPlanOrThrow(planType); - - var options = new InvoiceCreatePreviewOptions - { - Currency = "usd", - CustomerDetails = new InvoiceCustomerDetailsOptions - { - Address = new AddressOptions - { - Country = taxInformation.Country, - PostalCode = taxInformation.PostalCode - } - }, - SubscriptionDetails = new InvoiceSubscriptionDetailsOptions - { - Items = - [ - new InvoiceSubscriptionDetailsItemOptions - { - Price = plan.HasNonSeatBasedPasswordManagerPlan() - ? plan.PasswordManager.StripePlanId - : plan.PasswordManager.StripeSeatPlanId, - Quantity = 1 - } - ] - } - }; - - if (productType == ProductType.SecretsManager) - { - options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions - { - Price = plan.SecretsManager.StripeSeatPlanId, - Quantity = 1 - }); - - options.Coupon = StripeConstants.CouponIDs.SecretsManagerStandalone; - } - - if (!string.IsNullOrEmpty(taxInformation.TaxId)) - { - var taxIdType = taxService.GetStripeTaxCode( - taxInformation.Country, - taxInformation.TaxId); - - if (string.IsNullOrEmpty(taxIdType)) - { - return new BadRequest( - "We couldn't find a corresponding tax ID type for the tax ID you provided. Please try again or contact support for assistance."); - } - - options.CustomerDetails.TaxIds = - [ - new InvoiceCustomerDetailsTaxIdOptions { Type = taxIdType, Value = taxInformation.TaxId } - ]; - - if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) - { - options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions - { - Type = StripeConstants.TaxIdType.EUVAT, - Value = $"ES{parameters.TaxInformation.TaxId}" - }); - } - } - - options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; - if (parameters.PlanType.IsBusinessProductTierType() && - parameters.TaxInformation.Country != Core.Constants.CountryAbbreviations.UnitedStates) - { - options.CustomerDetails.TaxExempt = StripeConstants.TaxExempt.Reverse; - } - - var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); - return Convert.ToDecimal(invoice.Tax) / 100; - }); -} - -#region Command Parameters - -public record OrganizationTrialParameters -{ - public required PlanType PlanType { get; set; } - public required ProductType ProductType { get; set; } - public required TaxInformationDTO TaxInformation { get; set; } - - public void Deconstruct( - out PlanType planType, - out ProductType productType, - out TaxInformationDTO taxInformation) - { - planType = PlanType; - productType = ProductType; - taxInformation = TaxInformation; - } - - public record TaxInformationDTO - { - public required string Country { get; set; } - public required string PostalCode { get; set; } - public string? TaxId { get; set; } - } -} - -#endregion diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index d26e0f67fa..bcc9b0b40d 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -169,7 +169,6 @@ public static class FeatureFlagKeys public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships"; public const string UsePricingService = "use-pricing-service"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; - public const string PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout"; public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover"; public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings"; public const string PM23385_UseNewPremiumFlow = "pm-23385-use-new-premium-flow"; diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs index 03d1776e90..4863baf73e 100644 --- a/src/Core/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Services/Implementations/StripeAdapter.cs @@ -3,6 +3,7 @@ using Bit.Core.Models.BitStripe; using Stripe; +using Stripe.Tax; namespace Bit.Core.Services; @@ -23,6 +24,7 @@ public class StripeAdapter : IStripeAdapter private readonly Stripe.TestHelpers.TestClockService _testClockService; private readonly CustomerBalanceTransactionService _customerBalanceTransactionService; private readonly Stripe.Tax.RegistrationService _taxRegistrationService; + private readonly CalculationService _calculationService; public StripeAdapter() { @@ -41,6 +43,7 @@ public class StripeAdapter : IStripeAdapter _testClockService = new Stripe.TestHelpers.TestClockService(); _customerBalanceTransactionService = new CustomerBalanceTransactionService(); _taxRegistrationService = new Stripe.Tax.RegistrationService(); + _calculationService = new CalculationService(); } public Task CustomerCreateAsync(Stripe.CustomerCreateOptions options) diff --git a/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs new file mode 100644 index 0000000000..8e3cd5a0fa --- /dev/null +++ b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs @@ -0,0 +1,1262 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Pricing; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Stripe; +using Xunit; +using static Bit.Core.Billing.Constants.StripeConstants; + +namespace Bit.Core.Test.Billing.Organizations.Commands; + +public class PreviewOrganizationTaxCommandTests +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly PreviewOrganizationTaxCommand _command; + + public PreviewOrganizationTaxCommandTests() + { + _command = new PreviewOrganizationTaxCommand(_logger, _pricingClient, _stripeAdapter); + } + + #region Subscription Purchase + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_SponsoredPasswordManager_ReturnsCorrectTaxAmounts() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Families, + Cadence = PlanCadenceType.Annually, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 6, + AdditionalStorage = 0, + Sponsored = true + } + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new FamiliesPlan(); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 500, + Total = 5500 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(5.00m, tax); + Assert.Equal(55.00m, total); + + // Verify the correct Stripe API call for sponsored subscription + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2021-family-for-enterprise-annually" && + options.SubscriptionDetails.Items[0].Quantity == 1 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_StandaloneSecretsManager_ReturnsCorrectTaxAmounts() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0, + Sponsored = false + }, + SecretsManager = new OrganizationSubscriptionPurchase.SecretsManagerSelections + { + Seats = 3, + AdditionalServiceAccounts = 0, + Standalone = true + } + }; + + var billingAddress = new BillingAddress + { + Country = "CA", + PostalCode = "K1A 0A6" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 750, + Total = 8250 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(7.50m, tax); + Assert.Equal(82.50m, total); + + // Verify the correct Stripe API call for standalone secrets manager + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "CA" && + options.CustomerDetails.Address.PostalCode == "K1A 0A6" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-teams-org-seat-monthly" && item.Quantity == 5) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-teams-seat-monthly" && item.Quantity == 3) && + options.Coupon == CouponIDs.SecretsManagerStandalone)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_StandardPurchaseWithStorage_ReturnsCorrectTaxAmounts() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 10, + AdditionalStorage = 5, + Sponsored = false + }, + SecretsManager = new OrganizationSubscriptionPurchase.SecretsManagerSelections + { + Seats = 8, + AdditionalServiceAccounts = 3, + Standalone = false + } + }; + + var billingAddress = new BillingAddress + { + Country = "GB", + PostalCode = "SW1A 1AA", + TaxId = new TaxID("gb_vat", "123456789") + }; + + var plan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 1200, + Total = 12200 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(12.00m, tax); + Assert.Equal(122.00m, total); + + // Verify the correct Stripe API call for comprehensive purchase with storage and service accounts + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "GB" && + options.CustomerDetails.Address.PostalCode == "SW1A 1AA" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.CustomerDetails.TaxIds.Count == 1 && + options.CustomerDetails.TaxIds[0].Type == "gb_vat" && + options.CustomerDetails.TaxIds[0].Value == "123456789" && + options.SubscriptionDetails.Items.Count == 4 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 10) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "storage-gb-annually" && item.Quantity == 5) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 8) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 3) && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_FamiliesTier_NoSecretsManager_ReturnsCorrectTaxAmounts() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Families, + Cadence = PlanCadenceType.Annually, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 6, + AdditionalStorage = 0, + Sponsored = false + } + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "90210" + }; + + var plan = new FamiliesPlan(); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 300, + Total = 3300 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify the correct Stripe API call for Families tier (non-seat-based plan) + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "90210" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2020-families-org-annually" && + options.SubscriptionDetails.Items[0].Quantity == 6 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_BusinessUseNonUSCountry_UsesTaxExemptReverse() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 3, + AdditionalStorage = 0, + Sponsored = false + } + }; + + var billingAddress = new BillingAddress + { + Country = "DE", + PostalCode = "10115" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 0, + Total = 2700 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(0.00m, tax); + Assert.Equal(27.00m, total); + + // Verify the correct Stripe API call for business use in non-US country (tax exempt reverse) + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "DE" && + options.CustomerDetails.Address.PostalCode == "10115" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 3 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_SpanishNIFTaxId_AddsEUVATTaxId() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 15, + AdditionalStorage = 0, + Sponsored = false + } + }; + + var billingAddress = new BillingAddress + { + Country = "ES", + PostalCode = "28001", + TaxId = new TaxID(TaxIdType.SpanishNIF, "12345678Z") + }; + + var plan = new EnterprisePlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 2100, + Total = 12100 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(21.00m, tax); + Assert.Equal(121.00m, total); + + // Verify the correct Stripe API call for Spanish NIF that adds both Spanish NIF and EU VAT tax IDs + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "ES" && + options.CustomerDetails.Address.PostalCode == "28001" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.CustomerDetails.TaxIds.Count == 2 && + options.CustomerDetails.TaxIds.Any(t => t.Type == TaxIdType.SpanishNIF && t.Value == "12345678Z") && + options.CustomerDetails.TaxIds.Any(t => t.Type == TaxIdType.EUVAT && t.Value == "ES12345678Z") && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-enterprise-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 15 && + options.Coupon == null)); + } + + #endregion + + #region Subscription Plan Change + + [Fact] + public async Task Run_OrganizationPlanChange_FreeOrganizationToTeams_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.Free, + UseSecretsManager = false + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 120, + Total = 1320 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(1.20m, tax); + Assert.Equal(13.20m, total); + + // Verify the correct Stripe API call for free organization upgrade to Teams + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 2 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationPlanChange_FreeOrganizationToFamilies_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.Free, + UseSecretsManager = true + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Families, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "CA", + PostalCode = "K1A 0A6" + }; + + var plan = new FamiliesPlan(); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 400, + Total = 4400 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(4.00m, tax); + Assert.Equal(44.00m, total); + + // Verify the correct Stripe API call for free organization upgrade to Families (no SM for Families) + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "CA" && + options.CustomerDetails.Address.PostalCode == "K1A 0A6" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2020-families-org-annually" && + options.SubscriptionDetails.Items[0].Quantity == 2 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationPlanChange_FreeOrganizationWithSecretsManagerToEnterprise_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.Free, + UseSecretsManager = true + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "GB", + PostalCode = "SW1A 1AA" + }; + + var plan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 800, + Total = 8800 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(8.00m, tax); + Assert.Equal(88.00m, total); + + // Verify the correct Stripe API call for free organization with SM to Enterprise + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "GB" && + options.CustomerDetails.Address.PostalCode == "SW1A 1AA" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 2) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 2) && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationPlanChange_ExistingSubscriptionUpgrade_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123", + UseSecretsManager = true + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "DE", + PostalCode = "10115" + }; + + var currentPlan = new TeamsPlan(false); + var newPlan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(newPlan); + + // Mock existing subscription with items - using NEW plan IDs since command looks for new plan prices + var subscriptionItems = new List + { + new() { Price = new Price { Id = "2023-teams-org-seat-monthly" }, Quantity = 8 }, + new() { Price = new Price { Id = "storage-gb-annually" }, Quantity = 3 }, + new() { Price = new Price { Id = "secrets-manager-enterprise-seat-annually" }, Quantity = 5 }, + new() { Price = new Price { Id = "secrets-manager-service-account-2024-annually" }, Quantity = 10 } + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Items = new StripeList { Data = subscriptionItems }, + Customer = new Customer { Discount = null } + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 1500, + Total = 16500 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(15.00m, tax); + Assert.Equal(165.00m, total); + + // Verify the correct Stripe API call for existing subscription upgrade + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "DE" && + options.CustomerDetails.Address.PostalCode == "10115" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 4 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 8) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "storage-gb-annually" && item.Quantity == 3) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 5) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 10) && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationPlanChange_ExistingSubscriptionWithDiscount_PreservesCoupon() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsAnnually, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123", + UseSecretsManager = false + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "90210" + }; + + var currentPlan = new TeamsPlan(true); + var newPlan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(newPlan); + + // Mock existing subscription with discount + var subscriptionItems = new List + { + new() { Price = new Price { Id = "2023-teams-org-seat-annually" }, Quantity = 5 } + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Items = new StripeList { Data = subscriptionItems }, + Customer = new Customer + { + Discount = new Discount + { + Coupon = new Coupon { Id = "EXISTING_DISCOUNT_50" } + } + } + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 600, + Total = 6600 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(6.00m, tax); + Assert.Equal(66.00m, total); + + // Verify the correct Stripe API call preserves existing discount + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "90210" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-enterprise-org-seat-annually" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Coupon == "EXISTING_DISCOUNT_50")); + } + + [Fact] + public async Task Run_OrganizationPlanChange_OrganizationWithoutGatewayIds_ReturnsBadRequest() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + GatewayCustomerId = null, + GatewaySubscriptionId = null + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Organization does not have a subscription.", badRequest.Response); + + // Verify no Stripe API calls were made + await _stripeAdapter.DidNotReceive().InvoiceCreatePreviewAsync(Arg.Any()); + await _stripeAdapter.DidNotReceive().SubscriptionGetAsync(Arg.Any(), Arg.Any()); + } + + #endregion + + #region Subscription Update + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_PasswordManagerSeatsOnly_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + PasswordManager = new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = 10, + AdditionalStorage = null + } + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "US", PostalCode = "12345" }, + Discount = null, + TaxIds = null + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 600, + Total = 6600 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(6.00m, tax); + Assert.Equal(66.00m, total); + + // Verify the correct Stripe API call for PM seats only + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 10 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_PasswordManagerWithStorage_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.EnterpriseAnnually, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + PasswordManager = new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = 15, + AdditionalStorage = 5 + } + }; + + var plan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "CA", PostalCode = "K1A 0A6" }, + Discount = null, + TaxIds = null + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 1200, + Total = 13200 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(12.00m, tax); + Assert.Equal(132.00m, total); + + // Verify the correct Stripe API call for PM seats + storage + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "CA" && + options.CustomerDetails.Address.PostalCode == "K1A 0A6" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 15) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "storage-gb-annually" && item.Quantity == 5) && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_SecretsManagerOnly_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsAnnually, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + SecretsManager = new OrganizationSubscriptionUpdate.SecretsManagerSelections + { + Seats = 8, + AdditionalServiceAccounts = null + } + }; + + var plan = new TeamsPlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "DE", PostalCode = "10115" }, + Discount = null, + TaxIds = null + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 800, + Total = 8800 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(8.00m, tax); + Assert.Equal(88.00m, total); + + // Verify the correct Stripe API call for SM seats only + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "DE" && + options.CustomerDetails.Address.PostalCode == "10115" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "secrets-manager-teams-seat-annually" && + options.SubscriptionDetails.Items[0].Quantity == 8 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_SecretsManagerWithServiceAccounts_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.EnterpriseMonthly, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + SecretsManager = new OrganizationSubscriptionUpdate.SecretsManagerSelections + { + Seats = 12, + AdditionalServiceAccounts = 20 + } + }; + + var plan = new EnterprisePlan(false); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "GB", PostalCode = "SW1A 1AA" }, + Discount = null, + TaxIds = new StripeList + { + Data = new List + { + new() { Type = "gb_vat", Value = "GB123456789" } + } + } + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 1500, + Total = 16500 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(15.00m, tax); + Assert.Equal(165.00m, total); + + // Verify the correct Stripe API call for SM seats + service accounts with tax ID + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "GB" && + options.CustomerDetails.Address.PostalCode == "SW1A 1AA" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.CustomerDetails.TaxIds.Count == 1 && + options.CustomerDetails.TaxIds[0].Type == "gb_vat" && + options.CustomerDetails.TaxIds[0].Value == "GB123456789" && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-enterprise-seat-monthly" && item.Quantity == 12) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-service-account-2024-monthly" && item.Quantity == 20) && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_ComprehensiveUpdate_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.EnterpriseAnnually, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + PasswordManager = new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = 25, + AdditionalStorage = 10 + }, + SecretsManager = new OrganizationSubscriptionUpdate.SecretsManagerSelections + { + Seats = 15, + AdditionalServiceAccounts = 30 + } + }; + + var plan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "ES", PostalCode = "28001" }, + Discount = new Discount + { + Coupon = new Coupon { Id = "ENTERPRISE_DISCOUNT_20" } + }, + TaxIds = new StripeList + { + Data = new List + { + new() { Type = TaxIdType.SpanishNIF, Value = "12345678Z" } + } + } + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 2500, + Total = 27500 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(25.00m, tax); + Assert.Equal(275.00m, total); + + // Verify the correct Stripe API call for comprehensive update with discount and Spanish tax ID + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "ES" && + options.CustomerDetails.Address.PostalCode == "28001" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.CustomerDetails.TaxIds.Count == 2 && + options.CustomerDetails.TaxIds.Any(t => t.Type == TaxIdType.SpanishNIF && t.Value == "12345678Z") && + options.CustomerDetails.TaxIds.Any(t => t.Type == TaxIdType.EUVAT && t.Value == "ES12345678Z") && + options.SubscriptionDetails.Items.Count == 4 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 25) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "storage-gb-annually" && item.Quantity == 10) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 15) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 30) && + options.Coupon == "ENTERPRISE_DISCOUNT_20")); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_FamiliesTierPersonalUsage_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.FamiliesAnnually, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + PasswordManager = new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = 6, + AdditionalStorage = 2 + } + }; + + var plan = new FamiliesPlan(); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "AU", PostalCode = "2000" }, + Discount = null, + TaxIds = null + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 500, + Total = 5500 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(5.00m, tax); + Assert.Equal(55.00m, total); + + // Verify the correct Stripe API call for Families tier (personal usage, no business tax exemption) + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "AU" && + options.CustomerDetails.Address.PostalCode == "2000" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2020-families-org-annually" && item.Quantity == 6) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "personal-storage-gb-annually" && item.Quantity == 2) && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_OrganizationWithoutGatewayIds_ReturnsBadRequest() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + GatewayCustomerId = null, + GatewaySubscriptionId = null + }; + + var update = new OrganizationSubscriptionUpdate + { + PasswordManager = new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = 5 + } + }; + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Organization does not have a subscription.", badRequest.Response); + + // Verify no Stripe API calls were made + await _stripeAdapter.DidNotReceive().InvoiceCreatePreviewAsync(Arg.Any()); + await _stripeAdapter.DidNotReceive().SubscriptionGetAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_ZeroValuesExcluded_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + PasswordManager = new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0 // Should be excluded + }, + SecretsManager = new OrganizationSubscriptionUpdate.SecretsManagerSelections + { + Seats = 0, // Should be excluded entirely (including service accounts) + AdditionalServiceAccounts = 10 + } + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "US", PostalCode = "90210" }, + Discount = null, + TaxIds = null + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 300, + Total = 3300 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify only PM seats are included (storage=0 excluded, SM seats=0 so entire SM excluded) + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "90210" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Coupon == null)); + } + + #endregion +} diff --git a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs new file mode 100644 index 0000000000..bf7d093dc7 --- /dev/null +++ b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs @@ -0,0 +1,292 @@ +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Stripe; +using Xunit; +using static Bit.Core.Billing.Constants.StripeConstants; + +namespace Bit.Core.Test.Billing.Premium.Commands; + +public class PreviewPremiumTaxCommandTests +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly PreviewPremiumTaxCommand _command; + + public PreviewPremiumTaxCommandTests() + { + _command = new PreviewPremiumTaxCommand(_logger, _stripeAdapter); + } + + [Fact] + public async Task Run_PremiumWithoutStorage_ReturnsCorrectTaxAmounts() + { + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var invoice = new Invoice + { + Tax = 300, + Total = 3300 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(0, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually && + options.SubscriptionDetails.Items[0].Quantity == 1)); + } + + [Fact] + public async Task Run_PremiumWithAdditionalStorage_ReturnsCorrectTaxAmounts() + { + var billingAddress = new BillingAddress + { + Country = "CA", + PostalCode = "K1A 0A6" + }; + + var invoice = new Invoice + { + Tax = 500, + Total = 5500 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(5, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(5.00m, tax); + Assert.Equal(55.00m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "CA" && + options.CustomerDetails.Address.PostalCode == "K1A 0A6" && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.PremiumAnnually && item.Quantity == 1) && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.StoragePlanPersonal && item.Quantity == 5))); + } + + [Fact] + public async Task Run_PremiumWithZeroStorage_ExcludesStorageFromItems() + { + var billingAddress = new BillingAddress + { + Country = "GB", + PostalCode = "SW1A 1AA" + }; + + var invoice = new Invoice + { + Tax = 250, + Total = 2750 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(0, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(2.50m, tax); + Assert.Equal(27.50m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "GB" && + options.CustomerDetails.Address.PostalCode == "SW1A 1AA" && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually && + options.SubscriptionDetails.Items[0].Quantity == 1)); + } + + [Fact] + public async Task Run_PremiumWithLargeStorage_HandlesMultipleStorageUnits() + { + var billingAddress = new BillingAddress + { + Country = "DE", + PostalCode = "10115" + }; + + var invoice = new Invoice + { + Tax = 800, + Total = 8800 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(20, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(8.00m, tax); + Assert.Equal(88.00m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "DE" && + options.CustomerDetails.Address.PostalCode == "10115" && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.PremiumAnnually && item.Quantity == 1) && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.StoragePlanPersonal && item.Quantity == 20))); + } + + [Fact] + public async Task Run_PremiumInternationalAddress_UsesCorrectAddressInfo() + { + var billingAddress = new BillingAddress + { + Country = "AU", + PostalCode = "2000" + }; + + var invoice = new Invoice + { + Tax = 450, + Total = 4950 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(10, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(4.50m, tax); + Assert.Equal(49.50m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "AU" && + options.CustomerDetails.Address.PostalCode == "2000" && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.PremiumAnnually && item.Quantity == 1) && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.StoragePlanPersonal && item.Quantity == 10))); + } + + [Fact] + public async Task Run_PremiumNoTax_ReturnsZeroTax() + { + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "97330" // Example of a tax-free jurisdiction + }; + + var invoice = new Invoice + { + Tax = 0, + Total = 3000 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(0, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(0.00m, tax); + Assert.Equal(30.00m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "97330" && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually && + options.SubscriptionDetails.Items[0].Quantity == 1)); + } + + [Fact] + public async Task Run_NegativeStorage_TreatedAsZero() + { + var billingAddress = new BillingAddress + { + Country = "FR", + PostalCode = "75001" + }; + + var invoice = new Invoice + { + Tax = 600, + Total = 6600 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(-5, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(6.00m, tax); + Assert.Equal(66.00m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "FR" && + options.CustomerDetails.Address.PostalCode == "75001" && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually && + options.SubscriptionDetails.Items[0].Quantity == 1)); + } + + [Fact] + public async Task Run_AmountConversion_CorrectlyConvertsStripeAmounts() + { + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + // Stripe amounts are in cents + var invoice = new Invoice + { + Tax = 123, // $1.23 + Total = 3123 // $31.23 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(0, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(1.23m, tax); + Assert.Equal(31.23m, total); + } +} diff --git a/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs b/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs new file mode 100644 index 0000000000..a5970c79ab --- /dev/null +++ b/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs @@ -0,0 +1,198 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Commands; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Billing.Subscriptions; + +using static StripeConstants; + +public class RestartSubscriptionCommandTests +{ + private readonly IOrganizationRepository _organizationRepository = Substitute.For(); + private readonly IProviderRepository _providerRepository = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly ISubscriberService _subscriberService = Substitute.For(); + private readonly IUserRepository _userRepository = Substitute.For(); + private readonly RestartSubscriptionCommand _command; + + public RestartSubscriptionCommandTests() + { + _command = new RestartSubscriptionCommand( + _organizationRepository, + _providerRepository, + _stripeAdapter, + _subscriberService, + _userRepository); + } + + [Fact] + public async Task Run_SubscriptionNotCanceled_ReturnsBadRequest() + { + var organization = new Organization { Id = Guid.NewGuid() }; + + var subscription = new Subscription { Status = SubscriptionStatus.Active }; + _subscriberService.GetSubscription(organization).Returns(subscription); + + var result = await _command.Run(organization); + + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Cannot restart a subscription that is not canceled.", badRequest.Response); + } + + [Fact] + public async Task Run_NoExistingSubscription_ReturnsBadRequest() + { + var organization = new Organization { Id = Guid.NewGuid() }; + + _subscriberService.GetSubscription(organization).Returns((Subscription)null); + + var result = await _command.Run(organization); + + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Cannot restart a subscription that is not canceled.", badRequest.Response); + } + + [Fact] + public async Task Run_Organization_Success_ReturnsNone() + { + var organizationId = Guid.NewGuid(); + var organization = new Organization { Id = organizationId }; + var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + + var existingSubscription = new Subscription + { + Status = SubscriptionStatus.Canceled, + CustomerId = "cus_123", + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 }, + new SubscriptionItem { Price = new Price { Id = "price_2" }, Quantity = 2 } + ] + }, + Metadata = new Dictionary { ["key"] = "value" } + }; + + var newSubscription = new Subscription + { + Id = "sub_new", + CurrentPeriodEnd = currentPeriodEnd + }; + + _subscriberService.GetSubscription(organization).Returns(existingSubscription); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(newSubscription); + + var result = await _command.Run(organization); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is((SubscriptionCreateOptions options) => + options.AutomaticTax.Enabled == true && + options.CollectionMethod == CollectionMethod.ChargeAutomatically && + options.Customer == "cus_123" && + options.Items.Count == 2 && + options.Items[0].Price == "price_1" && + options.Items[0].Quantity == 1 && + options.Items[1].Price == "price_2" && + options.Items[1].Quantity == 2 && + options.Metadata["key"] == "value" && + options.OffSession == true && + options.TrialPeriodDays == 0)); + + await _organizationRepository.Received(1).ReplaceAsync(Arg.Is(org => + org.Id == organizationId && + org.GatewaySubscriptionId == "sub_new" && + org.Enabled == true && + org.ExpirationDate == currentPeriodEnd)); + } + + [Fact] + public async Task Run_Provider_Success_ReturnsNone() + { + var providerId = Guid.NewGuid(); + var provider = new Provider { Id = providerId }; + + var existingSubscription = new Subscription + { + Status = SubscriptionStatus.Canceled, + CustomerId = "cus_123", + Items = new StripeList + { + Data = [new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 }] + }, + Metadata = new Dictionary() + }; + + var newSubscription = new Subscription + { + Id = "sub_new", + CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) + }; + + _subscriberService.GetSubscription(provider).Returns(existingSubscription); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(newSubscription); + + var result = await _command.Run(provider); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any()); + + await _providerRepository.Received(1).ReplaceAsync(Arg.Is(prov => + prov.Id == providerId && + prov.GatewaySubscriptionId == "sub_new" && + prov.Enabled == true)); + } + + [Fact] + public async Task Run_User_Success_ReturnsNone() + { + var userId = Guid.NewGuid(); + var user = new User { Id = userId }; + var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + + var existingSubscription = new Subscription + { + Status = SubscriptionStatus.Canceled, + CustomerId = "cus_123", + Items = new StripeList + { + Data = [new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 }] + }, + Metadata = new Dictionary() + }; + + var newSubscription = new Subscription + { + Id = "sub_new", + CurrentPeriodEnd = currentPeriodEnd + }; + + _subscriberService.GetSubscription(user).Returns(existingSubscription); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(newSubscription); + + var result = await _command.Run(user); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any()); + + await _userRepository.Received(1).ReplaceAsync(Arg.Is(u => + u.Id == userId && + u.GatewaySubscriptionId == "sub_new" && + u.Premium == true && + u.PremiumExpirationDate == currentPeriodEnd)); + } +} diff --git a/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs b/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs deleted file mode 100644 index 1de180cea1..0000000000 --- a/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs +++ /dev/null @@ -1,541 +0,0 @@ -using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Tax.Commands; -using Bit.Core.Billing.Tax.Services; -using Bit.Core.Services; -using Bit.Core.Utilities; -using Microsoft.Extensions.Logging; -using NSubstitute; -using Stripe; -using Xunit; -using static Bit.Core.Billing.Tax.Commands.OrganizationTrialParameters; - -namespace Bit.Core.Test.Billing.Tax.Commands; - -public class PreviewTaxAmountCommandTests -{ - private readonly ILogger _logger = Substitute.For>(); - private readonly IPricingClient _pricingClient = Substitute.For(); - private readonly IStripeAdapter _stripeAdapter = Substitute.For(); - private readonly ITaxService _taxService = Substitute.For(); - - private readonly PreviewTaxAmountCommand _command; - - public PreviewTaxAmountCommandTests() - { - _command = new PreviewTaxAmountCommand(_logger, _pricingClient, _stripeAdapter, _taxService); - } - - [Fact] - public async Task Run_WithSeatBasedPasswordManagerPlan_GetsTaxAmount() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "US", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is(options => - options.Currency == "usd" && - options.CustomerDetails.Address.Country == "US" && - options.CustomerDetails.Address.PostalCode == "12345" && - options.SubscriptionDetails.Items.Count == 1 && - options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && - options.SubscriptionDetails.Items[0].Quantity == 1 && - options.AutomaticTax.Enabled == true - )) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - Assert.True(result.IsT0); - var taxAmount = result.AsT0; - Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100); - } - - [Fact] - public async Task Run_WithNonSeatBasedPasswordManagerPlan_GetsTaxAmount() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.FamiliesAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "US", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is(options => - options.Currency == "usd" && - options.CustomerDetails.Address.Country == "US" && - options.CustomerDetails.Address.PostalCode == "12345" && - options.SubscriptionDetails.Items.Count == 1 && - options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripePlanId && - options.SubscriptionDetails.Items[0].Quantity == 1 && - options.AutomaticTax.Enabled == true - )) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - Assert.True(result.IsT0); - var taxAmount = result.AsT0; - Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100); - } - - [Fact] - public async Task Run_WithSecretsManagerPlan_GetsTaxAmount() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.SecretsManager, - TaxInformation = new TaxInformationDTO - { - Country = "US", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is(options => - options.Currency == "usd" && - options.CustomerDetails.Address.Country == "US" && - options.CustomerDetails.Address.PostalCode == "12345" && - options.SubscriptionDetails.Items.Count == 2 && - options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && - options.SubscriptionDetails.Items[0].Quantity == 1 && - options.SubscriptionDetails.Items[1].Price == plan.SecretsManager.StripeSeatPlanId && - options.SubscriptionDetails.Items[1].Quantity == 1 && - options.Coupon == StripeConstants.CouponIDs.SecretsManagerStandalone && - options.AutomaticTax.Enabled == true - )) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - Assert.True(result.IsT0); - var taxAmount = result.AsT0; - Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100); - } - - [Fact] - public async Task Run_NonUSWithoutTaxId_GetsTaxAmount() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "CA", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is(options => - options.Currency == "usd" && - options.CustomerDetails.Address.Country == "CA" && - options.CustomerDetails.Address.PostalCode == "12345" && - options.SubscriptionDetails.Items.Count == 1 && - options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && - options.SubscriptionDetails.Items[0].Quantity == 1 && - options.AutomaticTax.Enabled == true - )) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - Assert.True(result.IsT0); - var taxAmount = result.AsT0; - Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100); - } - - [Fact] - public async Task Run_NonUSWithTaxId_GetsTaxAmount() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "CA", - PostalCode = "12345", - TaxId = "123456789" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - _taxService.GetStripeTaxCode(parameters.TaxInformation.Country, parameters.TaxInformation.TaxId) - .Returns("ca_st"); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is(options => - options.Currency == "usd" && - options.CustomerDetails.Address.Country == "CA" && - options.CustomerDetails.Address.PostalCode == "12345" && - options.CustomerDetails.TaxIds.Count == 1 && - options.CustomerDetails.TaxIds[0].Type == "ca_st" && - options.CustomerDetails.TaxIds[0].Value == "123456789" && - options.SubscriptionDetails.Items.Count == 1 && - options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && - options.SubscriptionDetails.Items[0].Quantity == 1 && - options.AutomaticTax.Enabled == true - )) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - Assert.True(result.IsT0); - var taxAmount = result.AsT0; - Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100); - } - - [Fact] - public async Task Run_NonUSWithTaxId_UnknownTaxIdType_BadRequest() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "CA", - PostalCode = "12345", - TaxId = "123456789" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - _taxService.GetStripeTaxCode(parameters.TaxInformation.Country, parameters.TaxInformation.TaxId) - .Returns((string)null); - - // Act - var result = await _command.Run(parameters); - - // Assert - Assert.True(result.IsT1); - var badRequest = result.AsT1; - Assert.Equal("We couldn't find a corresponding tax ID type for the tax ID you provided. Please try again or contact support for assistance.", badRequest.Response); - } - - [Fact] - public async Task Run_USBased_PersonalUse_SetsAutomaticTaxEnabled() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.FamiliesAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "US", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.AutomaticTax.Enabled == true - )); - Assert.True(result.IsT0); - } - - [Fact] - public async Task Run_USBased_BusinessUse_SetsAutomaticTaxEnabled() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "US", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.AutomaticTax.Enabled == true - )); - Assert.True(result.IsT0); - } - - [Fact] - public async Task Run_NonUSBased_PersonalUse_SetsAutomaticTaxEnabled() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.FamiliesAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "CA", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.AutomaticTax.Enabled == true - )); - Assert.True(result.IsT0); - } - - [Fact] - public async Task Run_NonUSBased_BusinessUse_SetsAutomaticTaxEnabled() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "CA", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.AutomaticTax.Enabled == true - )); - Assert.True(result.IsT0); - } - - [Fact] - public async Task Run_USBased_PersonalUse_DoesNotSetTaxExempt() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.FamiliesAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "US", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.CustomerDetails.TaxExempt == null - )); - Assert.True(result.IsT0); - } - - [Fact] - public async Task Run_USBased_BusinessUse_DoesNotSetTaxExempt() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "US", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.CustomerDetails.TaxExempt == null - )); - Assert.True(result.IsT0); - } - - [Fact] - public async Task Run_NonUSBased_PersonalUse_DoesNotSetTaxExempt() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.FamiliesAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "CA", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.CustomerDetails.TaxExempt == null - )); - Assert.True(result.IsT0); - - } - - [Fact] - public async Task Run_NonUSBased_BusinessUse_SetsTaxExemptReverse() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "CA", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.CustomerDetails.TaxExempt == StripeConstants.TaxExempt.Reverse - )); - Assert.True(result.IsT0); - } -}