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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user