From 2b43cde99b0b0f0400f8dba23df1d1815b9dfc9a Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Mon, 3 Jun 2024 11:00:52 -0400 Subject: [PATCH] [AC-1938] Update provider payment method (#4140) * Refactored GET provider subscription Refactoring this endpoint and its associated tests in preparation for the addition of more endpoints that share similar patterns * Replaced StripePaymentService call in AccountsController, OrganizationsController This was made in error during a previous PR. Since this is not related to Consolidated Billing, we want to try not to include it in these changes. * Removing GetPaymentInformation call from ProviderBillingService This method is a good call for the SubscriberService as we'll want to extend the functionality to all subscriber types * Refactored GetTaxInformation to use Billing owned DTO * Add UpdateTaxInformation to SubscriberService * Added GetTaxInformation and UpdateTaxInformation endpoints to ProviderBillingController * Added controller to manage creation of Stripe SetupIntents With the deprecation of the Sources API, we need to move the bank account creation process to using SetupIntents. This controller brings both the creation of "card" and "us_bank_account" SetupIntents under billing management. * Added UpdatePaymentMethod method to SubscriberService This method utilizes the SetupIntents created by the StripeController from the previous commit when a customer adds a card or us_bank_account payment method (Stripe). We need to cache the most recent SetupIntent for the subscriber so that we know which PaymentMethod is their most recent even when it hasn't been confirmed yet. * Refactored GetPaymentMethod to use billing owned DTO and check setup intents * Added GetPaymentMethod and UpdatePaymentMethod endpoints to ProviderBillingController * Re-added GetPaymentInformation endpoint to consolidate API calls on the payment method page * Added VerifyBankAccount endpoint to ProviderBillingController in order to finalize bank account payment methods * Updated BitPayInvoiceRequestModel to support providers * run dotnet format * Conner's feedback * Run dotnet format' --- .../Billing/ProviderBillingService.cs | 53 +- .../Billing/ProviderBillingServiceTests.cs | 175 +--- .../Auth/Controllers/AccountsController.cs | 2 +- .../Controllers/OrganizationsController.cs | 2 +- .../Controllers/ProviderBillingController.cs | 222 ++++- .../Billing/Controllers/StripeController.cs | 49 + .../Requests/TaxInformationRequestBody.cs | 16 + .../TokenizedPaymentMethodRequestBody.cs | 18 + .../Requests/VerifyBankAccountRequestBody.cs | 11 + ...onsolidatedBillingSubscriptionResponse.cs} | 18 +- .../Responses/MaskedPaymentMethodResponse.cs | 16 + .../Responses/PaymentInformationResponse.cs | 42 +- .../Responses/TaxInformationResponse.cs | 23 + .../Request/BitPayInvoiceRequestModel.cs | 9 +- src/Core/Billing/Caches/ISetupIntentCache.cs | 10 + .../SetupIntentDistributedCache.cs | 32 + src/Core/Billing/Constants/StripeConstants.cs | 6 + .../Billing/Extensions/BillingExtensions.cs | 15 + .../Extensions/ServiceCollectionExtensions.cs | 5 +- ... => ConsolidatedBillingSubscriptionDTO.cs} | 2 +- .../Billing/Models/MaskedPaymentMethodDTO.cs | 156 +++ .../Billing/Models/PaymentInformationDTO.cs | 6 + .../Billing/Models/ProviderPaymentInfoDTO.cs | 6 - src/Core/Billing/Models/TaxInformationDTO.cs | 149 +++ .../Models/TokenizedPaymentMethodDTO.cs | 7 + .../Services/IProviderBillingService.cs | 18 +- .../Billing/Services/ISubscriberService.cs | 68 +- .../Implementations/SubscriberService.cs | 546 ++++++++-- src/Core/Services/IPaymentService.cs | 1 + src/Core/Services/IStripeAdapter.cs | 6 + .../Services/Implementations/StripeAdapter.cs | 28 + .../Implementations/StripePaymentService.cs | 37 + .../ProviderBillingControllerTests.cs | 335 ++++++- .../Services/SubscriberServiceTests.cs | 929 ++++++++++++++++-- 34 files changed, 2478 insertions(+), 540 deletions(-) create mode 100644 src/Api/Billing/Controllers/StripeController.cs create mode 100644 src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs create mode 100644 src/Api/Billing/Models/Requests/TokenizedPaymentMethodRequestBody.cs create mode 100644 src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs rename src/Api/Billing/Models/Responses/{ProviderSubscriptionResponse.cs => ConsolidatedBillingSubscriptionResponse.cs} (72%) create mode 100644 src/Api/Billing/Models/Responses/MaskedPaymentMethodResponse.cs create mode 100644 src/Api/Billing/Models/Responses/TaxInformationResponse.cs create mode 100644 src/Core/Billing/Caches/ISetupIntentCache.cs create mode 100644 src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs rename src/Core/Billing/Models/{ProviderSubscriptionDTO.cs => ConsolidatedBillingSubscriptionDTO.cs} (73%) create mode 100644 src/Core/Billing/Models/MaskedPaymentMethodDTO.cs create mode 100644 src/Core/Billing/Models/PaymentInformationDTO.cs delete mode 100644 src/Core/Billing/Models/ProviderPaymentInfoDTO.cs create mode 100644 src/Core/Billing/Models/TaxInformationDTO.cs create mode 100644 src/Core/Billing/Models/TokenizedPaymentMethodDTO.cs diff --git a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs index 330ab1617..0e5ce8dc4 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/ProviderBillingService.cs @@ -226,22 +226,14 @@ public class ProviderBillingService( .Sum(providerOrganization => providerOrganization.Seats ?? 0); } - public async Task GetSubscriptionDTO(Guid providerId) + public async Task GetConsolidatedBillingSubscription( + Provider provider) { - var provider = await providerRepository.GetByIdAsync(providerId); - - if (provider == null) - { - logger.LogError( - "Could not find provider ({ID}) when retrieving subscription data.", - providerId); - - return null; - } + ArgumentNullException.ThrowIfNull(provider); if (provider.Type == ProviderType.Reseller) { - logger.LogError("Subscription data cannot be retrieved for reseller-type provider ({ID})", providerId); + logger.LogError("Consolidated billing subscription cannot be retrieved for reseller-type provider ({ID})", provider.Id); throw ContactSupport("Consolidated billing does not support reseller-type providers"); } @@ -256,14 +248,14 @@ public class ProviderBillingService( return null; } - var providerPlans = await providerPlanRepository.GetByProviderId(providerId); + var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); var configuredProviderPlans = providerPlans .Where(providerPlan => providerPlan.IsConfigured()) .Select(ConfiguredProviderPlanDTO.From) .ToList(); - return new ProviderSubscriptionDTO( + return new ConsolidatedBillingSubscriptionDTO( configuredProviderPlans, subscription); } @@ -454,39 +446,6 @@ public class ProviderBillingService( await providerRepository.ReplaceAsync(provider); } - public async Task GetPaymentInformationAsync(Guid providerId) - { - var provider = await providerRepository.GetByIdAsync(providerId); - - if (provider == null) - { - logger.LogError( - "Could not find provider ({ID}) when retrieving payment information.", - providerId); - - return null; - } - - if (provider.Type == ProviderType.Reseller) - { - logger.LogError("payment information cannot be retrieved for reseller-type provider ({ID})", providerId); - - throw ContactSupport("Consolidated billing does not support reseller-type providers"); - } - - var taxInformation = await subscriberService.GetTaxInformationAsync(provider); - var billingInformation = await subscriberService.GetPaymentMethodAsync(provider); - - if (taxInformation == null && billingInformation == null) - { - return null; - } - - return new ProviderPaymentInfoDTO( - billingInformation, - taxInformation); - } - private Func CurrySeatScalingUpdate( Provider provider, ProviderPlan providerPlan, diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs index c5bcc4fc2..f9c59d6b5 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/ProviderBillingServiceTests.cs @@ -21,7 +21,6 @@ using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; -using NSubstitute.ReturnsExtensions; using Stripe; using Xunit; using static Bit.Core.Test.Billing.Utilities; @@ -701,60 +700,33 @@ public class ProviderBillingServiceTests #endregion - #region GetSubscriptionData + #region GetConsolidatedBillingSubscription [Theory, BitAutoData] - public async Task GetSubscriptionData_NullProvider_ReturnsNull( - SutProvider sutProvider, - Guid providerId) - { - var providerRepository = sutProvider.GetDependency(); - - providerRepository.GetByIdAsync(providerId).ReturnsNull(); - - var subscriptionData = await sutProvider.Sut.GetSubscriptionDTO(providerId); - - Assert.Null(subscriptionData); - - await providerRepository.Received(1).GetByIdAsync(providerId); - } + public async Task GetConsolidatedBillingSubscription_NullProvider_ThrowsArgumentNullException( + SutProvider sutProvider) => + await Assert.ThrowsAsync(() => sutProvider.Sut.GetConsolidatedBillingSubscription(null)); [Theory, BitAutoData] - public async Task GetSubscriptionData_NullSubscription_ReturnsNull( + public async Task GetConsolidatedBillingSubscription_NullSubscription_ReturnsNull( SutProvider sutProvider, - Guid providerId, Provider provider) { - var providerRepository = sutProvider.GetDependency(); + var consolidatedBillingSubscription = await sutProvider.Sut.GetConsolidatedBillingSubscription(provider); - providerRepository.GetByIdAsync(providerId).Returns(provider); + Assert.Null(consolidatedBillingSubscription); - var subscriberService = sutProvider.GetDependency(); - - subscriberService.GetSubscription(provider).ReturnsNull(); - - var subscriptionData = await sutProvider.Sut.GetSubscriptionDTO(providerId); - - Assert.Null(subscriptionData); - - await providerRepository.Received(1).GetByIdAsync(providerId); - - await subscriberService.Received(1).GetSubscription( + await sutProvider.GetDependency().Received(1).GetSubscription( provider, Arg.Is( options => options.Expand.Count == 1 && options.Expand.First() == "customer")); } [Theory, BitAutoData] - public async Task GetSubscriptionData_Success( + public async Task GetConsolidatedBillingSubscription_Success( SutProvider sutProvider, - Guid providerId, Provider provider) { - var providerRepository = sutProvider.GetDependency(); - - providerRepository.GetByIdAsync(providerId).Returns(provider); - var subscriberService = sutProvider.GetDependency(); var subscription = new Subscription(); @@ -767,7 +739,7 @@ public class ProviderBillingServiceTests var enterprisePlan = new ProviderPlan { Id = Guid.NewGuid(), - ProviderId = providerId, + ProviderId = provider.Id, PlanType = PlanType.EnterpriseMonthly, SeatMinimum = 100, PurchasedSeats = 0, @@ -777,7 +749,7 @@ public class ProviderBillingServiceTests var teamsPlan = new ProviderPlan { Id = Guid.NewGuid(), - ProviderId = providerId, + ProviderId = provider.Id, PlanType = PlanType.TeamsMonthly, SeatMinimum = 50, PurchasedSeats = 10, @@ -786,37 +758,28 @@ public class ProviderBillingServiceTests var providerPlans = new List { enterprisePlan, teamsPlan, }; - providerPlanRepository.GetByProviderId(providerId).Returns(providerPlans); + providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); - var subscriptionData = await sutProvider.Sut.GetSubscriptionDTO(providerId); + var consolidatedBillingSubscription = await sutProvider.Sut.GetConsolidatedBillingSubscription(provider); - Assert.NotNull(subscriptionData); + Assert.NotNull(consolidatedBillingSubscription); - Assert.Equivalent(subscriptionData.Subscription, subscription); + Assert.Equivalent(consolidatedBillingSubscription.Subscription, subscription); - Assert.Equal(2, subscriptionData.ProviderPlans.Count); + Assert.Equal(2, consolidatedBillingSubscription.ProviderPlans.Count); var configuredEnterprisePlan = - subscriptionData.ProviderPlans.FirstOrDefault(configuredPlan => + consolidatedBillingSubscription.ProviderPlans.FirstOrDefault(configuredPlan => configuredPlan.PlanType == PlanType.EnterpriseMonthly); var configuredTeamsPlan = - subscriptionData.ProviderPlans.FirstOrDefault(configuredPlan => + consolidatedBillingSubscription.ProviderPlans.FirstOrDefault(configuredPlan => configuredPlan.PlanType == PlanType.TeamsMonthly); Compare(enterprisePlan, configuredEnterprisePlan); Compare(teamsPlan, configuredTeamsPlan); - await providerRepository.Received(1).GetByIdAsync(providerId); - - await subscriberService.Received(1).GetSubscription( - provider, - Arg.Is( - options => options.Expand.Count == 1 && options.Expand.First() == "customer")); - - await providerPlanRepository.Received(1).GetByProviderId(providerId); - return; void Compare(ProviderPlan providerPlan, ConfiguredProviderPlanDTO configuredProviderPlan) @@ -1005,106 +968,4 @@ public class ProviderBillingServiceTests } #endregion - - #region GetPaymentInformationAsync - [Theory, BitAutoData] - public async Task GetPaymentInformationAsync_NullProvider_ReturnsNull( - SutProvider sutProvider, - Guid providerId) - { - var providerRepository = sutProvider.GetDependency(); - providerRepository.GetByIdAsync(providerId).ReturnsNull(); - - var paymentService = sutProvider.GetDependency(); - paymentService.GetTaxInformationAsync(Arg.Any()).ReturnsNull(); - paymentService.GetPaymentMethodAsync(Arg.Any()).ReturnsNull(); - - var sut = sutProvider.Sut; - - var paymentInfo = await sut.GetPaymentInformationAsync(providerId); - - Assert.Null(paymentInfo); - await providerRepository.Received(1).GetByIdAsync(providerId); - await paymentService.DidNotReceive().GetTaxInformationAsync(Arg.Any()); - await paymentService.DidNotReceive().GetPaymentMethodAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task GetPaymentInformationAsync_NullSubscription_ReturnsNull( - SutProvider sutProvider, - Guid providerId, - Provider provider) - { - var providerRepository = sutProvider.GetDependency(); - - providerRepository.GetByIdAsync(providerId).Returns(provider); - - var subscriberService = sutProvider.GetDependency(); - - subscriberService.GetTaxInformationAsync(provider).ReturnsNull(); - subscriberService.GetPaymentMethodAsync(provider).ReturnsNull(); - - var paymentInformation = await sutProvider.Sut.GetPaymentInformationAsync(providerId); - - Assert.Null(paymentInformation); - await providerRepository.Received(1).GetByIdAsync(providerId); - await subscriberService.Received(1).GetTaxInformationAsync(provider); - await subscriberService.Received(1).GetPaymentMethodAsync(provider); - } - - [Theory, BitAutoData] - public async Task GetPaymentInformationAsync_ResellerProvider_ThrowContactSupport( - SutProvider sutProvider, - Guid providerId, - Provider provider) - { - provider.Id = providerId; - provider.Type = ProviderType.Reseller; - var providerRepository = sutProvider.GetDependency(); - providerRepository.GetByIdAsync(providerId).Returns(provider); - - var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.GetPaymentInformationAsync(providerId)); - - Assert.Equal("Consolidated billing does not support reseller-type providers", exception.Message); - } - - [Theory, BitAutoData] - public async Task GetPaymentInformationAsync_Success_ReturnsProviderPaymentInfoDTO( - SutProvider sutProvider, - Guid providerId, - Provider provider) - { - provider.Id = providerId; - provider.Type = ProviderType.Msp; - var taxInformation = new TaxInfo { TaxIdNumber = "12345" }; - var paymentMethod = new PaymentMethod - { - Id = "pm_test123", - Type = "card", - Card = new PaymentMethodCard - { - Brand = "visa", - Last4 = "4242", - ExpMonth = 12, - ExpYear = 2024 - } - }; - var billingInformation = new BillingInfo { PaymentSource = new BillingInfo.BillingSource(paymentMethod) }; - - var providerRepository = sutProvider.GetDependency(); - providerRepository.GetByIdAsync(providerId).Returns(provider); - - var subscriberService = sutProvider.GetDependency(); - subscriberService.GetTaxInformationAsync(provider).Returns(taxInformation); - subscriberService.GetPaymentMethodAsync(provider).Returns(billingInformation.PaymentSource); - - var result = await sutProvider.Sut.GetPaymentInformationAsync(providerId); - - // Assert - Assert.NotNull(result); - Assert.Equal(billingInformation.PaymentSource, result.billingSource); - Assert.Equal(taxInformation, result.taxInfo); - } - #endregion } diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 0f8543378..8acdbb7e8 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -835,7 +835,7 @@ public class AccountsController : Controller throw new UnauthorizedAccessException(); } - var taxInfo = await _subscriberService.GetTaxInformationAsync(user); + var taxInfo = await _paymentService.GetTaxInfoAsync(user); return new TaxInfoResponseModel(taxInfo); } diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index f3718ab10..f11ea4c34 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -304,7 +304,7 @@ public class OrganizationsController( throw new NotFoundException(); } - var taxInfo = await subscriberService.GetTaxInformationAsync(organization); + var taxInfo = await paymentService.GetTaxInfoAsync(organization); return new TaxInfoResponseModel(taxInfo); } diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index 3bc932fc4..42df02c67 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -1,10 +1,17 @@ -using Bit.Api.Billing.Models.Responses; +using Bit.Api.Billing.Models.Requests; +using Bit.Api.Billing.Models.Responses; using Bit.Core; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Models; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Stripe; namespace Bit.Api.Billing.Controllers; @@ -13,59 +20,194 @@ namespace Bit.Api.Billing.Controllers; public class ProviderBillingController( ICurrentContext currentContext, IFeatureService featureService, - IProviderBillingService providerBillingService) : Controller + IProviderBillingService providerBillingService, + IProviderRepository providerRepository, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService) : Controller { - [HttpGet("subscription")] - public async Task GetSubscriptionAsync([FromRoute] Guid providerId) - { - if (!featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)) - { - return TypedResults.NotFound(); - } - - if (!currentContext.ProviderProviderAdmin(providerId)) - { - return TypedResults.Unauthorized(); - } - - var providerSubscriptionDTO = await providerBillingService.GetSubscriptionDTO(providerId); - - if (providerSubscriptionDTO == null) - { - return TypedResults.NotFound(); - } - - var (providerPlans, subscription) = providerSubscriptionDTO; - - var providerSubscriptionResponse = ProviderSubscriptionResponse.From(providerPlans, subscription); - - return TypedResults.Ok(providerSubscriptionResponse); - } - [HttpGet("payment-information")] public async Task GetPaymentInformationAsync([FromRoute] Guid providerId) { - if (!featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)) + var (provider, result) = await GetAuthorizedBillableProviderOrResultAsync(providerId); + + if (provider == null) + { + return result; + } + + var paymentInformation = await subscriberService.GetPaymentInformation(provider); + + if (paymentInformation == null) { return TypedResults.NotFound(); } + var response = PaymentInformationResponse.From(paymentInformation); + + return TypedResults.Ok(response); + } + + [HttpGet("payment-method")] + public async Task GetPaymentMethodAsync([FromRoute] Guid providerId) + { + var (provider, result) = await GetAuthorizedBillableProviderOrResultAsync(providerId); + + if (provider == null) + { + return result; + } + + var maskedPaymentMethod = await subscriberService.GetPaymentMethod(provider); + + if (maskedPaymentMethod == null) + { + return TypedResults.NotFound(); + } + + var response = MaskedPaymentMethodResponse.From(maskedPaymentMethod); + + return TypedResults.Ok(response); + } + + [HttpPut("payment-method")] + public async Task UpdatePaymentMethodAsync( + [FromRoute] Guid providerId, + [FromBody] TokenizedPaymentMethodRequestBody requestBody) + { + var (provider, result) = await GetAuthorizedBillableProviderOrResultAsync(providerId); + + if (provider == null) + { + return result; + } + + var tokenizedPaymentMethod = new TokenizedPaymentMethodDTO( + requestBody.Type, + requestBody.Token); + + await subscriberService.UpdatePaymentMethod(provider, tokenizedPaymentMethod); + + await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, + new SubscriptionUpdateOptions + { + CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically + }); + + return TypedResults.Ok(); + } + + [HttpPost] + [Route("payment-method/verify-bank-account")] + public async Task VerifyBankAccountAsync( + [FromRoute] Guid providerId, + [FromBody] VerifyBankAccountRequestBody requestBody) + { + var (provider, result) = await GetAuthorizedBillableProviderOrResultAsync(providerId); + + if (provider == null) + { + return result; + } + + await subscriberService.VerifyBankAccount(provider, (requestBody.Amount1, requestBody.Amount2)); + + return TypedResults.Ok(); + } + + [HttpGet("subscription")] + public async Task GetSubscriptionAsync([FromRoute] Guid providerId) + { + var (provider, result) = await GetAuthorizedBillableProviderOrResultAsync(providerId); + + if (provider == null) + { + return result; + } + + var consolidatedBillingSubscription = await providerBillingService.GetConsolidatedBillingSubscription(provider); + + if (consolidatedBillingSubscription == null) + { + return TypedResults.NotFound(); + } + + var response = ConsolidatedBillingSubscriptionResponse.From(consolidatedBillingSubscription); + + return TypedResults.Ok(response); + } + + [HttpGet("tax-information")] + public async Task GetTaxInformationAsync([FromRoute] Guid providerId) + { + var (provider, result) = await GetAuthorizedBillableProviderOrResultAsync(providerId); + + if (provider == null) + { + return result; + } + + var taxInformation = await subscriberService.GetTaxInformation(provider); + + if (taxInformation == null) + { + return TypedResults.NotFound(); + } + + var response = TaxInformationResponse.From(taxInformation); + + return TypedResults.Ok(response); + } + + [HttpPut("tax-information")] + public async Task UpdateTaxInformationAsync( + [FromRoute] Guid providerId, + [FromBody] TaxInformationRequestBody requestBody) + { + var (provider, result) = await GetAuthorizedBillableProviderOrResultAsync(providerId); + + if (provider == null) + { + return result; + } + + var taxInformation = new TaxInformationDTO( + requestBody.Country, + requestBody.PostalCode, + requestBody.TaxId, + requestBody.Line1, + requestBody.Line2, + requestBody.City, + requestBody.State); + + await subscriberService.UpdateTaxInformation(provider, taxInformation); + + return TypedResults.Ok(); + } + + private async Task<(Provider, IResult)> GetAuthorizedBillableProviderOrResultAsync(Guid providerId) + { + if (!featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling)) + { + return (null, TypedResults.NotFound()); + } + + var provider = await providerRepository.GetByIdAsync(providerId); + + if (provider == null) + { + return (null, TypedResults.NotFound()); + } + if (!currentContext.ProviderProviderAdmin(providerId)) { - return TypedResults.Unauthorized(); + return (null, TypedResults.Unauthorized()); } - var providerPaymentInformationDto = await providerBillingService.GetPaymentInformationAsync(providerId); - - if (providerPaymentInformationDto == null) + if (!provider.IsBillable()) { - return TypedResults.NotFound(); + return (null, TypedResults.Unauthorized()); } - var (paymentSource, taxInfo) = providerPaymentInformationDto; - - var providerPaymentInformationResponse = PaymentInformationResponse.From(paymentSource, taxInfo); - - return TypedResults.Ok(providerPaymentInformationResponse); + return (provider, null); } } diff --git a/src/Api/Billing/Controllers/StripeController.cs b/src/Api/Billing/Controllers/StripeController.cs new file mode 100644 index 000000000..a4a974bb9 --- /dev/null +++ b/src/Api/Billing/Controllers/StripeController.cs @@ -0,0 +1,49 @@ +using Bit.Core.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Stripe; + +namespace Bit.Api.Billing.Controllers; + +[Authorize("Application")] +public class StripeController( + IStripeAdapter stripeAdapter) : Controller +{ + [HttpPost] + [Route("~/setup-intent/bank-account")] + public async Task> CreateSetupIntentForBankAccountAsync() + { + var options = new SetupIntentCreateOptions + { + PaymentMethodOptions = new SetupIntentPaymentMethodOptionsOptions + { + UsBankAccount = new SetupIntentPaymentMethodOptionsUsBankAccountOptions + { + VerificationMethod = "microdeposits" + } + }, + PaymentMethodTypes = ["us_bank_account"], + Usage = "off_session" + }; + + var setupIntent = await stripeAdapter.SetupIntentCreate(options); + + return TypedResults.Ok(setupIntent.ClientSecret); + } + + [HttpPost] + [Route("~/setup-intent/card")] + public async Task> CreateSetupIntentForCardAsync() + { + var options = new SetupIntentCreateOptions + { + PaymentMethodTypes = ["card"], + Usage = "off_session" + }; + + var setupIntent = await stripeAdapter.SetupIntentCreate(options); + + return TypedResults.Ok(setupIntent.ClientSecret); + } +} diff --git a/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs new file mode 100644 index 000000000..86b4b79cb --- /dev/null +++ b/src/Api/Billing/Models/Requests/TaxInformationRequestBody.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Billing.Models.Requests; + +public class TaxInformationRequestBody +{ + [Required] + public string Country { get; set; } + [Required] + public string PostalCode { get; set; } + public string TaxId { get; set; } + public string Line1 { get; set; } + public string Line2 { get; set; } + public string City { get; set; } + public string State { get; set; } +} diff --git a/src/Api/Billing/Models/Requests/TokenizedPaymentMethodRequestBody.cs b/src/Api/Billing/Models/Requests/TokenizedPaymentMethodRequestBody.cs new file mode 100644 index 000000000..edb4e6b36 --- /dev/null +++ b/src/Api/Billing/Models/Requests/TokenizedPaymentMethodRequestBody.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Utilities; +using Bit.Core.Enums; + +namespace Bit.Api.Billing.Models.Requests; + +public class TokenizedPaymentMethodRequestBody +{ + [Required] + [EnumMatches( + PaymentMethodType.BankAccount, + PaymentMethodType.Card, + PaymentMethodType.PayPal, + ErrorMessage = "'type' must be BankAccount, Card or PayPal")] + public PaymentMethodType Type { get; set; } + [Required] + public string Token { get; set; } +} diff --git a/src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs b/src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs new file mode 100644 index 000000000..de98755f3 --- /dev/null +++ b/src/Api/Billing/Models/Requests/VerifyBankAccountRequestBody.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Billing.Models.Requests; + +public class VerifyBankAccountRequestBody +{ + [Range(0, 99)] + public long Amount1 { get; set; } + [Range(0, 99)] + public long Amount2 { get; set; } +} diff --git a/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs b/src/Api/Billing/Models/Responses/ConsolidatedBillingSubscriptionResponse.cs similarity index 72% rename from src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs rename to src/Api/Billing/Models/Responses/ConsolidatedBillingSubscriptionResponse.cs index 51ab67129..b9f761b36 100644 --- a/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs +++ b/src/Api/Billing/Models/Responses/ConsolidatedBillingSubscriptionResponse.cs @@ -1,29 +1,29 @@ using Bit.Core.Billing.Models; using Bit.Core.Utilities; -using Stripe; namespace Bit.Api.Billing.Models.Responses; -public record ProviderSubscriptionResponse( +public record ConsolidatedBillingSubscriptionResponse( string Status, DateTime CurrentPeriodEndDate, decimal? DiscountPercentage, - IEnumerable Plans) + IEnumerable Plans) { private const string _annualCadence = "Annual"; private const string _monthlyCadence = "Monthly"; - public static ProviderSubscriptionResponse From( - IEnumerable providerPlans, - Subscription subscription) + public static ConsolidatedBillingSubscriptionResponse From( + ConsolidatedBillingSubscriptionDTO consolidatedBillingSubscription) { + var (providerPlans, subscription) = consolidatedBillingSubscription; + var providerPlansDTO = providerPlans .Select(providerPlan => { var plan = StaticStore.GetPlan(providerPlan.PlanType); var cost = (providerPlan.SeatMinimum + providerPlan.PurchasedSeats) * plan.PasswordManager.SeatPrice; var cadence = plan.IsAnnual ? _annualCadence : _monthlyCadence; - return new ProviderPlanDTO( + return new ProviderPlanResponse( plan.Name, providerPlan.SeatMinimum, providerPlan.PurchasedSeats, @@ -32,7 +32,7 @@ public record ProviderSubscriptionResponse( cadence); }); - return new ProviderSubscriptionResponse( + return new ConsolidatedBillingSubscriptionResponse( subscription.Status, subscription.CurrentPeriodEnd, subscription.Customer?.Discount?.Coupon?.PercentOff, @@ -40,7 +40,7 @@ public record ProviderSubscriptionResponse( } } -public record ProviderPlanDTO( +public record ProviderPlanResponse( string PlanName, int SeatMinimum, int PurchasedSeats, diff --git a/src/Api/Billing/Models/Responses/MaskedPaymentMethodResponse.cs b/src/Api/Billing/Models/Responses/MaskedPaymentMethodResponse.cs new file mode 100644 index 000000000..36cf6969e --- /dev/null +++ b/src/Api/Billing/Models/Responses/MaskedPaymentMethodResponse.cs @@ -0,0 +1,16 @@ +using Bit.Core.Billing.Models; +using Bit.Core.Enums; + +namespace Bit.Api.Billing.Models.Responses; + +public record MaskedPaymentMethodResponse( + PaymentMethodType Type, + string Description, + bool NeedsVerification) +{ + public static MaskedPaymentMethodResponse From(MaskedPaymentMethodDTO maskedPaymentMethod) + => new( + maskedPaymentMethod.Type, + maskedPaymentMethod.Description, + maskedPaymentMethod.NeedsVerification); +} diff --git a/src/Api/Billing/Models/Responses/PaymentInformationResponse.cs b/src/Api/Billing/Models/Responses/PaymentInformationResponse.cs index 6d6088e99..8e532d845 100644 --- a/src/Api/Billing/Models/Responses/PaymentInformationResponse.cs +++ b/src/Api/Billing/Models/Responses/PaymentInformationResponse.cs @@ -1,37 +1,15 @@ -using Bit.Core.Enums; -using Bit.Core.Models.Business; +using Bit.Core.Billing.Models; namespace Bit.Api.Billing.Models.Responses; -public record PaymentInformationResponse(PaymentMethod PaymentMethod, TaxInformation TaxInformation) +public record PaymentInformationResponse( + long AccountCredit, + MaskedPaymentMethodDTO PaymentMethod, + TaxInformationDTO TaxInformation) { - public static PaymentInformationResponse From(BillingInfo.BillingSource billingSource, TaxInfo taxInfo) - { - var paymentMethodDto = new PaymentMethod( - billingSource.Type, billingSource.Description, billingSource.CardBrand - ); - - var taxInformationDto = new TaxInformation( - taxInfo.BillingAddressCountry, taxInfo.BillingAddressPostalCode, taxInfo.TaxIdNumber, - taxInfo.BillingAddressLine1, taxInfo.BillingAddressLine2, taxInfo.BillingAddressCity, - taxInfo.BillingAddressState - ); - - return new PaymentInformationResponse(paymentMethodDto, taxInformationDto); - } - + public static PaymentInformationResponse From(PaymentInformationDTO paymentInformation) => + new( + paymentInformation.AccountCredit, + paymentInformation.PaymentMethod, + paymentInformation.TaxInformation); } - -public record PaymentMethod( - PaymentMethodType Type, - string Description, - string CardBrand); - -public record TaxInformation( - string Country, - string PostalCode, - string TaxId, - string Line1, - string Line2, - string City, - string State); diff --git a/src/Api/Billing/Models/Responses/TaxInformationResponse.cs b/src/Api/Billing/Models/Responses/TaxInformationResponse.cs new file mode 100644 index 000000000..53e2de19d --- /dev/null +++ b/src/Api/Billing/Models/Responses/TaxInformationResponse.cs @@ -0,0 +1,23 @@ +using Bit.Core.Billing.Models; + +namespace Bit.Api.Billing.Models.Responses; + +public record TaxInformationResponse( + string Country, + string PostalCode, + string TaxId, + string Line1, + string Line2, + string City, + string State) +{ + public static TaxInformationResponse From(TaxInformationDTO taxInformation) + => new( + taxInformation.Country, + taxInformation.PostalCode, + taxInformation.TaxId, + taxInformation.Line1, + taxInformation.Line2, + taxInformation.City, + taxInformation.State); +} diff --git a/src/Api/Models/Request/BitPayInvoiceRequestModel.cs b/src/Api/Models/Request/BitPayInvoiceRequestModel.cs index ba800cafd..66a5931ca 100644 --- a/src/Api/Models/Request/BitPayInvoiceRequestModel.cs +++ b/src/Api/Models/Request/BitPayInvoiceRequestModel.cs @@ -7,6 +7,7 @@ public class BitPayInvoiceRequestModel : IValidatableObject { public Guid? UserId { get; set; } public Guid? OrganizationId { get; set; } + public Guid? ProviderId { get; set; } public bool Credit { get; set; } [Required] public decimal? Amount { get; set; } @@ -40,6 +41,10 @@ public class BitPayInvoiceRequestModel : IValidatableObject { posData = "organizationId:" + OrganizationId.Value; } + else if (ProviderId.HasValue) + { + posData = "providerId:" + ProviderId.Value; + } if (Credit) { @@ -57,9 +62,9 @@ public class BitPayInvoiceRequestModel : IValidatableObject public IEnumerable Validate(ValidationContext validationContext) { - if (!UserId.HasValue && !OrganizationId.HasValue) + if (!UserId.HasValue && !OrganizationId.HasValue && !ProviderId.HasValue) { - yield return new ValidationResult("User or Organization is required."); + yield return new ValidationResult("User, Organization or Provider is required."); } } } diff --git a/src/Core/Billing/Caches/ISetupIntentCache.cs b/src/Core/Billing/Caches/ISetupIntentCache.cs new file mode 100644 index 000000000..099026623 --- /dev/null +++ b/src/Core/Billing/Caches/ISetupIntentCache.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.Billing.Caches; + +public interface ISetupIntentCache +{ + Task Get(Guid subscriberId); + + Task Remove(Guid subscriberId); + + Task Set(Guid subscriberId, string setupIntentId); +} diff --git a/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs b/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs new file mode 100644 index 000000000..ceb512a0e --- /dev/null +++ b/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; + +namespace Bit.Core.Billing.Caches.Implementations; + +public class SetupIntentDistributedCache( + [FromKeyedServices("persistent")] + IDistributedCache distributedCache) : ISetupIntentCache +{ + public async Task Get(Guid subscriberId) + { + var cacheKey = GetCacheKey(subscriberId); + + return await distributedCache.GetStringAsync(cacheKey); + } + + public async Task Remove(Guid subscriberId) + { + var cacheKey = GetCacheKey(subscriberId); + + await distributedCache.RemoveAsync(cacheKey); + } + + public async Task Set(Guid subscriberId, string setupIntentId) + { + var cacheKey = GetCacheKey(subscriberId); + + await distributedCache.SetStringAsync(cacheKey, setupIntentId); + } + + private static string GetCacheKey(Guid subscriberId) => $"pending_bank_account_{subscriberId}"; +} diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 71efc8a71..aa5737e3d 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -21,6 +21,12 @@ public static class StripeConstants public const string SecretsManagerStandalone = "sm-standalone"; } + public static class PaymentMethodTypes + { + public const string Card = "card"; + public const string USBankAccount = "us_bank_account"; + } + public static class ProrationBehavior { public const string AlwaysInvoice = "always_invoice"; diff --git a/src/Core/Billing/Extensions/BillingExtensions.cs b/src/Core/Billing/Extensions/BillingExtensions.cs index f0ee8989c..1a5665224 100644 --- a/src/Core/Billing/Extensions/BillingExtensions.cs +++ b/src/Core/Billing/Extensions/BillingExtensions.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Enums; +using Stripe; namespace Bit.Core.Billing.Extensions; @@ -26,6 +27,20 @@ public static class BillingExtensions => !string.IsNullOrEmpty(organization.GatewayCustomerId) && !string.IsNullOrEmpty(organization.GatewaySubscriptionId); + public static bool IsUnverifiedBankAccount(this SetupIntent setupIntent) => + setupIntent is + { + Status: "requires_action", + NextAction: + { + VerifyWithMicrodeposits: not null + }, + PaymentMethod: + { + UsBankAccount: not null + } + }; + public static bool SupportsConsolidatedBilling(this PlanType planType) => planType is PlanType.TeamsMonthly or PlanType.EnterpriseMonthly; } diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index d225193e7..ffe5cc3ed 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -1,4 +1,6 @@ -using Bit.Core.Billing.Services; +using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Caches.Implementations; +using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; namespace Bit.Core.Billing.Extensions; @@ -10,6 +12,7 @@ public static class ServiceCollectionExtensions public static void AddBillingOperations(this IServiceCollection services) { services.AddTransient(); + services.AddTransient(); services.AddTransient(); } } diff --git a/src/Core/Billing/Models/ProviderSubscriptionDTO.cs b/src/Core/Billing/Models/ConsolidatedBillingSubscriptionDTO.cs similarity index 73% rename from src/Core/Billing/Models/ProviderSubscriptionDTO.cs rename to src/Core/Billing/Models/ConsolidatedBillingSubscriptionDTO.cs index 557a6b359..1ebd264df 100644 --- a/src/Core/Billing/Models/ProviderSubscriptionDTO.cs +++ b/src/Core/Billing/Models/ConsolidatedBillingSubscriptionDTO.cs @@ -2,6 +2,6 @@ namespace Bit.Core.Billing.Models; -public record ProviderSubscriptionDTO( +public record ConsolidatedBillingSubscriptionDTO( List ProviderPlans, Subscription Subscription); diff --git a/src/Core/Billing/Models/MaskedPaymentMethodDTO.cs b/src/Core/Billing/Models/MaskedPaymentMethodDTO.cs new file mode 100644 index 000000000..4a234ecc7 --- /dev/null +++ b/src/Core/Billing/Models/MaskedPaymentMethodDTO.cs @@ -0,0 +1,156 @@ +using Bit.Core.Billing.Extensions; +using Bit.Core.Enums; + +namespace Bit.Core.Billing.Models; + +public record MaskedPaymentMethodDTO( + PaymentMethodType Type, + string Description, + bool NeedsVerification) +{ + public static MaskedPaymentMethodDTO From(Stripe.Customer customer) + { + var defaultPaymentMethod = customer.InvoiceSettings?.DefaultPaymentMethod; + + if (defaultPaymentMethod == null) + { + return customer.DefaultSource != null ? FromStripeLegacyPaymentSource(customer.DefaultSource) : null; + } + + return defaultPaymentMethod.Type switch + { + "card" => FromStripeCardPaymentMethod(defaultPaymentMethod.Card), + "us_bank_account" => FromStripeBankAccountPaymentMethod(defaultPaymentMethod.UsBankAccount), + _ => null + }; + } + + public static MaskedPaymentMethodDTO From(Stripe.SetupIntent setupIntent) + { + if (!setupIntent.IsUnverifiedBankAccount()) + { + return null; + } + + var bankAccount = setupIntent.PaymentMethod.UsBankAccount; + + var description = $"{bankAccount.BankName}, *{bankAccount.Last4}"; + + return new MaskedPaymentMethodDTO( + PaymentMethodType.BankAccount, + description, + true); + } + + public static MaskedPaymentMethodDTO From(Braintree.Customer customer) + { + var defaultPaymentMethod = customer.DefaultPaymentMethod; + + if (defaultPaymentMethod == null) + { + return null; + } + + switch (defaultPaymentMethod) + { + case Braintree.PayPalAccount payPalAccount: + { + return new MaskedPaymentMethodDTO( + PaymentMethodType.PayPal, + payPalAccount.Email, + false); + } + case Braintree.CreditCard creditCard: + { + var paddedExpirationMonth = creditCard.ExpirationMonth.PadLeft(2, '0'); + + var description = + $"{creditCard.CardType}, *{creditCard.LastFour}, {paddedExpirationMonth}/{creditCard.ExpirationYear}"; + + return new MaskedPaymentMethodDTO( + PaymentMethodType.Card, + description, + false); + } + case Braintree.UsBankAccount bankAccount: + { + return new MaskedPaymentMethodDTO( + PaymentMethodType.BankAccount, + $"{bankAccount.BankName}, *{bankAccount.Last4}", + false); + } + default: + { + return null; + } + } + } + + private static MaskedPaymentMethodDTO FromStripeBankAccountPaymentMethod( + Stripe.PaymentMethodUsBankAccount bankAccount) + { + var description = $"{bankAccount.BankName}, *{bankAccount.Last4}"; + + return new MaskedPaymentMethodDTO( + PaymentMethodType.BankAccount, + description, + false); + } + + private static MaskedPaymentMethodDTO FromStripeCardPaymentMethod(Stripe.PaymentMethodCard card) + => new( + PaymentMethodType.Card, + GetCardDescription(card.Brand, card.Last4, card.ExpMonth, card.ExpYear), + false); + + #region Legacy Source Payments + + private static MaskedPaymentMethodDTO FromStripeLegacyPaymentSource(Stripe.IPaymentSource paymentSource) + => paymentSource switch + { + Stripe.BankAccount bankAccount => FromStripeBankAccountLegacySource(bankAccount), + Stripe.Card card => FromStripeCardLegacySource(card), + Stripe.Source { Card: not null } source => FromStripeSourceCardLegacySource(source.Card), + _ => null + }; + + private static MaskedPaymentMethodDTO FromStripeBankAccountLegacySource(Stripe.BankAccount bankAccount) + { + var status = bankAccount.Status switch + { + "verified" => "Verified", + "errored" => "Invalid", + "verification_failed" => "Verification failed", + _ => "Unverified" + }; + + var description = $"{bankAccount.BankName}, *{bankAccount.Last4} - {status}"; + + var needsVerification = bankAccount.Status is "new" or "validated"; + + return new MaskedPaymentMethodDTO( + PaymentMethodType.BankAccount, + description, + needsVerification); + } + + private static MaskedPaymentMethodDTO FromStripeCardLegacySource(Stripe.Card card) + => new( + PaymentMethodType.Card, + GetCardDescription(card.Brand, card.Last4, card.ExpMonth, card.ExpYear), + false); + + private static MaskedPaymentMethodDTO FromStripeSourceCardLegacySource(Stripe.SourceCard card) + => new( + PaymentMethodType.Card, + GetCardDescription(card.Brand, card.Last4, card.ExpMonth, card.ExpYear), + false); + + #endregion + + private static string GetCardDescription( + string brand, + string last4, + long expirationMonth, + long expirationYear) => $"{brand.ToUpperInvariant()}, *{last4}, {expirationMonth:00}/{expirationYear}"; +} diff --git a/src/Core/Billing/Models/PaymentInformationDTO.cs b/src/Core/Billing/Models/PaymentInformationDTO.cs new file mode 100644 index 000000000..fe3195b3e --- /dev/null +++ b/src/Core/Billing/Models/PaymentInformationDTO.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Billing.Models; + +public record PaymentInformationDTO( + long AccountCredit, + MaskedPaymentMethodDTO PaymentMethod, + TaxInformationDTO TaxInformation); diff --git a/src/Core/Billing/Models/ProviderPaymentInfoDTO.cs b/src/Core/Billing/Models/ProviderPaymentInfoDTO.cs deleted file mode 100644 index 810fae9a5..000000000 --- a/src/Core/Billing/Models/ProviderPaymentInfoDTO.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Bit.Core.Models.Business; - -namespace Bit.Core.Billing.Models; - -public record ProviderPaymentInfoDTO(BillingInfo.BillingSource billingSource, - TaxInfo taxInfo); diff --git a/src/Core/Billing/Models/TaxInformationDTO.cs b/src/Core/Billing/Models/TaxInformationDTO.cs new file mode 100644 index 000000000..a5243b9ea --- /dev/null +++ b/src/Core/Billing/Models/TaxInformationDTO.cs @@ -0,0 +1,149 @@ +namespace Bit.Core.Billing.Models; + +public record TaxInformationDTO( + string Country, + string PostalCode, + string TaxId, + string Line1, + string Line2, + string City, + string State) +{ + public string GetTaxIdType() + { + if (string.IsNullOrEmpty(Country) || string.IsNullOrEmpty(TaxId)) + { + return null; + } + + switch (Country.ToUpper()) + { + case "AD": + return "ad_nrt"; + case "AE": + return "ae_trn"; + case "AR": + return "ar_cuit"; + case "AU": + return "au_abn"; + case "BO": + return "bo_tin"; + case "BR": + return "br_cnpj"; + case "CA": + // May break for those in Québec given the assumption of QST + if (State?.Contains("bec") ?? false) + { + return "ca_qst"; + } + return "ca_bn"; + case "CH": + return "ch_vat"; + case "CL": + return "cl_tin"; + case "CN": + return "cn_tin"; + case "CO": + return "co_nit"; + case "CR": + return "cr_tin"; + case "DO": + return "do_rcn"; + case "EC": + return "ec_ruc"; + case "EG": + return "eg_tin"; + case "GE": + return "ge_vat"; + case "ID": + return "id_npwp"; + case "IL": + return "il_vat"; + case "IS": + return "is_vat"; + case "KE": + return "ke_pin"; + case "AT": + case "BE": + case "BG": + case "CY": + case "CZ": + case "DE": + case "DK": + case "EE": + case "ES": + case "FI": + case "FR": + case "GB": + case "GR": + case "HR": + case "HU": + case "IE": + case "IT": + case "LT": + case "LU": + case "LV": + case "MT": + case "NL": + case "PL": + case "PT": + case "RO": + case "SE": + case "SI": + case "SK": + return "eu_vat"; + case "HK": + return "hk_br"; + case "IN": + return "in_gst"; + case "JP": + return "jp_cn"; + case "KR": + return "kr_brn"; + case "LI": + return "li_uid"; + case "MX": + return "mx_rfc"; + case "MY": + return "my_sst"; + case "NO": + return "no_vat"; + case "NZ": + return "nz_gst"; + case "PE": + return "pe_ruc"; + case "PH": + return "ph_tin"; + case "RS": + return "rs_pib"; + case "RU": + return "ru_inn"; + case "SA": + return "sa_vat"; + case "SG": + return "sg_gst"; + case "SV": + return "sv_nit"; + case "TH": + return "th_vat"; + case "TR": + return "tr_tin"; + case "TW": + return "tw_vat"; + case "UA": + return "ua_vat"; + case "US": + return "us_ein"; + case "UY": + return "uy_ruc"; + case "VE": + return "ve_rif"; + case "VN": + return "vn_tin"; + case "ZA": + return "za_vat"; + default: + return null; + } + } +} diff --git a/src/Core/Billing/Models/TokenizedPaymentMethodDTO.cs b/src/Core/Billing/Models/TokenizedPaymentMethodDTO.cs new file mode 100644 index 000000000..58d615c63 --- /dev/null +++ b/src/Core/Billing/Models/TokenizedPaymentMethodDTO.cs @@ -0,0 +1,7 @@ +using Bit.Core.Enums; + +namespace Bit.Core.Billing.Models; + +public record TokenizedPaymentMethodDTO( + PaymentMethodType Type, + string Token); diff --git a/src/Core/Billing/Services/IProviderBillingService.cs b/src/Core/Billing/Services/IProviderBillingService.cs index 6ff1fbf0f..76c08241b 100644 --- a/src/Core/Billing/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Services/IProviderBillingService.cs @@ -56,13 +56,13 @@ public interface IProviderBillingService PlanType planType); /// - /// Retrieves a provider's billing subscription data. + /// Retrieves the 's consolidated billing subscription, which includes their Stripe subscription and configured provider plans. /// - /// The ID of the provider to retrieve subscription data for. - /// A object containing the provider's Stripe and their s. + /// The provider to retrieve the consolidated billing subscription for. + /// A containing the provider's Stripe and a list of s representing their configured plans. /// This method opts for returning rather than throwing exceptions, making it ideal for surfacing data from API endpoints. - Task GetSubscriptionDTO( - Guid providerId); + Task GetConsolidatedBillingSubscription( + Provider provider); /// /// Scales the 's seats for the specified using the provided . @@ -85,12 +85,4 @@ public interface IProviderBillingService /// The provider to create the for. Task StartSubscription( Provider provider); - - /// - /// Retrieves a provider's billing payment information. - /// - /// The ID of the provider to retrieve payment information for. - /// A object containing the provider's Stripe and their s. - /// This method opts for returning rather than throwing exceptions, making it ideal for surfacing data from API endpoints. - Task GetPaymentInformationAsync(Guid providerId); } diff --git a/src/Core/Billing/Services/ISubscriberService.cs b/src/Core/Billing/Services/ISubscriberService.cs index dd825e39c..761e5a00d 100644 --- a/src/Core/Billing/Services/ISubscriberService.cs +++ b/src/Core/Billing/Services/ISubscriberService.cs @@ -1,6 +1,6 @@ using Bit.Core.Billing.Models; using Bit.Core.Entities; -using Bit.Core.Models.Business; +using Bit.Core.Enums; using Stripe; namespace Bit.Core.Billing.Services; @@ -46,6 +46,24 @@ public interface ISubscriberService ISubscriber subscriber, CustomerGetOptions customerGetOptions = null); + /// + /// Retrieves the account credit, a masked representation of the default payment method and the tax information for the + /// provided . This is essentially a consolidated invocation of the + /// and methods with a response that includes the customer's as account credit in order to cut down on Stripe API calls. + /// + /// The subscriber to retrieve payment information for. + /// A containing the subscriber's account credit, masked payment method and tax information. + Task GetPaymentInformation( + ISubscriber subscriber); + + /// + /// Retrieves a masked representation of the subscriber's payment method for presentation to a client. + /// + /// The subscriber to retrieve the masked payment method for. + /// A containing a non-identifiable description of the subscriber's payment method. + Task GetPaymentMethod( + ISubscriber subscriber); + /// /// Retrieves a Stripe using the 's property. /// @@ -71,6 +89,16 @@ public interface ISubscriberService ISubscriber subscriber, SubscriptionGetOptions subscriptionGetOptions = null); + /// + /// Retrieves the 's tax information using their Stripe 's . + /// + /// The subscriber to retrieve the tax information for. + /// A representing the 's tax information. + /// Thrown when the is . + /// This method opts for returning rather than throwing exceptions, making it ideal for surfacing data from API endpoints. + Task GetTaxInformation( + ISubscriber subscriber); + /// /// Attempts to remove a subscriber's saved payment method. If the Stripe representing the /// contains a valid "btCustomerId" key in its property, @@ -81,20 +109,34 @@ public interface ISubscriberService Task RemovePaymentMethod(ISubscriber subscriber); /// - /// Retrieves a Stripe using the 's property. + /// Updates the payment method for the provided using the . + /// The following payment method types are supported: [, , ]. + /// For each type, updating the payment method will attempt to establish a new payment method using the token in the . Then, it will + /// remove the exising payment method(s) linked to the subscriber's customer. /// - /// The subscriber to retrieve the Stripe customer for. - /// A Stripe . - /// Thrown when the is . - /// This method opts for returning rather than throwing exceptions, making it ideal for surfacing data from API endpoints. - Task GetTaxInformationAsync(ISubscriber subscriber); + /// The subscriber to update the payment method for. + /// A DTO representing a tokenized payment method. + Task UpdatePaymentMethod( + ISubscriber subscriber, + TokenizedPaymentMethodDTO tokenizedPaymentMethod); /// - /// Retrieves a Stripe using the 's property. + /// Updates the tax information for the provided . /// - /// The subscriber to retrieve the Stripe customer for. - /// A Stripe . - /// Thrown when the is . - /// This method opts for returning rather than throwing exceptions, making it ideal for surfacing data from API endpoints. - Task GetPaymentMethodAsync(ISubscriber subscriber); + /// The to update the tax information for. + /// A representing the 's updated tax information. + Task UpdateTaxInformation( + ISubscriber subscriber, + TaxInformationDTO taxInformation); + + /// + /// Verifies the subscriber's pending bank account using the provided . + /// + /// The subscriber to verify the bank account for. + /// Deposits made to the subscriber's bank account in order to ensure they have access to it. + /// Learn more. + /// + Task VerifyBankAccount( + ISubscriber subscriber, + (long, long) microdeposits); } diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 5cf21b1f4..34ae4e406 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -1,7 +1,10 @@ -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Models; using Bit.Core.Entities; -using Bit.Core.Models.Business; +using Bit.Core.Enums; using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; using Braintree; using Microsoft.Extensions.Logging; using Stripe; @@ -14,7 +17,9 @@ namespace Bit.Core.Billing.Services.Implementations; public class SubscriberService( IBraintreeGateway braintreeGateway, + IGlobalSettings globalSettings, ILogger logger, + ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter) : ISubscriberService { public async Task CancelSubscription( @@ -132,6 +137,46 @@ public class SubscriberService( } } + public async Task GetPaymentInformation( + ISubscriber subscriber) + { + ArgumentNullException.ThrowIfNull(subscriber); + + var customer = await GetCustomer(subscriber, new CustomerGetOptions + { + Expand = ["default_source", "invoice_settings.default_payment_method", "tax_ids"] + }); + + if (customer == null) + { + return null; + } + + var accountCredit = customer.Balance * -1 / 100; + + var paymentMethod = await GetMaskedPaymentMethodDTOAsync(subscriber.Id, customer); + + var taxInformation = GetTaxInformationDTOFrom(customer); + + return new PaymentInformationDTO( + accountCredit, + paymentMethod, + taxInformation); + } + + public async Task GetPaymentMethod( + ISubscriber subscriber) + { + ArgumentNullException.ThrowIfNull(subscriber); + + var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions + { + Expand = ["default_source", "invoice_settings.default_payment_method"] + }); + + return await GetMaskedPaymentMethodDTOAsync(subscriber.Id, customer); + } + public async Task GetCustomerOrThrow( ISubscriber subscriber, CustomerGetOptions customerGetOptions = null) @@ -240,6 +285,16 @@ public class SubscriberService( } } + public async Task GetTaxInformation( + ISubscriber subscriber) + { + ArgumentNullException.ThrowIfNull(subscriber); + + var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions { Expand = ["tax_ids"] }); + + return GetTaxInformationDTOFrom(customer); + } + public async Task RemovePaymentMethod( ISubscriber subscriber) { @@ -332,113 +387,438 @@ public class SubscriberService( } } - public async Task GetTaxInformationAsync(ISubscriber subscriber) + public async Task UpdatePaymentMethod( + ISubscriber subscriber, + TokenizedPaymentMethodDTO tokenizedPaymentMethod) { ArgumentNullException.ThrowIfNull(subscriber); + ArgumentNullException.ThrowIfNull(tokenizedPaymentMethod); - if (string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) + var customer = await GetCustomerOrThrow(subscriber); + + var (type, token) = tokenizedPaymentMethod; + + if (string.IsNullOrEmpty(token)) { - logger.LogError("Cannot retrieve GatewayCustomerId for subscriber ({SubscriberID}) with no {FieldName}", subscriber.Id, nameof(subscriber.GatewaySubscriptionId)); + logger.LogError("Updated payment method for ({SubscriberID}) must contain a token", subscriber.Id); - return null; + throw ContactSupport(); } - var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions { Expand = ["tax_ids"] }); - - if (customer is null) + // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault + switch (type) { - logger.LogError("Could not find Stripe customer ({CustomerID}) for subscriber ({SubscriberID})", - subscriber.GatewayCustomerId, subscriber.Id); + case PaymentMethodType.BankAccount: + { + var getSetupIntentsForUpdatedPaymentMethod = stripeAdapter.SetupIntentList(new SetupIntentListOptions + { + PaymentMethod = token + }); - return null; + var getExistingSetupIntentsForCustomer = stripeAdapter.SetupIntentList(new SetupIntentListOptions + { + Customer = subscriber.GatewayCustomerId + }); + + // Find the setup intent for the incoming payment method token. + var setupIntentsForUpdatedPaymentMethod = await getSetupIntentsForUpdatedPaymentMethod; + + if (setupIntentsForUpdatedPaymentMethod.Count != 1) + { + logger.LogError("There were more than 1 setup intents for subscriber's ({SubscriberID}) updated payment method", subscriber.Id); + + throw ContactSupport(); + } + + var matchingSetupIntent = setupIntentsForUpdatedPaymentMethod.First(); + + // Find the customer's existing setup intents that should be cancelled. + var existingSetupIntentsForCustomer = (await getExistingSetupIntentsForCustomer) + .Where(si => + si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action"); + + // Store the incoming payment method's setup intent ID in the cache for the subscriber so it can be verified later. + await setupIntentCache.Set(subscriber.Id, matchingSetupIntent.Id); + + // Cancel the customer's other open setup intents. + var postProcessing = existingSetupIntentsForCustomer.Select(si => + stripeAdapter.SetupIntentCancel(si.Id, + new SetupIntentCancelOptions { CancellationReason = "abandoned" })).ToList(); + + // Remove the customer's other attached Stripe payment methods. + postProcessing.Add(RemoveStripePaymentMethodsAsync(customer)); + + // Remove the customer's Braintree customer ID. + postProcessing.Add(RemoveBraintreeCustomerIdAsync(customer)); + + await Task.WhenAll(postProcessing); + + break; + } + case PaymentMethodType.Card: + { + var getExistingSetupIntentsForCustomer = stripeAdapter.SetupIntentList(new SetupIntentListOptions + { + Customer = subscriber.GatewayCustomerId + }); + + // Remove the customer's other attached Stripe payment methods. + await RemoveStripePaymentMethodsAsync(customer); + + // Attach the incoming payment method. + await stripeAdapter.PaymentMethodAttachAsync(token, + new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId }); + + // Find the customer's existing setup intents that should be cancelled. + var existingSetupIntentsForCustomer = (await getExistingSetupIntentsForCustomer) + .Where(si => + si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action"); + + // Cancel the customer's other open setup intents. + var postProcessing = existingSetupIntentsForCustomer.Select(si => + stripeAdapter.SetupIntentCancel(si.Id, + new SetupIntentCancelOptions { CancellationReason = "abandoned" })).ToList(); + + var metadata = customer.Metadata; + + if (metadata.ContainsKey(BraintreeCustomerIdKey)) + { + metadata[BraintreeCustomerIdKey] = null; + } + + // Set the customer's default payment method in Stripe and remove their Braintree customer ID. + postProcessing.Add(stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions + { + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + DefaultPaymentMethod = token + }, + Metadata = metadata + })); + + await Task.WhenAll(postProcessing); + + break; + } + case PaymentMethodType.PayPal: + { + string braintreeCustomerId; + + if (customer.Metadata != null) + { + var hasBraintreeCustomerId = customer.Metadata.TryGetValue(BraintreeCustomerIdKey, out braintreeCustomerId); + + if (hasBraintreeCustomerId) + { + var braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId); + + if (braintreeCustomer == null) + { + logger.LogError("Failed to retrieve Braintree customer ({BraintreeCustomerId}) when updating payment method for subscriber ({SubscriberID})", braintreeCustomerId, subscriber.Id); + + throw ContactSupport(); + } + + await ReplaceBraintreePaymentMethodAsync(braintreeCustomer, token); + + return; + } + } + + braintreeCustomerId = await CreateBraintreeCustomerAsync(subscriber, token); + + await AddBraintreeCustomerIdAsync(customer, braintreeCustomerId); + + break; + } + default: + { + logger.LogError("Cannot update subscriber's ({SubscriberID}) payment method to type ({PaymentMethodType}) as it is not supported", subscriber.Id, type.ToString()); + + throw ContactSupport(); + } } - - var address = customer.Address; - - // Line1 is required, so if missing we're using the subscriber name - // see: https://stripe.com/docs/api/customers/create#create_customer-address-line1 - if (address is not null && string.IsNullOrWhiteSpace(address.Line1)) - { - address.Line1 = null; - } - - return MapToTaxInfo(customer); } - public async Task GetPaymentMethodAsync(ISubscriber subscriber) + public async Task UpdateTaxInformation( + ISubscriber subscriber, + TaxInformationDTO taxInformation) { ArgumentNullException.ThrowIfNull(subscriber); - var customer = await GetCustomerOrThrow(subscriber, GetCustomerPaymentOptions()); - if (customer == null) + ArgumentNullException.ThrowIfNull(taxInformation); + + var customer = await GetCustomerOrThrow(subscriber, new CustomerGetOptions + { + Expand = ["tax_ids"] + }); + + await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions + { + Address = new AddressOptions + { + Country = taxInformation.Country, + PostalCode = taxInformation.PostalCode, + Line1 = taxInformation.Line1 ?? string.Empty, + Line2 = taxInformation.Line2, + City = taxInformation.City, + State = taxInformation.State + } + }); + + if (!subscriber.IsUser()) + { + var taxId = customer.TaxIds?.FirstOrDefault(); + + if (taxId != null) + { + await stripeAdapter.TaxIdDeleteAsync(customer.Id, taxId.Id); + } + + var taxIdType = taxInformation.GetTaxIdType(); + + if (!string.IsNullOrWhiteSpace(taxInformation.TaxId) && + !string.IsNullOrWhiteSpace(taxIdType)) + { + await stripeAdapter.TaxIdCreateAsync(customer.Id, new TaxIdCreateOptions + { + Type = taxIdType, + Value = taxInformation.TaxId, + }); + } + } + } + + public async Task VerifyBankAccount( + ISubscriber subscriber, + (long, long) microdeposits) + { + ArgumentNullException.ThrowIfNull(subscriber); + + var setupIntentId = await setupIntentCache.Get(subscriber.Id); + + if (string.IsNullOrEmpty(setupIntentId)) + { + logger.LogError("No setup intent ID exists to verify for subscriber with ID ({SubscriberID})", subscriber.Id); + + throw ContactSupport(); + } + + var (amount1, amount2) = microdeposits; + + await stripeAdapter.SetupIntentVerifyMicroDeposit(setupIntentId, new SetupIntentVerifyMicrodepositsOptions + { + Amounts = [amount1, amount2] + }); + + var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId); + + await stripeAdapter.PaymentMethodAttachAsync(setupIntent.PaymentMethodId, new PaymentMethodAttachOptions + { + Customer = subscriber.GatewayCustomerId + }); + + await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, + new CustomerUpdateOptions + { + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + DefaultPaymentMethod = setupIntent.PaymentMethodId + } + }); + } + + #region Shared Utilities + + private async Task AddBraintreeCustomerIdAsync( + Customer customer, + string braintreeCustomerId) + { + var metadata = customer.Metadata ?? new Dictionary(); + + metadata[BraintreeCustomerIdKey] = braintreeCustomerId; + + await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions + { + Metadata = metadata + }); + } + + private async Task CreateBraintreeCustomerAsync( + ISubscriber subscriber, + string paymentMethodNonce) + { + var braintreeCustomerId = + subscriber.BraintreeCustomerIdPrefix() + + subscriber.Id.ToString("N").ToLower() + + CoreHelpers.RandomString(3, upper: false, numeric: false); + + var customerResult = await braintreeGateway.Customer.CreateAsync(new CustomerRequest + { + Id = braintreeCustomerId, + CustomFields = new Dictionary + { + [subscriber.BraintreeIdField()] = subscriber.Id.ToString(), + [subscriber.BraintreeCloudRegionField()] = globalSettings.BaseServiceUri.CloudRegion + }, + Email = subscriber.BillingEmailAddress(), + PaymentMethodNonce = paymentMethodNonce, + }); + + if (customerResult.IsSuccess()) + { + return customerResult.Target.Id; + } + + logger.LogError("Failed to create Braintree customer for subscriber ({ID})", subscriber.Id); + + throw ContactSupport(); + } + + private async Task GetMaskedPaymentMethodDTOAsync( + Guid subscriberId, + Customer customer) + { + if (customer.Metadata != null) + { + var hasBraintreeCustomerId = customer.Metadata.TryGetValue(BraintreeCustomerIdKey, out var braintreeCustomerId); + + if (hasBraintreeCustomerId) + { + var braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId); + + return MaskedPaymentMethodDTO.From(braintreeCustomer); + } + } + + var attachedPaymentMethodDTO = MaskedPaymentMethodDTO.From(customer); + + if (attachedPaymentMethodDTO != null) + { + return attachedPaymentMethodDTO; + } + + /* + * attachedPaymentMethodDTO being null represents a case where we could be looking for the SetupIntent for an unverified "us_bank_account". + * We store the ID of this SetupIntent in the cache when we originally update the payment method. + */ + var setupIntentId = await setupIntentCache.Get(subscriberId); + + if (string.IsNullOrEmpty(setupIntentId)) { - logger.LogError("Could not find Stripe customer ({CustomerID}) for subscriber ({SubscriberID})", - subscriber.GatewayCustomerId, subscriber.Id); return null; } - if (customer.Metadata?.ContainsKey("btCustomerId") ?? false) + var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions { - try + Expand = ["payment_method"] + }); + + return MaskedPaymentMethodDTO.From(setupIntent); + } + + private static TaxInformationDTO GetTaxInformationDTOFrom( + Customer customer) + { + if (customer.Address == null) + { + return null; + } + + return new TaxInformationDTO( + customer.Address.Country, + customer.Address.PostalCode, + customer.TaxIds?.FirstOrDefault()?.Value, + customer.Address.Line1, + customer.Address.Line2, + customer.Address.City, + customer.Address.State); + } + + private async Task RemoveBraintreeCustomerIdAsync( + Customer customer) + { + var metadata = customer.Metadata ?? new Dictionary(); + + if (metadata.ContainsKey(BraintreeCustomerIdKey)) + { + metadata[BraintreeCustomerIdKey] = null; + + await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { - var braintreeCustomer = await braintreeGateway.Customer.FindAsync( - customer.Metadata["btCustomerId"]); - if (braintreeCustomer?.DefaultPaymentMethod != null) + Metadata = metadata + }); + } + } + + private async Task RemoveStripePaymentMethodsAsync( + Customer customer) + { + if (customer.Sources != null && customer.Sources.Any()) + { + foreach (var source in customer.Sources) + { + switch (source) { - return new BillingInfo.BillingSource( - braintreeCustomer.DefaultPaymentMethod); + case BankAccount: + await stripeAdapter.BankAccountDeleteAsync(customer.Id, source.Id); + break; + case Card: + await stripeAdapter.CardDeleteAsync(customer.Id, source.Id); + break; } } - catch (Braintree.Exceptions.NotFoundException ex) + } + + var paymentMethods = await stripeAdapter.CustomerListPaymentMethods(customer.Id); + + await Task.WhenAll(paymentMethods.Select(pm => stripeAdapter.PaymentMethodDetachAsync(pm.Id))); + } + + private async Task ReplaceBraintreePaymentMethodAsync( + Braintree.Customer customer, + string defaultPaymentMethodToken) + { + var existingDefaultPaymentMethod = customer.DefaultPaymentMethod; + + var createPaymentMethodResult = await braintreeGateway.PaymentMethod.CreateAsync(new PaymentMethodRequest + { + CustomerId = customer.Id, + PaymentMethodNonce = defaultPaymentMethodToken + }); + + if (!createPaymentMethodResult.IsSuccess()) + { + logger.LogError("Failed to replace payment method for Braintree customer ({ID}) - Creation of new payment method failed | Error: {Error}", customer.Id, createPaymentMethodResult.Message); + + throw ContactSupport(); + } + + var updateCustomerResult = await braintreeGateway.Customer.UpdateAsync( + customer.Id, + new CustomerRequest { DefaultPaymentMethodToken = createPaymentMethodResult.Target.Token }); + + if (!updateCustomerResult.IsSuccess()) + { + logger.LogError("Failed to replace payment method for Braintree customer ({ID}) - Customer update failed | Error: {Error}", + customer.Id, updateCustomerResult.Message); + + await braintreeGateway.PaymentMethod.DeleteAsync(createPaymentMethodResult.Target.Token); + + throw ContactSupport(); + } + + if (existingDefaultPaymentMethod != null) + { + var deletePaymentMethodResult = await braintreeGateway.PaymentMethod.DeleteAsync(existingDefaultPaymentMethod.Token); + + if (!deletePaymentMethodResult.IsSuccess()) { - logger.LogError("An error occurred while trying to retrieve braintree customer ({SubscriberID}): {Error}", subscriber.Id, ex.Message); + logger.LogWarning( + "Failed to delete replaced payment method for Braintree customer ({ID}) - outdated payment method still exists | Error: {Error}", + customer.Id, deletePaymentMethodResult.Message); } } - - if (customer.InvoiceSettings?.DefaultPaymentMethod?.Type == "card") - { - return new BillingInfo.BillingSource( - customer.InvoiceSettings.DefaultPaymentMethod); - } - - if (customer.DefaultSource != null && - (customer.DefaultSource is Card || customer.DefaultSource is BankAccount)) - { - return new BillingInfo.BillingSource(customer.DefaultSource); - } - - var paymentMethod = GetLatestCardPaymentMethod(customer.Id); - return paymentMethod != null ? new BillingInfo.BillingSource(paymentMethod) : null; } - private static CustomerGetOptions GetCustomerPaymentOptions() - { - var customerOptions = new CustomerGetOptions(); - customerOptions.AddExpand("default_source"); - customerOptions.AddExpand("invoice_settings.default_payment_method"); - return customerOptions; - } - - private Stripe.PaymentMethod GetLatestCardPaymentMethod(string customerId) - { - var cardPaymentMethods = stripeAdapter.PaymentMethodListAutoPaging( - new PaymentMethodListOptions { Customer = customerId, Type = "card" }); - return cardPaymentMethods.MaxBy(m => m.Created); - } - - private TaxInfo MapToTaxInfo(Customer customer) - { - var address = customer.Address; - var taxId = customer.TaxIds?.FirstOrDefault(); - - return new TaxInfo - { - TaxIdNumber = taxId?.Value, - BillingAddressLine1 = address?.Line1, - BillingAddressLine2 = address?.Line2, - BillingAddressCity = address?.City, - BillingAddressState = address?.State, - BillingAddressPostalCode = address?.PostalCode, - BillingAddressCountry = address?.Country, - }; - } + #endregion } diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index 52bdab4bb..3c78c585f 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -49,6 +49,7 @@ public interface IPaymentService Task GetBillingHistoryAsync(ISubscriber subscriber); Task GetBillingBalanceAndSourceAsync(ISubscriber subscriber); Task GetSubscriptionAsync(ISubscriber subscriber); + Task GetTaxInfoAsync(ISubscriber subscriber); Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo); Task CreateTaxRateAsync(TaxRate taxRate); Task UpdateTaxRateAsync(TaxRate taxRate); diff --git a/src/Core/Services/IStripeAdapter.cs b/src/Core/Services/IStripeAdapter.cs index 908dc2c0d..bb57f1cd0 100644 --- a/src/Core/Services/IStripeAdapter.cs +++ b/src/Core/Services/IStripeAdapter.cs @@ -9,6 +9,7 @@ public interface IStripeAdapter Task CustomerGetAsync(string id, Stripe.CustomerGetOptions options = null); Task CustomerUpdateAsync(string id, Stripe.CustomerUpdateOptions options = null); Task CustomerDeleteAsync(string id); + Task> CustomerListPaymentMethods(string id, CustomerListPaymentMethodsOptions options = null); Task SubscriptionCreateAsync(Stripe.SubscriptionCreateOptions subscriptionCreateOptions); Task SubscriptionGetAsync(string id, Stripe.SubscriptionGetOptions options = null); Task> SubscriptionListAsync(StripeSubscriptionListOptions subscriptionSearchOptions); @@ -38,5 +39,10 @@ public interface IStripeAdapter Task BankAccountCreateAsync(string customerId, Stripe.BankAccountCreateOptions options = null); Task BankAccountDeleteAsync(string customerId, string bankAccount, Stripe.BankAccountDeleteOptions options = null); Task> PriceListAsync(Stripe.PriceListOptions options = null); + Task SetupIntentCreate(SetupIntentCreateOptions options); + Task> SetupIntentList(SetupIntentListOptions options); + Task SetupIntentCancel(string id, SetupIntentCancelOptions options = null); + Task SetupIntentGet(string id, SetupIntentGetOptions options = null); + Task SetupIntentVerifyMicroDeposit(string id, SetupIntentVerifyMicrodepositsOptions options); Task> TestClockListAsync(); } diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs index a7109252d..100a47f75 100644 --- a/src/Core/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Services/Implementations/StripeAdapter.cs @@ -16,6 +16,7 @@ public class StripeAdapter : IStripeAdapter private readonly Stripe.CardService _cardService; private readonly Stripe.BankAccountService _bankAccountService; private readonly Stripe.PriceService _priceService; + private readonly Stripe.SetupIntentService _setupIntentService; private readonly Stripe.TestHelpers.TestClockService _testClockService; public StripeAdapter() @@ -31,6 +32,7 @@ public class StripeAdapter : IStripeAdapter _cardService = new Stripe.CardService(); _bankAccountService = new Stripe.BankAccountService(); _priceService = new Stripe.PriceService(); + _setupIntentService = new SetupIntentService(); _testClockService = new Stripe.TestHelpers.TestClockService(); } @@ -54,6 +56,13 @@ public class StripeAdapter : IStripeAdapter return _customerService.DeleteAsync(id); } + public async Task> CustomerListPaymentMethods(string id, + CustomerListPaymentMethodsOptions options = null) + { + var paymentMethods = await _customerService.ListPaymentMethodsAsync(id, options); + return paymentMethods.Data; + } + public Task SubscriptionCreateAsync(Stripe.SubscriptionCreateOptions options) { return _subscriptionService.CreateAsync(options); @@ -222,6 +231,25 @@ public class StripeAdapter : IStripeAdapter return await _priceService.ListAsync(options); } + public Task SetupIntentCreate(SetupIntentCreateOptions options) + => _setupIntentService.CreateAsync(options); + + public async Task> SetupIntentList(SetupIntentListOptions options) + { + var setupIntents = await _setupIntentService.ListAsync(options); + + return setupIntents.Data; + } + + public Task SetupIntentCancel(string id, SetupIntentCancelOptions options = null) + => _setupIntentService.CancelAsync(id, options); + + public Task SetupIntentGet(string id, SetupIntentGetOptions options = null) + => _setupIntentService.GetAsync(id, options); + + public Task SetupIntentVerifyMicroDeposit(string id, SetupIntentVerifyMicrodepositsOptions options) + => _setupIntentService.VerifyMicrodepositsAsync(id, options); + public async Task> TestClockListAsync() { var items = new List(); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 47185da80..cc2bee06b 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -1651,6 +1651,43 @@ public class StripePaymentService : IPaymentService return subscriptionInfo; } + public async Task GetTaxInfoAsync(ISubscriber subscriber) + { + if (subscriber == null || string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) + { + return null; + } + + var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, + new CustomerGetOptions { Expand = ["tax_ids"] }); + + if (customer == null) + { + return null; + } + + var address = customer.Address; + var taxId = customer.TaxIds?.FirstOrDefault(); + + // Line1 is required, so if missing we're using the subscriber name + // see: https://stripe.com/docs/api/customers/create#create_customer-address-line1 + if (address != null && string.IsNullOrWhiteSpace(address.Line1)) + { + address.Line1 = null; + } + + return new TaxInfo + { + TaxIdNumber = taxId?.Value, + BillingAddressLine1 = address?.Line1, + BillingAddressLine2 = address?.Line2, + BillingAddressCity = address?.City, + BillingAddressState = address?.State, + BillingAddressPostalCode = address?.PostalCode, + BillingAddressCountry = address?.Country, + }; + } + public async Task SaveTaxInfoAsync(ISubscriber subscriber, TaxInfo taxInfo) { if (subscriber != null && !string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) diff --git a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs index ec7b3a28f..cb31cdac7 100644 --- a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs @@ -1,6 +1,11 @@ using Bit.Api.Billing.Controllers; +using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; using Bit.Core; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; using Bit.Core.Billing.Services; using Bit.Core.Context; @@ -21,6 +26,7 @@ namespace Bit.Api.Test.Billing.Controllers; [SutProviderCustomize] public class ProviderBillingControllerTests { + #region GetSubscriptionAsync [Theory, BitAutoData] public async Task GetSubscriptionAsync_FFDisabled_NotFound( Guid providerId, @@ -35,33 +41,14 @@ public class ProviderBillingControllerTests } [Theory, BitAutoData] - public async Task GetSubscriptionAsync_NotProviderAdmin_Unauthorized( + public async Task GetSubscriptionAsync_NullProvider_NotFound( Guid providerId, SutProvider sutProvider) { sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) .Returns(true); - sutProvider.GetDependency().ProviderProviderAdmin(providerId) - .Returns(false); - - var result = await sutProvider.Sut.GetSubscriptionAsync(providerId); - - Assert.IsType(result); - } - - [Theory, BitAutoData] - public async Task GetSubscriptionAsync_NoSubscriptionData_NotFound( - Guid providerId, - SutProvider sutProvider) - { - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) - .Returns(true); - - sutProvider.GetDependency().ProviderProviderAdmin(providerId) - .Returns(true); - - sutProvider.GetDependency().GetSubscriptionDTO(providerId).ReturnsNull(); + sutProvider.GetDependency().GetByIdAsync(providerId).ReturnsNull(); var result = await sutProvider.Sut.GetSubscriptionAsync(providerId); @@ -69,20 +56,69 @@ public class ProviderBillingControllerTests } [Theory, BitAutoData] - public async Task GetSubscriptionAsync_OK( - Guid providerId, + public async Task GetSubscriptionAsync_NotProviderAdmin_Unauthorized( + Provider provider, SutProvider sutProvider) { sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) .Returns(true); - sutProvider.GetDependency().ProviderProviderAdmin(providerId) + sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id) + .Returns(false); + + var result = await sutProvider.Sut.GetSubscriptionAsync(provider.Id); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetSubscriptionAsync_ProviderNotBillable_Unauthorized( + Provider provider, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) .Returns(true); - var configuredProviderPlanDTOList = new List + provider.Type = ProviderType.Reseller; + provider.Status = ProviderStatusType.Created; + + sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id) + .Returns(false); + + var result = await sutProvider.Sut.GetSubscriptionAsync(provider.Id); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetSubscriptionAsync_NullConsolidatedBillingSubscription_NotFound( + Provider provider, + SutProvider sutProvider) + { + ConfigureStableInputs(provider, sutProvider); + + sutProvider.GetDependency().GetConsolidatedBillingSubscription(provider).ReturnsNull(); + + var result = await sutProvider.Sut.GetSubscriptionAsync(provider.Id); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetSubscriptionAsync_Ok( + Provider provider, + SutProvider sutProvider) + { + ConfigureStableInputs(provider, sutProvider); + + var configuredProviderPlans = new List { - new (Guid.NewGuid(), providerId, PlanType.TeamsMonthly, 50, 10, 30), - new (Guid.NewGuid(), providerId, PlanType.EnterpriseMonthly, 100, 0, 90) + new (Guid.NewGuid(), provider.Id, PlanType.TeamsMonthly, 50, 10, 30), + new (Guid.NewGuid(), provider.Id , PlanType.EnterpriseMonthly, 100, 0, 90) }; var subscription = new Subscription @@ -92,25 +128,25 @@ public class ProviderBillingControllerTests Customer = new Customer { Discount = new Discount { Coupon = new Coupon { PercentOff = 10 } } } }; - var providerSubscriptionDTO = new ProviderSubscriptionDTO( - configuredProviderPlanDTOList, + var consolidatedBillingSubscription = new ConsolidatedBillingSubscriptionDTO( + configuredProviderPlans, subscription); - sutProvider.GetDependency().GetSubscriptionDTO(providerId) - .Returns(providerSubscriptionDTO); + sutProvider.GetDependency().GetConsolidatedBillingSubscription(provider) + .Returns(consolidatedBillingSubscription); - var result = await sutProvider.Sut.GetSubscriptionAsync(providerId); + var result = await sutProvider.Sut.GetSubscriptionAsync(provider.Id); - Assert.IsType>(result); + Assert.IsType>(result); - var providerSubscriptionResponse = ((Ok)result).Value; + var response = ((Ok)result).Value; - Assert.Equal(providerSubscriptionResponse.Status, subscription.Status); - Assert.Equal(providerSubscriptionResponse.CurrentPeriodEndDate, subscription.CurrentPeriodEnd); - Assert.Equal(providerSubscriptionResponse.DiscountPercentage, subscription.Customer!.Discount!.Coupon!.PercentOff); + Assert.Equal(response.Status, subscription.Status); + Assert.Equal(response.CurrentPeriodEndDate, subscription.CurrentPeriodEnd); + Assert.Equal(response.DiscountPercentage, subscription.Customer!.Discount!.Coupon!.PercentOff); var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); - var providerTeamsPlan = providerSubscriptionResponse.Plans.FirstOrDefault(plan => plan.PlanName == teamsPlan.Name); + var providerTeamsPlan = response.Plans.FirstOrDefault(plan => plan.PlanName == teamsPlan.Name); Assert.NotNull(providerTeamsPlan); Assert.Equal(50, providerTeamsPlan.SeatMinimum); Assert.Equal(10, providerTeamsPlan.PurchasedSeats); @@ -119,7 +155,7 @@ public class ProviderBillingControllerTests Assert.Equal("Monthly", providerTeamsPlan.Cadence); var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly); - var providerEnterprisePlan = providerSubscriptionResponse.Plans.FirstOrDefault(plan => plan.PlanName == enterprisePlan.Name); + var providerEnterprisePlan = response.Plans.FirstOrDefault(plan => plan.PlanName == enterprisePlan.Name); Assert.NotNull(providerEnterprisePlan); Assert.Equal(100, providerEnterprisePlan.SeatMinimum); Assert.Equal(0, providerEnterprisePlan.PurchasedSeats); @@ -127,4 +163,225 @@ public class ProviderBillingControllerTests Assert.Equal(100 * enterprisePlan.PasswordManager.SeatPrice, providerEnterprisePlan.Cost); Assert.Equal("Monthly", providerEnterprisePlan.Cadence); } + #endregion + + #region GetPaymentInformationAsync + + [Theory, BitAutoData] + public async Task GetPaymentInformation_PaymentInformationNull_NotFound( + Provider provider, + SutProvider sutProvider) + { + ConfigureStableInputs(provider, sutProvider); + + sutProvider.GetDependency().GetPaymentInformation(provider).ReturnsNull(); + + var result = await sutProvider.Sut.GetSubscriptionAsync(provider.Id); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetPaymentInformation_Ok( + Provider provider, + SutProvider sutProvider) + { + ConfigureStableInputs(provider, sutProvider); + + var maskedPaymentMethod = new MaskedPaymentMethodDTO(PaymentMethodType.Card, "VISA *1234", false); + + var taxInformation = + new TaxInformationDTO("US", "12345", "123456789", "123 Example St.", null, "Example Town", "NY"); + + sutProvider.GetDependency().GetPaymentInformation(provider).Returns(new PaymentInformationDTO( + 100, + maskedPaymentMethod, + taxInformation)); + + var result = await sutProvider.Sut.GetPaymentInformationAsync(provider.Id); + + Assert.IsType>(result); + + var response = ((Ok)result).Value; + + Assert.Equal(100, response.AccountCredit); + Assert.Equal(maskedPaymentMethod.Description, response.PaymentMethod.Description); + Assert.Equal(taxInformation.TaxId, response.TaxInformation.TaxId); + } + + #endregion + + #region GetPaymentMethodAsync + + [Theory, BitAutoData] + public async Task GetPaymentMethod_PaymentMethodNull_NotFound( + Provider provider, + SutProvider sutProvider) + { + ConfigureStableInputs(provider, sutProvider); + + sutProvider.GetDependency().GetPaymentMethod(provider).ReturnsNull(); + + var result = await sutProvider.Sut.GetSubscriptionAsync(provider.Id); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetPaymentMethod_Ok( + Provider provider, + SutProvider sutProvider) + { + ConfigureStableInputs(provider, sutProvider); + + sutProvider.GetDependency().GetPaymentMethod(provider).Returns(new MaskedPaymentMethodDTO( + PaymentMethodType.Card, "Description", false)); + + var result = await sutProvider.Sut.GetPaymentMethodAsync(provider.Id); + + Assert.IsType>(result); + + var response = ((Ok)result).Value; + + Assert.Equal(PaymentMethodType.Card, response.Type); + Assert.Equal("Description", response.Description); + Assert.False(response.NeedsVerification); + } + + #endregion + + #region GetTaxInformationAsync + + [Theory, BitAutoData] + public async Task GetTaxInformation_TaxInformationNull_NotFound( + Provider provider, + SutProvider sutProvider) + { + ConfigureStableInputs(provider, sutProvider); + + sutProvider.GetDependency().GetTaxInformation(provider).ReturnsNull(); + + var result = await sutProvider.Sut.GetSubscriptionAsync(provider.Id); + + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task GetTaxInformation_Ok( + Provider provider, + SutProvider sutProvider) + { + ConfigureStableInputs(provider, sutProvider); + + sutProvider.GetDependency().GetTaxInformation(provider).Returns(new TaxInformationDTO( + "US", + "12345", + "123456789", + "123 Example St.", + null, + "Example Town", + "NY")); + + var result = await sutProvider.Sut.GetTaxInformationAsync(provider.Id); + + Assert.IsType>(result); + + var response = ((Ok)result).Value; + + Assert.Equal("US", response.Country); + Assert.Equal("12345", response.PostalCode); + Assert.Equal("123456789", response.TaxId); + Assert.Equal("123 Example St.", response.Line1); + Assert.Null(response.Line2); + Assert.Equal("Example Town", response.City); + Assert.Equal("NY", response.State); + } + + #endregion + + #region UpdatePaymentMethodAsync + + [Theory, BitAutoData] + public async Task UpdatePaymentMethod_Ok( + Provider provider, + TokenizedPaymentMethodRequestBody requestBody, + SutProvider sutProvider) + { + ConfigureStableInputs(provider, sutProvider); + + await sutProvider.Sut.UpdatePaymentMethodAsync(provider.Id, requestBody); + + await sutProvider.GetDependency().Received(1).UpdatePaymentMethod( + provider, Arg.Is( + options => options.Type == requestBody.Type && options.Token == requestBody.Token)); + + await sutProvider.GetDependency().Received(1).SubscriptionUpdateAsync( + provider.GatewaySubscriptionId, Arg.Is( + options => options.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically)); + } + + #endregion + + #region UpdateTaxInformationAsync + + [Theory, BitAutoData] + public async Task UpdateTaxInformation_Ok( + Provider provider, + TaxInformationRequestBody requestBody, + SutProvider sutProvider) + { + ConfigureStableInputs(provider, sutProvider); + + await sutProvider.Sut.UpdateTaxInformationAsync(provider.Id, requestBody); + + await sutProvider.GetDependency().Received(1).UpdateTaxInformation( + provider, Arg.Is( + options => + options.Country == requestBody.Country && + options.PostalCode == requestBody.PostalCode && + options.TaxId == requestBody.TaxId && + options.Line1 == requestBody.Line1 && + options.Line2 == requestBody.Line2 && + options.City == requestBody.City && + options.State == requestBody.State)); + } + + #endregion + + #region VerifyBankAccount + + [Theory, BitAutoData] + public async Task VerifyBankAccount_Ok( + Provider provider, + VerifyBankAccountRequestBody requestBody, + SutProvider sutProvider) + { + ConfigureStableInputs(provider, sutProvider); + + var result = await sutProvider.Sut.VerifyBankAccountAsync(provider.Id, requestBody); + + Assert.IsType(result); + + await sutProvider.GetDependency().Received(1).VerifyBankAccount( + provider, + (requestBody.Amount1, requestBody.Amount2)); + } + + #endregion + + private static void ConfigureStableInputs( + Provider provider, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) + .Returns(true); + + provider.Type = ProviderType.Msp; + provider.Status = ProviderStatusType.Billable; + + sutProvider.GetDependency().GetByIdAsync(provider.Id).Returns(provider); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id) + .Returns(true); + } } diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index f052fb92d..79147feb7 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -1,9 +1,12 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.Billing; +using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; using Bit.Core.Billing.Services.Implementations; +using Bit.Core.Enums; using Bit.Core.Services; +using Bit.Core.Settings; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Braintree; @@ -14,6 +17,7 @@ using Stripe; using Xunit; using static Bit.Core.Test.Billing.Utilities; +using Address = Stripe.Address; using Customer = Stripe.Customer; using PaymentMethod = Stripe.PaymentMethod; using Subscription = Stripe.Subscription; @@ -316,6 +320,305 @@ public class SubscriberServiceTests } #endregion + #region GetPaymentMethod + [Theory, BitAutoData] + public async Task GetPaymentMethod_NullSubscriber_ThrowsArgumentNullException( + SutProvider sutProvider) => + await Assert.ThrowsAsync(() => sutProvider.Sut.GetPaymentMethod(null)); + + [Theory, BitAutoData] + public async Task GetPaymentMethod_Braintree_NoDefaultPaymentMethod_ReturnsNull( + Provider provider, + SutProvider sutProvider) + { + const string braintreeCustomerId = "braintree_customer_id"; + + var customer = new Customer + { + Id = provider.GatewayCustomerId, + Metadata = new Dictionary + { + [Core.Billing.Utilities.BraintreeCustomerIdKey] = braintreeCustomerId + } + }; + + sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, + Arg.Is( + options => options.Expand.Contains("default_source") && + options.Expand.Contains("invoice_settings.default_payment_method"))) + .Returns(customer); + + var (_, customerGateway, _) = SetupBraintree(sutProvider.GetDependency()); + + var braintreeCustomer = Substitute.For(); + + braintreeCustomer.Id.Returns(braintreeCustomerId); + + braintreeCustomer.PaymentMethods.Returns([]); + + customerGateway.FindAsync(braintreeCustomerId).Returns(braintreeCustomer); + + var paymentMethod = await sutProvider.Sut.GetPaymentMethod(provider); + + Assert.Null(paymentMethod); + } + + [Theory, BitAutoData] + public async Task GetPaymentMethod_Braintree_PayPalAccount_Succeeds( + Provider provider, + SutProvider sutProvider) + { + const string braintreeCustomerId = "braintree_customer_id"; + + var customer = new Customer + { + Id = provider.GatewayCustomerId, + Metadata = new Dictionary + { + [Core.Billing.Utilities.BraintreeCustomerIdKey] = braintreeCustomerId + } + }; + + sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, + Arg.Is( + options => options.Expand.Contains("default_source") && + options.Expand.Contains("invoice_settings.default_payment_method"))) + .Returns(customer); + + var (_, customerGateway, _) = SetupBraintree(sutProvider.GetDependency()); + + var braintreeCustomer = Substitute.For(); + + braintreeCustomer.Id.Returns(braintreeCustomerId); + + var payPalAccount = Substitute.For(); + + payPalAccount.IsDefault.Returns(true); + + payPalAccount.Email.Returns("a@example.com"); + + braintreeCustomer.PaymentMethods.Returns([payPalAccount]); + + customerGateway.FindAsync(braintreeCustomerId).Returns(braintreeCustomer); + + var paymentMethod = await sutProvider.Sut.GetPaymentMethod(provider); + + Assert.Equal(PaymentMethodType.PayPal, paymentMethod.Type); + Assert.Equal("a@example.com", paymentMethod.Description); + Assert.False(paymentMethod.NeedsVerification); + } + + // TODO: Determine if we need to test Braintree.CreditCard + + // TODO: Determine if we need to test Braintree.UsBankAccount + + [Theory, BitAutoData] + public async Task GetPaymentMethod_Stripe_BankAccountPaymentMethod_Succeeds( + Provider provider, + SutProvider sutProvider) + { + var customer = new Customer + { + InvoiceSettings = new CustomerInvoiceSettings + { + DefaultPaymentMethod = new PaymentMethod + { + Type = StripeConstants.PaymentMethodTypes.USBankAccount, + UsBankAccount = new PaymentMethodUsBankAccount + { + BankName = "Chase", + Last4 = "9999" + } + } + } + }; + + sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, + Arg.Is( + options => options.Expand.Contains("default_source") && + options.Expand.Contains("invoice_settings.default_payment_method"))) + .Returns(customer); + + var paymentMethod = await sutProvider.Sut.GetPaymentMethod(provider); + + Assert.Equal(PaymentMethodType.BankAccount, paymentMethod.Type); + Assert.Equal("Chase, *9999", paymentMethod.Description); + Assert.False(paymentMethod.NeedsVerification); + } + + [Theory, BitAutoData] + public async Task GetPaymentMethod_Stripe_CardPaymentMethod_Succeeds( + Provider provider, + SutProvider sutProvider) + { + var customer = new Customer + { + InvoiceSettings = new CustomerInvoiceSettings + { + DefaultPaymentMethod = new PaymentMethod + { + Type = StripeConstants.PaymentMethodTypes.Card, + Card = new PaymentMethodCard + { + Brand = "Visa", + Last4 = "9999", + ExpMonth = 9, + ExpYear = 2028 + } + } + } + }; + + sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, + Arg.Is( + options => options.Expand.Contains("default_source") && + options.Expand.Contains("invoice_settings.default_payment_method"))) + .Returns(customer); + + var paymentMethod = await sutProvider.Sut.GetPaymentMethod(provider); + + Assert.Equal(PaymentMethodType.Card, paymentMethod.Type); + Assert.Equal("VISA, *9999, 09/2028", paymentMethod.Description); + Assert.False(paymentMethod.NeedsVerification); + } + + [Theory, BitAutoData] + public async Task GetPaymentMethod_Stripe_SetupIntentForBankAccount_Succeeds( + Provider provider, + SutProvider sutProvider) + { + var customer = new Customer + { + Id = provider.GatewayCustomerId + }; + + sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, + Arg.Is( + options => options.Expand.Contains("default_source") && + options.Expand.Contains("invoice_settings.default_payment_method"))) + .Returns(customer); + + var setupIntent = new SetupIntent + { + Id = "setup_intent_id", + Status = "requires_action", + NextAction = new SetupIntentNextAction + { + VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() + }, + PaymentMethod = new PaymentMethod + { + UsBankAccount = new PaymentMethodUsBankAccount + { + BankName = "Chase", + Last4 = "9999" + } + } + }; + + sutProvider.GetDependency().Get(provider.Id).Returns(setupIntent.Id); + + sutProvider.GetDependency().SetupIntentGet(setupIntent.Id, Arg.Is( + options => options.Expand.Contains("payment_method"))).Returns(setupIntent); + + var paymentMethod = await sutProvider.Sut.GetPaymentMethod(provider); + + Assert.Equal(PaymentMethodType.BankAccount, paymentMethod.Type); + Assert.Equal("Chase, *9999", paymentMethod.Description); + Assert.True(paymentMethod.NeedsVerification); + } + + [Theory, BitAutoData] + public async Task GetPaymentMethod_Stripe_LegacyBankAccount_Succeeds( + Provider provider, + SutProvider sutProvider) + { + var customer = new Customer + { + DefaultSource = new BankAccount + { + Status = "verified", + BankName = "Chase", + Last4 = "9999" + } + }; + + sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, + Arg.Is( + options => options.Expand.Contains("default_source") && + options.Expand.Contains("invoice_settings.default_payment_method"))) + .Returns(customer); + + var paymentMethod = await sutProvider.Sut.GetPaymentMethod(provider); + + Assert.Equal(PaymentMethodType.BankAccount, paymentMethod.Type); + Assert.Equal("Chase, *9999 - Verified", paymentMethod.Description); + Assert.False(paymentMethod.NeedsVerification); + } + + [Theory, BitAutoData] + public async Task GetPaymentMethod_Stripe_LegacyCard_Succeeds( + Provider provider, + SutProvider sutProvider) + { + var customer = new Customer + { + DefaultSource = new Card + { + Brand = "Visa", + Last4 = "9999", + ExpMonth = 9, + ExpYear = 2028 + } + }; + + sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, + Arg.Is( + options => options.Expand.Contains("default_source") && + options.Expand.Contains("invoice_settings.default_payment_method"))) + .Returns(customer); + + var paymentMethod = await sutProvider.Sut.GetPaymentMethod(provider); + + Assert.Equal(PaymentMethodType.Card, paymentMethod.Type); + Assert.Equal("VISA, *9999, 09/2028", paymentMethod.Description); + Assert.False(paymentMethod.NeedsVerification); + } + + [Theory, BitAutoData] + public async Task GetPaymentMethod_Stripe_LegacySourceCard_Succeeds( + Provider provider, + SutProvider sutProvider) + { + var customer = new Customer + { + DefaultSource = new Source + { + Card = new SourceCard + { + Brand = "Visa", + Last4 = "9999", + ExpMonth = 9, + ExpYear = 2028 + } + } + }; + + sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, + Arg.Is( + options => options.Expand.Contains("default_source") && + options.Expand.Contains("invoice_settings.default_payment_method"))) + .Returns(customer); + + var paymentMethod = await sutProvider.Sut.GetPaymentMethod(provider); + + Assert.Equal(PaymentMethodType.Card, paymentMethod.Type); + Assert.Equal("VISA, *9999, 09/2028", paymentMethod.Description); + Assert.False(paymentMethod.NeedsVerification); + } + + #endregion + #region GetSubscription [Theory, BitAutoData] public async Task GetSubscription_NullSubscriber_ThrowsArgumentNullException( @@ -443,6 +746,65 @@ public class SubscriberServiceTests } #endregion + #region GetTaxInformation + + [Theory, BitAutoData] + public async Task GetTaxInformation_NullSubscriber_ThrowsArgumentNullException( + SutProvider sutProvider) => + await Assert.ThrowsAsync(() => sutProvider.Sut.GetTaxInformation(null)); + + [Theory, BitAutoData] + public async Task GetTaxInformation_NullAddress_ReturnsNull( + Organization organization, + SutProvider sutProvider) + { + sutProvider.GetDependency().CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .Returns(new Customer()); + + var taxInformation = await sutProvider.Sut.GetTaxInformation(organization); + + Assert.Null(taxInformation); + } + + [Theory, BitAutoData] + public async Task GetTaxInformation_Success( + Organization organization, + SutProvider sutProvider) + { + var address = new Address + { + Country = "US", + PostalCode = "12345", + Line1 = "123 Example St.", + Line2 = "Unit 1", + City = "Example Town", + State = "NY" + }; + + sutProvider.GetDependency().CustomerGetAsync(organization.GatewayCustomerId, Arg.Any()) + .Returns(new Customer + { + Address = address, + TaxIds = new StripeList + { + Data = [new TaxId { Value = "tax_id" }] + } + }); + + var taxInformation = await sutProvider.Sut.GetTaxInformation(organization); + + Assert.NotNull(taxInformation); + Assert.Equal(address.Country, taxInformation.Country); + Assert.Equal(address.PostalCode, taxInformation.PostalCode); + Assert.Equal("tax_id", taxInformation.TaxId); + Assert.Equal(address.Line1, taxInformation.Line1); + Assert.Equal(address.Line2, taxInformation.Line2); + Assert.Equal(address.City, taxInformation.City); + Assert.Equal(address.State, taxInformation.State); + } + + #endregion + #region RemovePaymentMethod [Theory, BitAutoData] public async Task RemovePaymentMethod_NullSubscriber_ArgumentNullException( @@ -737,145 +1099,522 @@ public class SubscriberServiceTests } #endregion - #region GetTaxInformationAsync - [Theory, BitAutoData] - public async Task GetTaxInformationAsync_NullSubscriber_ThrowsArgumentNullException( - SutProvider sutProvider) - => await Assert.ThrowsAsync( - async () => await sutProvider.Sut.GetTaxInformationAsync(null)); + #region UpdatePaymentMethod [Theory, BitAutoData] - public async Task GetTaxInformationAsync_NoGatewayCustomerId_ReturnsNull( - Provider subscriber, + public async Task UpdatePaymentMethod_NullSubscriber_ArgumentNullException( + SutProvider sutProvider) + => await Assert.ThrowsAsync(() => sutProvider.Sut.UpdatePaymentMethod(null, null)); + + [Theory, BitAutoData] + public async Task UpdatePaymentMethod_NullTokenizedPaymentMethod_ArgumentNullException( + Provider provider, + SutProvider sutProvider) + => await Assert.ThrowsAsync(() => sutProvider.Sut.UpdatePaymentMethod(provider, null)); + + [Theory, BitAutoData] + public async Task UpdatePaymentMethod_NoToken_ContactSupport( + Provider provider, SutProvider sutProvider) { - subscriber.GatewayCustomerId = null; + sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId) + .Returns(new Customer()); - var taxInfo = await sutProvider.Sut.GetTaxInformationAsync(subscriber); - - Assert.Null(taxInfo); + await ThrowsContactSupportAsync(() => + sutProvider.Sut.UpdatePaymentMethod(provider, new TokenizedPaymentMethodDTO(PaymentMethodType.Card, null))); } [Theory, BitAutoData] - public async Task GetTaxInformationAsync_NoCustomer_ReturnsNull( - Provider subscriber, + public async Task UpdatePaymentMethod_UnsupportedPaymentMethod_ContactSupport( + Provider provider, SutProvider sutProvider) { - sutProvider.GetDependency() - .CustomerGetAsync(subscriber.GatewayCustomerId, Arg.Any()) - .Returns((Customer)null); + sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId) + .Returns(new Customer()); - await Assert.ThrowsAsync( - () => sutProvider.Sut.GetTaxInformationAsync(subscriber)); + await ThrowsContactSupportAsync(() => + sutProvider.Sut.UpdatePaymentMethod(provider, new TokenizedPaymentMethodDTO(PaymentMethodType.BitPay, "TOKEN"))); } [Theory, BitAutoData] - public async Task GetTaxInformationAsync_StripeException_ReturnsNull( - Provider subscriber, + public async Task UpdatePaymentMethod_BankAccount_IncorrectNumberOfSetupIntentsForToken_ContactSupport( + Provider provider, SutProvider sutProvider) { - sutProvider.GetDependency() - .CustomerGetAsync(subscriber.GatewayCustomerId, Arg.Any()) - .ThrowsAsync(new StripeException()); + var stripeAdapter = sutProvider.GetDependency(); - await Assert.ThrowsAsync( - () => sutProvider.Sut.GetTaxInformationAsync(subscriber)); + stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId) + .Returns(new Customer()); + + stripeAdapter.SetupIntentList(Arg.Is(options => options.PaymentMethod == "TOKEN")) + .Returns([new SetupIntent(), new SetupIntent()]); + + await ThrowsContactSupportAsync(() => + sutProvider.Sut.UpdatePaymentMethod(provider, new TokenizedPaymentMethodDTO(PaymentMethodType.BankAccount, "TOKEN"))); } [Theory, BitAutoData] - public async Task GetTaxInformationAsync_Succeeds( - Provider subscriber, + public async Task UpdatePaymentMethod_BankAccount_Succeeds( + Provider provider, SutProvider sutProvider) { - var customer = new Customer - { - Address = new Stripe.Address + var stripeAdapter = sutProvider.GetDependency(); + + stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId) + .Returns(new Customer { - Line1 = "123 Main St", - Line2 = "Apt 4B", - City = "Metropolis", - State = "NY", - PostalCode = "12345", - Country = "US" - } - }; + Id = provider.GatewayCustomerId, + Metadata = new Dictionary + { + [Core.Billing.Utilities.BraintreeCustomerIdKey] = "braintree_customer_id" + } + }); - sutProvider.GetDependency() - .CustomerGetAsync(subscriber.GatewayCustomerId, Arg.Any()) - .Returns(customer); + var matchingSetupIntent = new SetupIntent { Id = "setup_intent_1" }; - var taxInfo = await sutProvider.Sut.GetTaxInformationAsync(subscriber); + stripeAdapter.SetupIntentList(Arg.Is(options => options.PaymentMethod == "TOKEN")) + .Returns([matchingSetupIntent]); - Assert.NotNull(taxInfo); - Assert.Equal("123 Main St", taxInfo.BillingAddressLine1); - Assert.Equal("Apt 4B", taxInfo.BillingAddressLine2); - Assert.Equal("Metropolis", taxInfo.BillingAddressCity); - Assert.Equal("NY", taxInfo.BillingAddressState); - Assert.Equal("12345", taxInfo.BillingAddressPostalCode); - Assert.Equal("US", taxInfo.BillingAddressCountry); + stripeAdapter.SetupIntentList(Arg.Is(options => options.Customer == provider.GatewayCustomerId)) + .Returns([ + new SetupIntent { Id = "setup_intent_2", Status = "requires_payment_method" }, + new SetupIntent { Id = "setup_intent_3", Status = "succeeded" } + ]); + + stripeAdapter.CustomerListPaymentMethods(provider.GatewayCustomerId).Returns([ + new PaymentMethod { Id = "payment_method_1" } + ]); + + await sutProvider.Sut.UpdatePaymentMethod(provider, + new TokenizedPaymentMethodDTO(PaymentMethodType.BankAccount, "TOKEN")); + + await sutProvider.GetDependency().Received(1).Set(provider.Id, "setup_intent_1"); + + await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_2", + Arg.Is(options => options.CancellationReason == "abandoned")); + + await stripeAdapter.Received(1).PaymentMethodDetachAsync("payment_method_1"); + + await stripeAdapter.Received(1).CustomerUpdateAsync(provider.GatewayCustomerId, Arg.Is( + options => options.Metadata[Core.Billing.Utilities.BraintreeCustomerIdKey] == null)); } + + [Theory, BitAutoData] + public async Task UpdatePaymentMethod_Card_Succeeds( + Provider provider, + SutProvider sutProvider) + { + var stripeAdapter = sutProvider.GetDependency(); + + stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId) + .Returns(new Customer + { + Id = provider.GatewayCustomerId, + Metadata = new Dictionary + { + [Core.Billing.Utilities.BraintreeCustomerIdKey] = "braintree_customer_id" + } + }); + + stripeAdapter.SetupIntentList(Arg.Is(options => options.Customer == provider.GatewayCustomerId)) + .Returns([ + new SetupIntent { Id = "setup_intent_2", Status = "requires_payment_method" }, + new SetupIntent { Id = "setup_intent_3", Status = "succeeded" } + ]); + + stripeAdapter.CustomerListPaymentMethods(provider.GatewayCustomerId).Returns([ + new PaymentMethod { Id = "payment_method_1" } + ]); + + await sutProvider.Sut.UpdatePaymentMethod(provider, + new TokenizedPaymentMethodDTO(PaymentMethodType.Card, "TOKEN")); + + await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_2", + Arg.Is(options => options.CancellationReason == "abandoned")); + + await stripeAdapter.Received(1).PaymentMethodDetachAsync("payment_method_1"); + + await stripeAdapter.Received(1).PaymentMethodAttachAsync("TOKEN", Arg.Is( + options => options.Customer == provider.GatewayCustomerId)); + + await stripeAdapter.Received(1).CustomerUpdateAsync(provider.GatewayCustomerId, Arg.Is( + options => + options.InvoiceSettings.DefaultPaymentMethod == "TOKEN" && + options.Metadata[Core.Billing.Utilities.BraintreeCustomerIdKey] == null)); + } + + [Theory, BitAutoData] + public async Task UpdatePaymentMethod_Braintree_NullCustomer_ContactSupport( + Provider provider, + SutProvider sutProvider) + { + const string braintreeCustomerId = "braintree_customer_id"; + + sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId) + .Returns(new Customer + { + Id = provider.GatewayCustomerId, + Metadata = new Dictionary + { + [Core.Billing.Utilities.BraintreeCustomerIdKey] = braintreeCustomerId + } + }); + + var (_, customerGateway, paymentMethodGateway) = SetupBraintree(sutProvider.GetDependency()); + + customerGateway.FindAsync(braintreeCustomerId).ReturnsNull(); + + await ThrowsContactSupportAsync(() => sutProvider.Sut.UpdatePaymentMethod(provider, new TokenizedPaymentMethodDTO(PaymentMethodType.PayPal, "TOKEN"))); + + await paymentMethodGateway.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdatePaymentMethod_Braintree_ReplacePaymentMethod_CreatePaymentMethodFails_ContactSupport( + Provider provider, + SutProvider sutProvider) + { + const string braintreeCustomerId = "braintree_customer_id"; + + sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId) + .Returns(new Customer + { + Id = provider.GatewayCustomerId, + Metadata = new Dictionary + { + [Core.Billing.Utilities.BraintreeCustomerIdKey] = braintreeCustomerId + } + }); + + var (_, customerGateway, paymentMethodGateway) = SetupBraintree(sutProvider.GetDependency()); + + var customer = Substitute.For(); + + customer.Id.Returns(braintreeCustomerId); + + customerGateway.FindAsync(braintreeCustomerId).Returns(customer); + + var createPaymentMethodResult = Substitute.For>(); + + createPaymentMethodResult.IsSuccess().Returns(false); + + paymentMethodGateway.CreateAsync(Arg.Is( + options => options.CustomerId == braintreeCustomerId && options.PaymentMethodNonce == "TOKEN")) + .Returns(createPaymentMethodResult); + + await ThrowsContactSupportAsync(() => sutProvider.Sut.UpdatePaymentMethod(provider, new TokenizedPaymentMethodDTO(PaymentMethodType.PayPal, "TOKEN"))); + + await customerGateway.DidNotReceiveWithAnyArgs().UpdateAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdatePaymentMethod_Braintree_ReplacePaymentMethod_UpdateCustomerFails_DeletePaymentMethod_ContactSupport( + Provider provider, + SutProvider sutProvider) + { + const string braintreeCustomerId = "braintree_customer_id"; + + sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId) + .Returns(new Customer + { + Id = provider.GatewayCustomerId, + Metadata = new Dictionary + { + [Core.Billing.Utilities.BraintreeCustomerIdKey] = braintreeCustomerId + } + }); + + var (_, customerGateway, paymentMethodGateway) = SetupBraintree(sutProvider.GetDependency()); + + var customer = Substitute.For(); + + customer.Id.Returns(braintreeCustomerId); + + customerGateway.FindAsync(braintreeCustomerId).Returns(customer); + + var createPaymentMethodResult = Substitute.For>(); + + var createdPaymentMethod = Substitute.For(); + + createdPaymentMethod.Token.Returns("TOKEN"); + + createPaymentMethodResult.IsSuccess().Returns(true); + + createPaymentMethodResult.Target.Returns(createdPaymentMethod); + + paymentMethodGateway.CreateAsync(Arg.Is( + options => options.CustomerId == braintreeCustomerId && options.PaymentMethodNonce == "TOKEN")) + .Returns(createPaymentMethodResult); + + var updateCustomerResult = Substitute.For>(); + + updateCustomerResult.IsSuccess().Returns(false); + + customerGateway.UpdateAsync(braintreeCustomerId, Arg.Is(options => + options.DefaultPaymentMethodToken == createPaymentMethodResult.Target.Token)) + .Returns(updateCustomerResult); + + await ThrowsContactSupportAsync(() => sutProvider.Sut.UpdatePaymentMethod(provider, new TokenizedPaymentMethodDTO(PaymentMethodType.PayPal, "TOKEN"))); + + await paymentMethodGateway.Received(1).DeleteAsync(createPaymentMethodResult.Target.Token); + } + + [Theory, BitAutoData] + public async Task UpdatePaymentMethod_Braintree_ReplacePaymentMethod_Success( + Provider provider, + SutProvider sutProvider) + { + const string braintreeCustomerId = "braintree_customer_id"; + + sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId) + .Returns(new Customer + { + Id = provider.GatewayCustomerId, + Metadata = new Dictionary + { + [Core.Billing.Utilities.BraintreeCustomerIdKey] = braintreeCustomerId + } + }); + + var (_, customerGateway, paymentMethodGateway) = SetupBraintree(sutProvider.GetDependency()); + + var customer = Substitute.For(); + + var existingPaymentMethod = Substitute.For(); + + existingPaymentMethod.Token.Returns("OLD_TOKEN"); + + existingPaymentMethod.IsDefault.Returns(true); + + customer.PaymentMethods.Returns([existingPaymentMethod]); + + customer.Id.Returns(braintreeCustomerId); + + customerGateway.FindAsync(braintreeCustomerId).Returns(customer); + + var createPaymentMethodResult = Substitute.For>(); + + var updatedPaymentMethod = Substitute.For(); + + updatedPaymentMethod.Token.Returns("TOKEN"); + + createPaymentMethodResult.IsSuccess().Returns(true); + + createPaymentMethodResult.Target.Returns(updatedPaymentMethod); + + paymentMethodGateway.CreateAsync(Arg.Is( + options => options.CustomerId == braintreeCustomerId && options.PaymentMethodNonce == "TOKEN")) + .Returns(createPaymentMethodResult); + + var updateCustomerResult = Substitute.For>(); + + updateCustomerResult.IsSuccess().Returns(true); + + customerGateway.UpdateAsync(braintreeCustomerId, Arg.Is(options => + options.DefaultPaymentMethodToken == createPaymentMethodResult.Target.Token)) + .Returns(updateCustomerResult); + + var deletePaymentMethodResult = Substitute.For>(); + + deletePaymentMethodResult.IsSuccess().Returns(true); + + paymentMethodGateway.DeleteAsync(existingPaymentMethod.Token).Returns(deletePaymentMethodResult); + + await sutProvider.Sut.UpdatePaymentMethod(provider, + new TokenizedPaymentMethodDTO(PaymentMethodType.PayPal, "TOKEN")); + + await paymentMethodGateway.Received(1).DeleteAsync(existingPaymentMethod.Token); + } + + [Theory, BitAutoData] + public async Task UpdatePaymentMethod_Braintree_CreateCustomer_CustomerUpdateFails_ContactSupport( + Provider provider, + SutProvider sutProvider) + { + const string braintreeCustomerId = "braintree_customer_id"; + + sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId) + .Returns(new Customer + { + Id = provider.GatewayCustomerId + }); + + sutProvider.GetDependency().BaseServiceUri + .Returns(new Settings.GlobalSettings.BaseServiceUriSettings(new Settings.GlobalSettings()) + { + CloudRegion = "US" + }); + + var (_, customerGateway, _) = SetupBraintree(sutProvider.GetDependency()); + + var createCustomerResult = Substitute.For>(); + + createCustomerResult.IsSuccess().Returns(false); + + customerGateway.CreateAsync(Arg.Is( + options => + options.Id == braintreeCustomerId && + options.CustomFields[provider.BraintreeIdField()] == provider.Id.ToString() && + options.CustomFields[provider.BraintreeCloudRegionField()] == "US" && + options.Email == provider.BillingEmailAddress() && + options.PaymentMethodNonce == "TOKEN")) + .Returns(createCustomerResult); + + await ThrowsContactSupportAsync(() => + sutProvider.Sut.UpdatePaymentMethod(provider, + new TokenizedPaymentMethodDTO(PaymentMethodType.PayPal, "TOKEN"))); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .CustomerUpdateAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdatePaymentMethod_Braintree_CreateCustomer_Succeeds( + Provider provider, + SutProvider sutProvider) + { + const string braintreeCustomerId = "braintree_customer_id"; + + sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId) + .Returns(new Customer + { + Id = provider.GatewayCustomerId + }); + + sutProvider.GetDependency().BaseServiceUri + .Returns(new Settings.GlobalSettings.BaseServiceUriSettings(new Settings.GlobalSettings()) + { + CloudRegion = "US" + }); + + var (_, customerGateway, _) = SetupBraintree(sutProvider.GetDependency()); + + var createCustomerResult = Substitute.For>(); + + var createdCustomer = Substitute.For(); + + createdCustomer.Id.Returns(braintreeCustomerId); + + createCustomerResult.IsSuccess().Returns(true); + + createCustomerResult.Target.Returns(createdCustomer); + + customerGateway.CreateAsync(Arg.Is( + options => + options.CustomFields[provider.BraintreeIdField()] == provider.Id.ToString() && + options.CustomFields[provider.BraintreeCloudRegionField()] == "US" && + options.Email == provider.BillingEmailAddress() && + options.PaymentMethodNonce == "TOKEN")) + .Returns(createCustomerResult); + + await sutProvider.Sut.UpdatePaymentMethod(provider, + new TokenizedPaymentMethodDTO(PaymentMethodType.PayPal, "TOKEN")); + + await sutProvider.GetDependency().Received(1).CustomerUpdateAsync(provider.GatewayCustomerId, + Arg.Is( + options => options.Metadata[Core.Billing.Utilities.BraintreeCustomerIdKey] == braintreeCustomerId)); + } + #endregion - #region GetPaymentMethodAsync + #region UpdateTaxInformation + [Theory, BitAutoData] - public async Task GetPaymentMethodAsync_NullSubscriber_ThrowsArgumentNullException( - SutProvider sutProvider) - { + public async Task UpdateTaxInformation_NullSubscriber_ThrowsArgumentNullException( + SutProvider sutProvider) => await Assert.ThrowsAsync( - async () => await sutProvider.Sut.GetPaymentMethodAsync(null)); - } + () => sutProvider.Sut.UpdateTaxInformation(null, null)); [Theory, BitAutoData] - public async Task GetPaymentMethodAsync_NoCustomer_ReturnsNull( - Provider subscriber, - SutProvider sutProvider) - { - subscriber.GatewayCustomerId = null; - sutProvider.GetDependency() - .CustomerGetAsync(subscriber.GatewayCustomerId, Arg.Any()) - .Returns((Customer)null); - - await Assert.ThrowsAsync(() => sutProvider.Sut.GetPaymentMethodAsync(subscriber)); - } + public async Task UpdateTaxInformation_NullTaxInformation_ThrowsArgumentNullException( + Provider provider, + SutProvider sutProvider) => + await Assert.ThrowsAsync( + () => sutProvider.Sut.UpdateTaxInformation(provider, null)); [Theory, BitAutoData] - public async Task GetPaymentMethodAsync_StripeCardPaymentMethod_ReturnsBillingSource( - Provider subscriber, + public async Task UpdateTaxInformation_NonUser_MakesCorrectInvocations( + Provider provider, SutProvider sutProvider) { - var customer = new Customer(); - var paymentMethod = CreateSamplePaymentMethod(); - subscriber.GatewayCustomerId = "test_customer_id"; - customer.InvoiceSettings = new CustomerInvoiceSettings - { - DefaultPaymentMethod = paymentMethod - }; + var stripeAdapter = sutProvider.GetDependency(); - sutProvider.GetDependency() - .CustomerGetAsync(subscriber.GatewayCustomerId, Arg.Any()) - .Returns(customer); + var customer = new Customer { Id = provider.GatewayCustomerId, TaxIds = new StripeList { Data = [new TaxId { Id = "tax_id_1" }] } }; - var billingSource = await sutProvider.Sut.GetPaymentMethodAsync(subscriber); + stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId, Arg.Is( + options => options.Expand.Contains("tax_ids"))).Returns(customer); - Assert.NotNull(billingSource); - Assert.Equal(paymentMethod.Card.Brand, billingSource.CardBrand); + var taxInformation = new TaxInformationDTO( + "US", + "12345", + "123456789", + "123 Example St.", + null, + "Example Town", + "NY"); + + await sutProvider.Sut.UpdateTaxInformation(provider, taxInformation); + + await stripeAdapter.Received(1).CustomerUpdateAsync(provider.GatewayCustomerId, Arg.Is( + options => + options.Address.Country == taxInformation.Country && + options.Address.PostalCode == taxInformation.PostalCode && + options.Address.Line1 == taxInformation.Line1 && + options.Address.Line2 == taxInformation.Line2 && + options.Address.City == taxInformation.City && + options.Address.State == taxInformation.State)); + + await stripeAdapter.Received(1).TaxIdDeleteAsync(provider.GatewayCustomerId, "tax_id_1"); + + await stripeAdapter.Received(1).TaxIdCreateAsync(provider.GatewayCustomerId, Arg.Is( + options => options.Type == "us_ein" && + options.Value == taxInformation.TaxId)); } - private static PaymentMethod CreateSamplePaymentMethod() + #endregion + + #region VerifyBankAccount + + [Theory, BitAutoData] + public async Task VerifyBankAccount_NullSubscriber_ThrowsArgumentNullException( + SutProvider sutProvider) => await Assert.ThrowsAsync( + () => sutProvider.Sut.VerifyBankAccount(null, (0, 0))); + + [Theory, BitAutoData] + public async Task VerifyBankAccount_NoSetupIntentId_ContactSupport( + Provider provider, + SutProvider sutProvider) => await ThrowsContactSupportAsync(() => sutProvider.Sut.VerifyBankAccount(provider, (1, 1))); + + [Theory, BitAutoData] + public async Task VerifyBankAccount_MakesCorrectInvocations( + Provider provider, + SutProvider sutProvider) { - var paymentMethod = new PaymentMethod + var setupIntent = new SetupIntent { - Id = "pm_test123", - Type = "card", - Card = new PaymentMethodCard - { - Brand = "visa", - Last4 = "4242", - ExpMonth = 12, - ExpYear = 2024 - } + Id = "setup_intent_id", + PaymentMethodId = "payment_method_id" }; - return paymentMethod; + + sutProvider.GetDependency().Get(provider.Id).Returns(setupIntent.Id); + + var stripeAdapter = sutProvider.GetDependency(); + + stripeAdapter.SetupIntentGet(setupIntent.Id).Returns(setupIntent); + + await sutProvider.Sut.VerifyBankAccount(provider, (1, 1)); + + await stripeAdapter.Received(1).SetupIntentVerifyMicroDeposit(setupIntent.Id, + Arg.Is( + options => options.Amounts[0] == 1 && options.Amounts[1] == 1)); + + await stripeAdapter.Received(1).PaymentMethodAttachAsync(setupIntent.PaymentMethodId, + Arg.Is( + options => options.Customer == provider.GatewayCustomerId)); + + await stripeAdapter.Received(1).CustomerUpdateAsync(provider.GatewayCustomerId, Arg.Is( + options => options.InvoiceSettings.DefaultPaymentMethod == setupIntent.PaymentMethodId)); } + #endregion }