From 9c0f9cf43dcc795d3ea9fe7d70386dcab7ca0af5 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 12 Feb 2025 09:00:52 -0500 Subject: [PATCH] [PM-18221] Update credited user's billing location when purchasing premium subscription (#5393) * Moved user crediting to PremiumUserBillingService * Fix tests --- src/Billing/Controllers/BitPayController.cs | 11 +-- src/Billing/Controllers/PayPalController.cs | 11 ++- .../Services/IPremiumUserBillingService.cs | 2 + .../PremiumUserBillingService.cs | 82 +++++++++++++++++++ .../Controllers/PayPalControllerTests.cs | 11 ++- 5 files changed, 102 insertions(+), 15 deletions(-) diff --git a/src/Billing/Controllers/BitPayController.cs b/src/Billing/Controllers/BitPayController.cs index 4caf57aa20..3747631bd0 100644 --- a/src/Billing/Controllers/BitPayController.cs +++ b/src/Billing/Controllers/BitPayController.cs @@ -1,6 +1,7 @@ using System.Globalization; using Bit.Billing.Models; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; @@ -25,6 +26,7 @@ public class BitPayController : Controller private readonly IMailService _mailService; private readonly IPaymentService _paymentService; private readonly ILogger _logger; + private readonly IPremiumUserBillingService _premiumUserBillingService; public BitPayController( IOptions billingSettings, @@ -35,7 +37,8 @@ public class BitPayController : Controller IProviderRepository providerRepository, IMailService mailService, IPaymentService paymentService, - ILogger logger) + ILogger logger, + IPremiumUserBillingService premiumUserBillingService) { _billingSettings = billingSettings?.Value; _bitPayClient = bitPayClient; @@ -46,6 +49,7 @@ public class BitPayController : Controller _mailService = mailService; _paymentService = paymentService; _logger = logger; + _premiumUserBillingService = premiumUserBillingService; } [HttpPost("ipn")] @@ -145,10 +149,7 @@ public class BitPayController : Controller if (user != null) { billingEmail = user.BillingEmailAddress(); - if (await _paymentService.CreditAccountAsync(user, tx.Amount)) - { - await _userRepository.ReplaceAsync(user); - } + await _premiumUserBillingService.Credit(user, tx.Amount); } } else if (tx.ProviderId.HasValue) diff --git a/src/Billing/Controllers/PayPalController.cs b/src/Billing/Controllers/PayPalController.cs index 2fc8aab4f2..2afde80601 100644 --- a/src/Billing/Controllers/PayPalController.cs +++ b/src/Billing/Controllers/PayPalController.cs @@ -1,6 +1,7 @@ using System.Text; using Bit.Billing.Models; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; @@ -23,6 +24,7 @@ public class PayPalController : Controller private readonly ITransactionRepository _transactionRepository; private readonly IUserRepository _userRepository; private readonly IProviderRepository _providerRepository; + private readonly IPremiumUserBillingService _premiumUserBillingService; public PayPalController( IOptions billingSettings, @@ -32,7 +34,8 @@ public class PayPalController : Controller IPaymentService paymentService, ITransactionRepository transactionRepository, IUserRepository userRepository, - IProviderRepository providerRepository) + IProviderRepository providerRepository, + IPremiumUserBillingService premiumUserBillingService) { _billingSettings = billingSettings?.Value; _logger = logger; @@ -42,6 +45,7 @@ public class PayPalController : Controller _transactionRepository = transactionRepository; _userRepository = userRepository; _providerRepository = providerRepository; + _premiumUserBillingService = premiumUserBillingService; } [HttpPost("ipn")] @@ -257,10 +261,9 @@ public class PayPalController : Controller { var user = await _userRepository.GetByIdAsync(transaction.UserId.Value); - if (await _paymentService.CreditAccountAsync(user, transaction.Amount)) + if (user != null) { - await _userRepository.ReplaceAsync(user); - + await _premiumUserBillingService.Credit(user, transaction.Amount); billingEmail = user.BillingEmailAddress(); } } diff --git a/src/Core/Billing/Services/IPremiumUserBillingService.cs b/src/Core/Billing/Services/IPremiumUserBillingService.cs index 2161b247b9..b3bb580e2d 100644 --- a/src/Core/Billing/Services/IPremiumUserBillingService.cs +++ b/src/Core/Billing/Services/IPremiumUserBillingService.cs @@ -6,6 +6,8 @@ namespace Bit.Core.Billing.Services; public interface IPremiumUserBillingService { + Task Credit(User user, decimal amount); + /// /// Establishes the Stripe entities necessary for a Bitwarden using the provided . /// diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index ed841c9576..6f571950f5 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -27,6 +27,57 @@ public class PremiumUserBillingService( ISubscriberService subscriberService, IUserRepository userRepository) : IPremiumUserBillingService { + public async Task Credit(User user, decimal amount) + { + var customer = await subscriberService.GetCustomer(user); + + // Negative credit represents a balance and all Stripe denomination is in cents. + var credit = (long)amount * -100; + + if (customer == null) + { + var options = new CustomerCreateOptions + { + Balance = credit, + Description = user.Name, + Email = user.Email, + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + CustomFields = + [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = user.SubscriberType(), + Value = user.SubscriberName().Length <= 30 + ? user.SubscriberName() + : user.SubscriberName()[..30] + } + ] + }, + Metadata = new Dictionary + { + ["region"] = globalSettings.BaseServiceUri.CloudRegion, + ["userId"] = user.Id.ToString() + } + }; + + customer = await stripeAdapter.CustomerCreateAsync(options); + + user.Gateway = GatewayType.Stripe; + user.GatewayCustomerId = customer.Id; + await userRepository.ReplaceAsync(user); + } + else + { + var options = new CustomerUpdateOptions + { + Balance = customer.Balance + credit + }; + + await stripeAdapter.CustomerUpdateAsync(customer.Id, options); + } + } + public async Task Finalize(PremiumUserSale sale) { var (user, customerSetup, storage) = sale; @@ -37,6 +88,37 @@ public class PremiumUserBillingService( ? await CreateCustomerAsync(user, customerSetup) : await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = expand }); + /* + * If the customer was previously set up with credit, which does not require a billing location, + * we need to update the customer on the fly before we start the subscription. + */ + if (customerSetup is + { + TokenizedPaymentSource.Type: PaymentMethodType.Credit, + TaxInformation: { Country: not null and not "", PostalCode: not null and not "" } + }) + { + var options = new CustomerUpdateOptions + { + Address = new AddressOptions + { + Line1 = customerSetup.TaxInformation.Line1, + Line2 = customerSetup.TaxInformation.Line2, + City = customerSetup.TaxInformation.City, + PostalCode = customerSetup.TaxInformation.PostalCode, + State = customerSetup.TaxInformation.State, + Country = customerSetup.TaxInformation.Country, + }, + Expand = ["tax"], + Tax = new CustomerTaxOptions + { + ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately + } + }; + + customer = await stripeAdapter.CustomerUpdateAsync(customer.Id, options); + } + var subscription = await CreateSubscriptionAsync(user.Id, customer, storage); switch (customerSetup.TokenizedPaymentSource) diff --git a/test/Billing.Test/Controllers/PayPalControllerTests.cs b/test/Billing.Test/Controllers/PayPalControllerTests.cs index a059207c76..7ec17bd85a 100644 --- a/test/Billing.Test/Controllers/PayPalControllerTests.cs +++ b/test/Billing.Test/Controllers/PayPalControllerTests.cs @@ -3,6 +3,7 @@ using Bit.Billing.Controllers; using Bit.Billing.Test.Utilities; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; @@ -33,6 +34,7 @@ public class PayPalControllerTests private readonly ITransactionRepository _transactionRepository = Substitute.For(); private readonly IUserRepository _userRepository = Substitute.For(); private readonly IProviderRepository _providerRepository = Substitute.For(); + private readonly IPremiumUserBillingService _premiumUserBillingService = Substitute.For(); private const string _defaultWebhookKey = "webhook-key"; @@ -385,8 +387,6 @@ public class PayPalControllerTests _userRepository.GetByIdAsync(userId).Returns(user); - _paymentService.CreditAccountAsync(user, 48M).Returns(true); - var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); var result = await controller.PostIpn(); @@ -398,9 +398,7 @@ public class PayPalControllerTests transaction.UserId == userId && transaction.Amount == 48M)); - await _paymentService.Received(1).CreditAccountAsync(user, 48M); - - await _userRepository.Received(1).ReplaceAsync(user); + await _premiumUserBillingService.Received(1).Credit(user, 48M); await _mailService.Received(1).SendAddedCreditAsync(billingEmail, 48M); } @@ -544,7 +542,8 @@ public class PayPalControllerTests _paymentService, _transactionRepository, _userRepository, - _providerRepository); + _providerRepository, + _premiumUserBillingService); var httpContext = new DefaultHttpContext();