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