mirror of
https://github.com/bitwarden/server.git
synced 2026-04-29 03:50:41 -05:00
Include schedule Phase 2 discount in premium tax estimate preview (#7385)
When a subscription has an active schedule during the ~15-day window before renewal, the invoice preview for tax estimation was built with the new price but without the Phase 2 discount coupon. This caused the estimated tax on the subscription page to be higher than what Stripe would actually charge. Pass the coupon ID from the schedule's Phase 2 discount through to EstimatePremiumTaxAsync so it is included in the InvoiceCreatePreviewOptions.
This commit is contained in:
@@ -109,9 +109,9 @@ public class GetBitwardenSubscriptionQuery(
|
||||
|
||||
var (cartLevelDiscount, productLevelDiscounts) = GetStripeDiscounts(subscription);
|
||||
|
||||
var scheduleDiscount = cartLevelDiscount == null
|
||||
var (scheduleDiscount, scheduleCouponId) = cartLevelDiscount == null
|
||||
? await GetSchedulePhase2DiscountAsync(subscription)
|
||||
: null;
|
||||
: (null, (string?)null);
|
||||
|
||||
var availablePlan = plans.First(plan => plan.Available);
|
||||
var onCurrentPricing = passwordManagerSeatsItem.Price.Id == availablePlan.Seat.StripePriceId;
|
||||
@@ -127,7 +127,7 @@ public class GetBitwardenSubscriptionQuery(
|
||||
else
|
||||
{
|
||||
seatCost = availablePlan.Seat.Price;
|
||||
estimatedTax = await EstimatePremiumTaxAsync(subscription, plans, availablePlan);
|
||||
estimatedTax = await EstimatePremiumTaxAsync(subscription, plans, availablePlan, scheduleCouponId);
|
||||
}
|
||||
|
||||
var passwordManagerSeats = new CartItem
|
||||
@@ -166,7 +166,8 @@ public class GetBitwardenSubscriptionQuery(
|
||||
private async Task<decimal> EstimatePremiumTaxAsync(
|
||||
Subscription subscription,
|
||||
List<PremiumPlan>? plans = null,
|
||||
PremiumPlan? availablePlan = null)
|
||||
PremiumPlan? availablePlan = null,
|
||||
string? couponId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -195,6 +196,11 @@ public class GetBitwardenSubscriptionQuery(
|
||||
};
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
if (couponId != null)
|
||||
{
|
||||
options.Discounts = [new InvoiceDiscountOptions { Coupon = couponId }];
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -248,11 +254,11 @@ public class GetBitwardenSubscriptionQuery(
|
||||
return (cartLevel.FirstOrDefault(), productLevel);
|
||||
}
|
||||
|
||||
private async Task<BitwardenDiscount?> GetSchedulePhase2DiscountAsync(Subscription subscription)
|
||||
private async Task<(BitwardenDiscount? Discount, string? CouponId)> GetSchedulePhase2DiscountAsync(Subscription subscription)
|
||||
{
|
||||
if (string.IsNullOrEmpty(subscription.ScheduleId))
|
||||
{
|
||||
return null;
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
try
|
||||
@@ -265,17 +271,18 @@ public class GetBitwardenSubscriptionQuery(
|
||||
|
||||
if (schedule.Status != SubscriptionScheduleStatus.Active || schedule.Phases.Count < 2)
|
||||
{
|
||||
return null;
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
return schedule.Phases[1].Discounts?.FirstOrDefault()?.Coupon;
|
||||
var discount = schedule.Phases[1].Discounts?.FirstOrDefault();
|
||||
return (discount?.Coupon, discount?.CouponId);
|
||||
}
|
||||
catch (StripeException stripeException)
|
||||
{
|
||||
logger.LogError(stripeException,
|
||||
"Failed to retrieve subscription schedule ({ScheduleID}) for discount resolution",
|
||||
subscription.ScheduleId);
|
||||
return null;
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -751,6 +751,86 @@ public class GetBitwardenSubscriptionQueryTests
|
||||
Assert.Null(result.Cart.PasswordManager.Seats.Discount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_UserOnLegacyPricing_WithScheduleDiscount_IncludesCouponInTaxPreview()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active, legacyPricing: true);
|
||||
subscription.ScheduleId = "sub_sched_test";
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
var schedule = CreateSubscriptionSchedule(percentOff: 30, couponId: "milestone-2c");
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview(totalTax: 150));
|
||||
_stripeAdapter.GetSubscriptionScheduleAsync("sub_sched_test", Arg.Any<SubscriptionScheduleGetOptions>())
|
||||
.Returns(schedule);
|
||||
|
||||
await _query.Run(user);
|
||||
|
||||
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
|
||||
Arg.Is<InvoiceCreatePreviewOptions>(opts =>
|
||||
opts.Subscription == null &&
|
||||
opts.SubscriptionDetails != null &&
|
||||
opts.Discounts != null &&
|
||||
opts.Discounts.Count == 1 &&
|
||||
opts.Discounts[0].Coupon == "milestone-2c"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_UserOnLegacyPricing_WithScheduleNoDiscount_DoesNotIncludeCouponInTaxPreview()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active, legacyPricing: true);
|
||||
subscription.ScheduleId = "sub_sched_test";
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
var schedule = CreateSubscriptionSchedule(includePhase2: false);
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
_stripeAdapter.GetSubscriptionScheduleAsync("sub_sched_test", Arg.Any<SubscriptionScheduleGetOptions>())
|
||||
.Returns(schedule);
|
||||
|
||||
await _query.Run(user);
|
||||
|
||||
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
|
||||
Arg.Is<InvoiceCreatePreviewOptions>(opts =>
|
||||
opts.Subscription == null &&
|
||||
opts.SubscriptionDetails != null &&
|
||||
opts.Discounts == null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_UserOnLegacyPricing_WithSchedulePhase2NoDiscount_DoesNotIncludeCouponInTaxPreview()
|
||||
{
|
||||
var user = CreateUser();
|
||||
var subscription = CreateSubscription(SubscriptionStatus.Active, legacyPricing: true);
|
||||
subscription.ScheduleId = "sub_sched_test";
|
||||
var premiumPlans = CreatePremiumPlans();
|
||||
var schedule = CreateSubscriptionSchedule();
|
||||
|
||||
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
|
||||
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
|
||||
.Returns(CreateInvoicePreview());
|
||||
_stripeAdapter.GetSubscriptionScheduleAsync("sub_sched_test", Arg.Any<SubscriptionScheduleGetOptions>())
|
||||
.Returns(schedule);
|
||||
|
||||
await _query.Run(user);
|
||||
|
||||
await _stripeAdapter.Received(1).CreateInvoicePreviewAsync(
|
||||
Arg.Is<InvoiceCreatePreviewOptions>(opts =>
|
||||
opts.Subscription == null &&
|
||||
opts.SubscriptionDetails != null &&
|
||||
opts.Discounts == null));
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static User CreateUser()
|
||||
@@ -903,7 +983,8 @@ public class GetBitwardenSubscriptionQueryTests
|
||||
decimal? percentOff = null,
|
||||
long? amountOff = null,
|
||||
string status = StripeConstants.SubscriptionScheduleStatus.Active,
|
||||
bool validCoupon = true)
|
||||
bool validCoupon = true,
|
||||
string? couponId = null)
|
||||
{
|
||||
var phases = new List<SubscriptionSchedulePhase>
|
||||
{
|
||||
@@ -912,13 +993,18 @@ public class GetBitwardenSubscriptionQueryTests
|
||||
|
||||
if (includePhase2)
|
||||
{
|
||||
phases.Add(new SubscriptionSchedulePhase
|
||||
{
|
||||
Discounts = [new SubscriptionSchedulePhaseDiscount
|
||||
var discounts = percentOff != null || amountOff != null
|
||||
? new List<SubscriptionSchedulePhaseDiscount>
|
||||
{
|
||||
Coupon = new Coupon { Valid = validCoupon, PercentOff = percentOff, AmountOff = amountOff }
|
||||
}]
|
||||
});
|
||||
new()
|
||||
{
|
||||
CouponId = couponId,
|
||||
Coupon = new Coupon { Id = couponId, Valid = validCoupon, PercentOff = percentOff, AmountOff = amountOff }
|
||||
}
|
||||
}
|
||||
: new List<SubscriptionSchedulePhaseDiscount>();
|
||||
|
||||
phases.Add(new SubscriptionSchedulePhase { Discounts = discounts });
|
||||
}
|
||||
|
||||
return new SubscriptionSchedule
|
||||
|
||||
Reference in New Issue
Block a user