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 }