1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-04 20:50:21 -05:00

[PM-18221] Update credited user's billing location when purchasing premium subscription (#5393)

* Moved user crediting to PremiumUserBillingService

* Fix tests
This commit is contained in:
Alex Morask 2025-02-12 09:00:52 -05:00 committed by GitHub
parent 02262476d6
commit 9c0f9cf43d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 102 additions and 15 deletions

View File

@ -1,6 +1,7 @@
using System.Globalization; using System.Globalization;
using Bit.Billing.Models; using Bit.Billing.Models;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -25,6 +26,7 @@ public class BitPayController : Controller
private readonly IMailService _mailService; private readonly IMailService _mailService;
private readonly IPaymentService _paymentService; private readonly IPaymentService _paymentService;
private readonly ILogger<BitPayController> _logger; private readonly ILogger<BitPayController> _logger;
private readonly IPremiumUserBillingService _premiumUserBillingService;
public BitPayController( public BitPayController(
IOptions<BillingSettings> billingSettings, IOptions<BillingSettings> billingSettings,
@ -35,7 +37,8 @@ public class BitPayController : Controller
IProviderRepository providerRepository, IProviderRepository providerRepository,
IMailService mailService, IMailService mailService,
IPaymentService paymentService, IPaymentService paymentService,
ILogger<BitPayController> logger) ILogger<BitPayController> logger,
IPremiumUserBillingService premiumUserBillingService)
{ {
_billingSettings = billingSettings?.Value; _billingSettings = billingSettings?.Value;
_bitPayClient = bitPayClient; _bitPayClient = bitPayClient;
@ -46,6 +49,7 @@ public class BitPayController : Controller
_mailService = mailService; _mailService = mailService;
_paymentService = paymentService; _paymentService = paymentService;
_logger = logger; _logger = logger;
_premiumUserBillingService = premiumUserBillingService;
} }
[HttpPost("ipn")] [HttpPost("ipn")]
@ -145,10 +149,7 @@ public class BitPayController : Controller
if (user != null) if (user != null)
{ {
billingEmail = user.BillingEmailAddress(); billingEmail = user.BillingEmailAddress();
if (await _paymentService.CreditAccountAsync(user, tx.Amount)) await _premiumUserBillingService.Credit(user, tx.Amount);
{
await _userRepository.ReplaceAsync(user);
}
} }
} }
else if (tx.ProviderId.HasValue) else if (tx.ProviderId.HasValue)

View File

@ -1,6 +1,7 @@
using System.Text; using System.Text;
using Bit.Billing.Models; using Bit.Billing.Models;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -23,6 +24,7 @@ public class PayPalController : Controller
private readonly ITransactionRepository _transactionRepository; private readonly ITransactionRepository _transactionRepository;
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IProviderRepository _providerRepository; private readonly IProviderRepository _providerRepository;
private readonly IPremiumUserBillingService _premiumUserBillingService;
public PayPalController( public PayPalController(
IOptions<BillingSettings> billingSettings, IOptions<BillingSettings> billingSettings,
@ -32,7 +34,8 @@ public class PayPalController : Controller
IPaymentService paymentService, IPaymentService paymentService,
ITransactionRepository transactionRepository, ITransactionRepository transactionRepository,
IUserRepository userRepository, IUserRepository userRepository,
IProviderRepository providerRepository) IProviderRepository providerRepository,
IPremiumUserBillingService premiumUserBillingService)
{ {
_billingSettings = billingSettings?.Value; _billingSettings = billingSettings?.Value;
_logger = logger; _logger = logger;
@ -42,6 +45,7 @@ public class PayPalController : Controller
_transactionRepository = transactionRepository; _transactionRepository = transactionRepository;
_userRepository = userRepository; _userRepository = userRepository;
_providerRepository = providerRepository; _providerRepository = providerRepository;
_premiumUserBillingService = premiumUserBillingService;
} }
[HttpPost("ipn")] [HttpPost("ipn")]
@ -257,10 +261,9 @@ public class PayPalController : Controller
{ {
var user = await _userRepository.GetByIdAsync(transaction.UserId.Value); 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(); billingEmail = user.BillingEmailAddress();
} }
} }

View File

@ -6,6 +6,8 @@ namespace Bit.Core.Billing.Services;
public interface IPremiumUserBillingService public interface IPremiumUserBillingService
{ {
Task Credit(User user, decimal amount);
/// <summary> /// <summary>
/// <para>Establishes the Stripe entities necessary for a Bitwarden <see cref="User"/> using the provided <paramref name="sale"/>.</para> /// <para>Establishes the Stripe entities necessary for a Bitwarden <see cref="User"/> using the provided <paramref name="sale"/>.</para>
/// <para> /// <para>

View File

@ -27,6 +27,57 @@ public class PremiumUserBillingService(
ISubscriberService subscriberService, ISubscriberService subscriberService,
IUserRepository userRepository) : IPremiumUserBillingService 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<string, string>
{
["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) public async Task Finalize(PremiumUserSale sale)
{ {
var (user, customerSetup, storage) = sale; var (user, customerSetup, storage) = sale;
@ -37,6 +88,37 @@ public class PremiumUserBillingService(
? await CreateCustomerAsync(user, customerSetup) ? await CreateCustomerAsync(user, customerSetup)
: await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = expand }); : 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); var subscription = await CreateSubscriptionAsync(user.Id, customer, storage);
switch (customerSetup.TokenizedPaymentSource) switch (customerSetup.TokenizedPaymentSource)

View File

@ -3,6 +3,7 @@ using Bit.Billing.Controllers;
using Bit.Billing.Test.Utilities; using Bit.Billing.Test.Utilities;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Repositories; using Bit.Core.Repositories;
@ -33,6 +34,7 @@ public class PayPalControllerTests
private readonly ITransactionRepository _transactionRepository = Substitute.For<ITransactionRepository>(); private readonly ITransactionRepository _transactionRepository = Substitute.For<ITransactionRepository>();
private readonly IUserRepository _userRepository = Substitute.For<IUserRepository>(); private readonly IUserRepository _userRepository = Substitute.For<IUserRepository>();
private readonly IProviderRepository _providerRepository = Substitute.For<IProviderRepository>(); private readonly IProviderRepository _providerRepository = Substitute.For<IProviderRepository>();
private readonly IPremiumUserBillingService _premiumUserBillingService = Substitute.For<IPremiumUserBillingService>();
private const string _defaultWebhookKey = "webhook-key"; private const string _defaultWebhookKey = "webhook-key";
@ -385,8 +387,6 @@ public class PayPalControllerTests
_userRepository.GetByIdAsync(userId).Returns(user); _userRepository.GetByIdAsync(userId).Returns(user);
_paymentService.CreditAccountAsync(user, 48M).Returns(true);
var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody); var controller = ConfigureControllerContextWith(logger, _defaultWebhookKey, ipnBody);
var result = await controller.PostIpn(); var result = await controller.PostIpn();
@ -398,9 +398,7 @@ public class PayPalControllerTests
transaction.UserId == userId && transaction.UserId == userId &&
transaction.Amount == 48M)); transaction.Amount == 48M));
await _paymentService.Received(1).CreditAccountAsync(user, 48M); await _premiumUserBillingService.Received(1).Credit(user, 48M);
await _userRepository.Received(1).ReplaceAsync(user);
await _mailService.Received(1).SendAddedCreditAsync(billingEmail, 48M); await _mailService.Received(1).SendAddedCreditAsync(billingEmail, 48M);
} }
@ -544,7 +542,8 @@ public class PayPalControllerTests
_paymentService, _paymentService,
_transactionRepository, _transactionRepository,
_userRepository, _userRepository,
_providerRepository); _providerRepository,
_premiumUserBillingService);
var httpContext = new DefaultHttpContext(); var httpContext = new DefaultHttpContext();