Skip to content

Commit fdd5b1f

Browse files
refactor(billing): source cart discounts from schedule phase 2 when attached
1 parent 53dc21c commit fdd5b1f

3 files changed

Lines changed: 158 additions & 59 deletions

File tree

src/Core/Billing/Extensions/DiscountExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ public static class DiscountExtensions
77
public static bool AppliesTo(this Discount discount, SubscriptionItem subscriptionItem)
88
=> discount.Coupon.AppliesTo.Products.Contains(subscriptionItem.Price.Product.Id);
99

10+
public static bool AppliesTo(this Coupon coupon, SubscriptionItem subscriptionItem)
11+
=> coupon.AppliesTo?.Products?.Contains(subscriptionItem.Price.Product.Id) ?? false;
12+
1013
public static bool IsValid(this Discount? discount)
1114
=> discount?.Coupon?.Valid ?? false;
1215
}

src/Core/Billing/Subscriptions/Queries/GetBitwardenSubscriptionQuery.cs

Lines changed: 60 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,8 @@ private async Task<Cart> GetPremiumCartAsync(
107107
var additionalStorageItem = subscription.Items.FirstOrDefault(item =>
108108
plans.Any(plan => plan.Storage.StripePriceId == item.Price.Id));
109109

110-
var (cartLevelDiscount, productLevelDiscounts) = GetStripeDiscounts(subscription);
111-
112-
var (scheduleDiscount, scheduleCouponId) = cartLevelDiscount == null
113-
? await GetSchedulePhase2DiscountAsync(subscription)
114-
: (null, (string?)null);
110+
var coupons = await GetRelevantCouponsAsync(subscription);
111+
var (cartLevelCoupon, productLevelCoupons) = PartitionCouponsByScope(coupons);
115112

116113
var availablePlan = plans.First(plan => plan.Available);
117114
var onCurrentPricing = passwordManagerSeatsItem.Price.Id == availablePlan.Seat.StripePriceId;
@@ -127,15 +124,17 @@ private async Task<Cart> GetPremiumCartAsync(
127124
else
128125
{
129126
seatCost = availablePlan.Seat.Price;
130-
estimatedTax = await EstimatePremiumTaxAsync(subscription, plans, availablePlan, scheduleCouponId);
127+
estimatedTax = await EstimatePremiumTaxAsync(
128+
subscription, plans, availablePlan,
129+
[.. coupons.Select(c => c.Id)]);
131130
}
132131

133132
var passwordManagerSeats = new CartItem
134133
{
135134
TranslationKey = "premiumMembership",
136135
Quantity = passwordManagerSeatsItem.Quantity,
137136
Cost = seatCost,
138-
Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(passwordManagerSeatsItem)) ?? scheduleDiscount
137+
Discount = productLevelCoupons.FirstOrDefault(coupon => coupon.AppliesTo(passwordManagerSeatsItem))
139138
};
140139

141140
var additionalStorage = additionalStorageItem != null
@@ -144,7 +143,7 @@ private async Task<Cart> GetPremiumCartAsync(
144143
TranslationKey = "additionalStorageGB",
145144
Quantity = additionalStorageItem.Quantity,
146145
Cost = GetCost(additionalStorageItem),
147-
Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(additionalStorageItem))
146+
Discount = productLevelCoupons.FirstOrDefault(coupon => coupon.AppliesTo(additionalStorageItem))
148147
}
149148
: null;
150149

@@ -156,18 +155,16 @@ private async Task<Cart> GetPremiumCartAsync(
156155
AdditionalStorage = additionalStorage
157156
},
158157
Cadence = PlanCadenceType.Annually,
159-
Discount = cartLevelDiscount,
158+
Discount = cartLevelCoupon,
160159
EstimatedTax = estimatedTax
161160
};
162161
}
163162

164-
#region Utilities
165-
166163
private async Task<decimal> EstimatePremiumTaxAsync(
167164
Subscription subscription,
168165
List<PremiumPlan>? plans = null,
169166
PremiumPlan? availablePlan = null,
170-
string? couponId = null)
167+
List<string>? couponIds = null)
171168
{
172169
try
173170
{
@@ -185,7 +182,7 @@ private async Task<decimal> EstimatePremiumTaxAsync(
185182

186183
options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
187184
{
188-
Items = subscription.Items.Select(item =>
185+
Items = [.. subscription.Items.Select(item =>
189186
{
190187
var isSeatItem = plans.Any(plan => plan.Seat.StripePriceId == item.Price.Id);
191188

@@ -194,12 +191,12 @@ private async Task<decimal> EstimatePremiumTaxAsync(
194191
Price = isSeatItem ? availablePlan.Seat.StripePriceId : item.Price.Id,
195192
Quantity = item.Quantity
196193
};
197-
}).ToList()
194+
})]
198195
};
199196

200-
if (couponId != null)
197+
if (couponIds is { Count: > 0 })
201198
{
202-
options.Discounts = [new InvoiceDiscountOptions { Coupon = couponId }];
199+
options.Discounts = [.. couponIds.Select(id => new InvoiceDiscountOptions { Coupon = id })];
203200
}
204201
}
205202
else
@@ -223,55 +220,72 @@ private static decimal GetCost(OneOf<SubscriptionItem, List<InvoiceTotalTax>> va
223220
item => (item.Price.UnitAmountDecimal ?? 0) / 100M,
224221
taxes => taxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount) / 100M);
225222

226-
private static (Discount? CartLevel, List<Discount> ProductLevel) GetStripeDiscounts(
227-
Subscription subscription)
223+
/// <summary>
224+
/// Returns the coupons relevant to the subscription's upcoming invoice. When a subscription
225+
/// schedule is attached, Phase 2's discounts are the source of truth (they reflect the
226+
/// upcoming-renewal state, including any preserved current discounts plus migration coupons).
227+
/// Otherwise the subscription's current discounts are used. Customer-level discounts apply
228+
/// independently of the schedule and are always included.
229+
/// </summary>
230+
private async Task<List<Coupon>> GetRelevantCouponsAsync(Subscription subscription)
228231
{
229-
var discounts = new List<Discount>();
232+
var coupons = new List<Coupon>();
230233

231234
if (subscription.Customer.Discount.IsValid())
232235
{
233-
discounts.Add(subscription.Customer.Discount);
236+
coupons.Add(subscription.Customer.Discount.Coupon);
234237
}
235238

236-
discounts.AddRange(subscription.Discounts.Where(discount => discount.IsValid()));
239+
if (!string.IsNullOrEmpty(subscription.ScheduleId))
240+
{
241+
coupons.AddRange(await GetSchedulePhase2CouponsAsync(subscription));
242+
}
243+
else
244+
{
245+
coupons.AddRange((subscription.Discounts ?? [])
246+
.Where(d => d.IsValid())
247+
.Select(d => d.Coupon));
248+
}
249+
250+
return coupons;
251+
}
237252

238-
var cartLevel = new List<Discount>();
239-
var productLevel = new List<Discount>();
253+
private static (Coupon? CartLevel, List<Coupon> ProductLevel) PartitionCouponsByScope(
254+
IEnumerable<Coupon> coupons)
255+
{
256+
var cartLevel = new List<Coupon>();
257+
var productLevel = new List<Coupon>();
240258

241-
foreach (var discount in discounts)
259+
foreach (var coupon in coupons)
242260
{
243-
switch (discount)
261+
switch (coupon)
244262
{
245-
case { Coupon.AppliesTo.Products: null or { Count: 0 } }:
246-
cartLevel.Add(discount);
263+
case { AppliesTo.Products: null or { Count: 0 } }:
264+
case { AppliesTo: null }:
265+
cartLevel.Add(coupon);
247266
break;
248-
case { Coupon.AppliesTo.Products.Count: > 0 }:
249-
productLevel.Add(discount);
267+
case { AppliesTo.Products.Count: > 0 }:
268+
productLevel.Add(coupon);
250269
break;
251270
}
252271
}
253272

254273
return (cartLevel.FirstOrDefault(), productLevel);
255274
}
256275

257-
private async Task<(BitwardenDiscount? Discount, string? CouponId)> GetSchedulePhase2DiscountAsync(Subscription subscription)
276+
private async Task<List<Coupon>> GetSchedulePhase2CouponsAsync(Subscription subscription)
258277
{
259-
if (string.IsNullOrEmpty(subscription.ScheduleId))
260-
{
261-
return (null, null);
262-
}
263-
264278
try
265279
{
266280
var schedule = await stripeAdapter.GetSubscriptionScheduleAsync(subscription.ScheduleId,
267281
new SubscriptionScheduleGetOptions
268282
{
269-
Expand = ["phases.discounts.coupon"]
283+
Expand = ["phases.discounts.coupon.applies_to"]
270284
});
271285

272286
if (schedule.Status != SubscriptionScheduleStatus.Active || schedule.Phases.Count < 2)
273287
{
274-
return (null, null);
288+
return [];
275289
}
276290

277291
var phase2 = schedule.Phases[1];
@@ -282,18 +296,23 @@ private static (Discount? CartLevel, List<Discount> ProductLevel) GetStripeDisco
282296
logger.LogInformation(
283297
"Schedule phase 2 for subscription schedule ({ScheduleID}) has already started, skipping discount display",
284298
subscription.ScheduleId);
285-
return (null, null);
299+
return [];
286300
}
287301

288-
var discount = phase2.Discounts?.FirstOrDefault();
289-
return (discount?.Coupon, discount?.CouponId);
302+
return phase2.Discounts?
303+
.Where(d => d?.Coupon?.Valid == true)
304+
.Select(d => d.Coupon)
305+
.ToList() ?? [];
290306
}
291307
catch (StripeException stripeException)
292308
{
309+
// Rethrow rather than soft-fail. The schedule's coupons feed both the discount display
310+
// and the tax-preview's `options.Discounts` list — silently dropping them would inflate
311+
// the tax estimate the user sees against the new pricing without any error signal.
293312
logger.LogError(stripeException,
294313
"Failed to retrieve subscription schedule ({ScheduleID}) for discount resolution",
295314
subscription.ScheduleId);
296-
return (null, null);
315+
throw;
297316
}
298317
}
299318

@@ -318,6 +337,4 @@ private static (Discount? CartLevel, List<Discount> ProductLevel) GetStripeDisco
318337
return null;
319338
}
320339
}
321-
322-
#endregion
323340
}

0 commit comments

Comments
 (0)