1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-04 12:40:22 -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 Alex Morask
parent e4d862fe6e
commit 858506b021
No known key found for this signature in database
GPG Key ID: 23E38285B743E3A8
5 changed files with 102 additions and 15 deletions

View File

@ -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<BitPayController> _logger;
private readonly IPremiumUserBillingService _premiumUserBillingService;
public BitPayController(
IOptions<BillingSettings> billingSettings,
@ -35,7 +37,8 @@ public class BitPayController : Controller
IProviderRepository providerRepository,
IMailService mailService,
IPaymentService paymentService,
ILogger<BitPayController> logger)
ILogger<BitPayController> 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)

View File

@ -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> 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();
}
}

View File

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

View File

@ -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<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)
{
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)

View File

@ -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<ITransactionRepository>();
private readonly IUserRepository _userRepository = Substitute.For<IUserRepository>();
private readonly IProviderRepository _providerRepository = Substitute.For<IProviderRepository>();
private readonly IPremiumUserBillingService _premiumUserBillingService = Substitute.For<IPremiumUserBillingService>();
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();