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:
Conner Turnbull
2026-04-03 10:41:33 -04:00
committed by GitHub
parent 5596ffce0d
commit 6eb0c3c3ef
2 changed files with 109 additions and 16 deletions

View File

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

View File

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