[PM-41064] fix: Preserve discounts across subscription schedule rebuilds - #8278
[PM-41064] fix: Preserve discounts across subscription schedule rebuilds#8278amorask-bitwarden wants to merge 20 commits into
Conversation
Backs the schedule-rebuild discount carry-over fix: live discounts carry by discount id at subscription and phase scope, coupon id at item scope, and never an empty array. Callers migrate in later commits; legacy helpers stay for now.
…empty-array guards
… delete empty-array guards
…d delete empty-array guards
BuildMirroredPhaseOptions re-emitted the current phase's recorded coupon ids, which re-mints a one-time coupon already consumed on the current invoice (verified on a Stripe test clock). Carry only what is still live on the subscription, by discount id, so a consumed coupon carries nothing.
…e active phase BuildPhaseLevelDiscounts injects the customer's account coupon, which an active phase's explicit discount would otherwise suppress -- listing it newly stacks it onto the current period (verified on a Stripe test clock). Carry only the live discounts on the active phase via a new shared BuildCurrentPhaseDiscounts helper; keep the full builder for future phases, which need the customer coupon re-listed to survive their own discounts. The churn command's current-phase carry now uses the same helper.
…test The customer coupon reads only from customer.discount.source.coupon; the discounts.source.coupon expand is unrelated to that assertion.
…d doc BuildPhaseLevelDiscounts already skips null/empty coupon ids, so drop the array-spread-Where-Select ceremony at the three PriceIncreaseScheduler call sites in favor of conditional collection expressions. Tighten the RequireScheduleDiscountExpansions doc: it detects unexpanded discount stubs and a missing customer, not an absent discounts field.
…hase _PreservesPhase1AsIs now sets both a live subscription discount and a customer coupon, asserting the active phase carries only the live discount by id -- command-level parity with the premium-storage coverage.
…phase CreateAndConfigureScheduleAsync built phase 1 (the active period) with the full BuildPhaseLevelDiscounts, injecting the customer coupon on top of any live subscription discount and stacking the two. Carry only the live discounts on the active phase via BuildCurrentPhaseDiscounts, matching the storage and churn rebuild paths; phase 2 keeps the full builder.
🤖 Bitwarden Claude Code ReviewOverall Assessment: APPROVE Re-reviewed after No security, zero-knowledge, or data-exposure concerns; the new and reworded log statements carry only Stripe ids and counts. No dependency manifest, Claude configuration, or skill files in this diff. The No findings at or above the reporting bar. |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #8278 +/- ##
==========================================
+ Coverage 63.48% 63.51% +0.03%
==========================================
Files 2430 2430
Lines 104928 105004 +76
Branches 9505 9521 +16
==========================================
+ Hits 66614 66694 +80
+ Misses 36034 36028 -6
- Partials 2280 2282 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…ive phase The upcoming-invoice handler, billing-address update, and org subscription update still built the active phase with BuildPhaseLevelDiscounts, injecting the customer coupon on top of any live subscription discount and stacking the two. Carry only the live discounts on the active phase via BuildCurrentPhaseDiscounts, matching the premium-storage, churn, and migration scheduler paths; future phases keep the full builder. Flip the three tests that pinned the old carry-everywhere behavior, plus the consumed-milestone case that also asserted the customer coupon on the active phase.
cyprain-okeke
left a comment
There was a problem hiding this comment.
Looking good, the per-scope carry rules read clearly and the expansion guard is nice
🎟️ Tracking
Resolves PM-41064.
📔 Objective
Several billing operations rebuild a Stripe subscription schedule wholesale — every phase is replaced, so any discount not re-listed on the rebuilt phases was silently lost. In some paths this also re-minted a consumed one-time coupon or stacked the customer coupon onto the active phase (an over-discount on the current period).
This PR centralizes the discount-carry rules in
DiscountExtensionsand applies them at every schedule-rebuild site:Sites updated: premium storage change, churn-mitigation redeem, annual-upgrade redeem, org subscription/seat update, billing-address update, and the upcoming-invoice / price-increase scheduler that creates the migration schedules.
Verified end-to-end against Stripe test mode across all affected flows.
Note: manual testing surfaced one pre-existing, out-of-scope gap — the migration creation path drops item-level discounts when building the new-plan phase, while annual-upgrade preserves them — tracked separately.
📸 Screenshots
N/A — no UI changes.