1
0
mirror of https://github.com/bitwarden/server synced 2026-02-12 06:23:28 +00:00

[PM-31359] Show new price to premium users who have yet to be migrated (#6963)

* test: add tests for legacy pricing pivot in GetBitwardenSubscriptionQuery

* feat(billing): preview next charge at new price for users on legacy Premium pricing

* chore: apply dotnet format
This commit is contained in:
Alex Morask
2026-02-10 11:23:16 -06:00
committed by GitHub
parent 37770b20ae
commit e2c0861050
2 changed files with 149 additions and 10 deletions

View File

@@ -14,6 +14,7 @@ namespace Bit.Core.Billing.Subscriptions.Queries;
using static StripeConstants;
using static Utilities;
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
public interface IGetBitwardenSubscriptionQuery
{
@@ -107,11 +108,28 @@ public class GetBitwardenSubscriptionQuery(
var (cartLevelDiscount, productLevelDiscounts) = GetStripeDiscounts(subscription);
var availablePlan = plans.First(plan => plan.Available);
var onCurrentPricing = passwordManagerSeatsItem.Price.Id == availablePlan.Seat.StripePriceId;
decimal seatCost;
decimal estimatedTax;
if (onCurrentPricing)
{
seatCost = GetCost(passwordManagerSeatsItem);
estimatedTax = await EstimatePremiumTaxAsync(subscription);
}
else
{
seatCost = availablePlan.Seat.Price;
estimatedTax = await EstimatePremiumTaxAsync(subscription, plans, availablePlan);
}
var passwordManagerSeats = new CartItem
{
TranslationKey = "premiumMembership",
Quantity = passwordManagerSeatsItem.Quantity,
Cost = GetCost(passwordManagerSeatsItem),
Cost = seatCost,
Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(passwordManagerSeatsItem))
};
@@ -125,8 +143,6 @@ public class GetBitwardenSubscriptionQuery(
}
: null;
var estimatedTax = await EstimateTaxAsync(subscription);
return new Cart
{
PasswordManager = new PasswordManagerCartItems
@@ -142,15 +158,45 @@ public class GetBitwardenSubscriptionQuery(
#region Utilities
private async Task<decimal> EstimateTaxAsync(Subscription subscription)
private async Task<decimal> EstimatePremiumTaxAsync(
Subscription subscription,
List<PremiumPlan>? plans = null,
PremiumPlan? availablePlan = null)
{
try
{
var invoice = await stripeAdapter.CreateInvoicePreviewAsync(new InvoiceCreatePreviewOptions
var options = new InvoiceCreatePreviewOptions
{
Customer = subscription.Customer.Id,
Subscription = subscription.Id
});
Customer = subscription.Customer.Id
};
if (plans != null && availablePlan != null)
{
options.AutomaticTax = new InvoiceAutomaticTaxOptions
{
Enabled = subscription.AutomaticTax?.Enabled ?? false
};
options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
{
Items = subscription.Items.Select(item =>
{
var isSeatItem = plans.Any(plan => plan.Seat.StripePriceId == item.Price.Id);
return new InvoiceSubscriptionDetailsItemOptions
{
Price = isSeatItem ? availablePlan.Seat.StripePriceId : item.Price.Id,
Quantity = item.Quantity
};
}).ToList()
};
}
else
{
options.Subscription = subscription.Id;
}
var invoice = await stripeAdapter.CreateInvoicePreviewAsync(options);
return GetCost(invoice.TotalTaxes);
}

View File

@@ -461,6 +461,77 @@ public class GetBitwardenSubscriptionQueryTests
Assert.Equal(PlanCadenceType.Annually, result.Cart.Cadence);
}
[Fact]
public async Task Run_UserOnLegacyPricing_ReturnsCostFromPricingService()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Active, legacyPricing: true);
var premiumPlans = CreatePremiumPlans();
var availablePlan = premiumPlans.First(p => p.Available);
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
var previewInvoice = CreateInvoicePreview(totalTax: 150);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(previewInvoice);
var result = await _query.Run(user);
Assert.NotNull(result);
Assert.Equal(availablePlan.Seat.Price, result.Cart.PasswordManager.Seats.Cost);
Assert.Equal(1.50m, result.Cart.EstimatedTax);
}
[Fact]
public async Task Run_UserOnLegacyPricing_CallsPreviewInvoiceWithRebuiltSubscription()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Active, legacyPricing: true);
var premiumPlans = CreatePremiumPlans();
var availablePlan = premiumPlans.First(p => p.Available);
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
var previewInvoice = CreateInvoicePreview();
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(previewInvoice);
await _query.Run(user);
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
Arg.Is<InvoiceCreatePreviewOptions>(opts =>
opts.Subscription == null &&
opts.AutomaticTax != null &&
opts.AutomaticTax.Enabled == true &&
opts.SubscriptionDetails != null &&
opts.SubscriptionDetails.Items.Any(i =>
i.Price == availablePlan.Seat.StripePriceId &&
i.Quantity == 1)));
}
[Fact]
public async Task Run_UserOnCurrentPricing_ReturnsCostFromSubscriptionItem()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Active, legacyPricing: false);
var premiumPlans = CreatePremiumPlans();
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
var result = await _query.Run(user);
Assert.NotNull(result);
Assert.Equal(19.80m, result.Cart.PasswordManager.Seats.Cost);
}
#region Helper Methods
private static User CreateUser()
@@ -477,11 +548,14 @@ public class GetBitwardenSubscriptionQueryTests
private static Subscription CreateSubscription(
string status,
bool includeStorage = false,
bool legacyPricing = false,
DateTime? cancelAt = null,
DateTime? canceledAt = null,
string collectionMethod = "charge_automatically")
{
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
var seatPriceId = legacyPricing ? "price_legacy_premium_seat" : "price_premium_seat";
var seatUnitAmount = legacyPricing ? 1000 : 1980;
var items = new List<SubscriptionItem>
{
new()
@@ -489,8 +563,8 @@ public class GetBitwardenSubscriptionQueryTests
Id = "si_premium_seat",
Price = new Price
{
Id = "price_premium_seat",
UnitAmountDecimal = 1000,
Id = seatPriceId,
UnitAmountDecimal = seatUnitAmount,
Product = new Product { Id = "prod_premium_seat" }
},
Quantity = 1,
@@ -521,6 +595,7 @@ public class GetBitwardenSubscriptionQueryTests
Id = "sub_test123",
Status = status,
Created = DateTime.UtcNow.AddMonths(-1),
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
Customer = new Customer
{
Id = "cus_test123",
@@ -548,6 +623,24 @@ public class GetBitwardenSubscriptionQueryTests
Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "price_premium_seat",
Price = 19.80m,
Provided = 1
},
Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "price_storage",
Price = 4.0m,
Provided = 1
}
},
new()
{
Name = "Premium",
Available = false,
LegacyYear = 2024,
Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "price_legacy_premium_seat",
Price = 10.0m,
Provided = 1
},