Skip to content

[PM-38422] feat: Stack customer-level discount with churn and migration discounts#7831

Open
amorask-bitwarden wants to merge 1 commit into
mainfrom
billing/PM-38422/pre-existing-customer-level-discount-not-preserved-by-churn
Open

[PM-38422] feat: Stack customer-level discount with churn and migration discounts#7831
amorask-bitwarden wants to merge 1 commit into
mainfrom
billing/PM-38422/pre-existing-customer-level-discount-not-preserved-by-churn

Conversation

@amorask-bitwarden

Copy link
Copy Markdown
Contributor

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-38422

📔 Objective

When a churn-mitigation, proactive, or milestone-migration coupon is applied to a subscription that already carries a customer-level discount (customer.discount), the new coupon was written into the subscription's (or schedule phase's) discounts array on its own. Because Stripe lets a subscription/phase-level discount override the customer-level one, the pre-existing customer discount was silently dropped from billing.

This adds a shared DiscountExtensions.MergeDiscountCouponIds extension (customer coupon first, deduped by id with StringComparison.Ordinal, order-preserving) that merges the customer-level discount with the existing subscription/phase discounts and the newly applied coupon so they stack. It is applied wherever a discounts array is written and a pre-existing customer discount could be shadowed:

  • Churn-mitigation redeem (churn-only subscription update + migration-cohort Phase 2)
  • Premium / families / business price-increase scheduler Phase 2 builders
  • Premium-storage and billing-address schedule rebuilds, and the worker UpcomingInvoiceHandler automatic-tax rebuild — future phases only (active phases are mirrored verbatim so a discount is not re-applied on the already-billed period)
  • Org subscription-update schedule rebuild — future phase only
  • GetBitwardenSubscriptionQuery dedups the carried coupon so the tax preview does not double-count it

The customer coupon is carried regardless of Coupon.Valid (an active customer.discount is one Stripe is already applying). Unblocks migration cohorts that include customers with pre-existing discounts. Unit tests cover the helper, the stacking/repro/self-heal cases, and the active-vs-future-phase boundary at each site.

…on discounts

Subscription- and schedule-phase-level discounts override a customer-level discount in Stripe, so applying a churn-mitigation, proactive, or milestone-migration coupon silently dropped a pre-existing customer.discount from billing. A shared DiscountExtensions.MergeDiscountCouponIds extension now carries the customer coupon into the subscription/phase discounts array so the two stack (deduped, customer coupon first). Unblocks migration cohorts that include customers with existing discounts.
@amorask-bitwarden amorask-bitwarden added the ai-review Request a Claude code review label Jun 17, 2026
@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Reviewed this billing change that stacks a pre-existing customer-level discount (customer.discount) with newly applied churn, proactive, and milestone-migration coupons, rather than letting the subscription/phase-level discount silently shadow it. The new DiscountExtensions.MergeDiscountCouponIds helper merges customer-first, deduplicates by id with StringComparison.Ordinal, and is null/empty-safe; it is applied consistently across the redeem, scheduler, storage, billing-address, and worker-rebuild sites, gating carry-over to future phases (StartDate > now) so a discount is never re-applied on the already-billed active period. I traced the active-vs-future phase boundary, the merged-set self-heal no-op guard, and the DistinctBy dedup in the tax-preview query, and verified each against its unit tests.

Code Review Details

No findings. The change is well-structured with comprehensive unit coverage:

  • Helper edge cases (null/empty interleaving, ordinal case-sensitivity, customer-first ordering, invalid-coupon carry-through, cross-source dedup).
  • Active-vs-future phase boundary asserted at each call site, including the consumed-phase suppression path.
  • Self-heal-on-retry and true-no-op (preserves ChurnDiscountAppliedDate) cases for the churn redeem flow.
  • Tax-preview dedup so a coupon present on both the customer and the mirrored Phase 2 list is passed once.

Null-safety (customerDiscount?.Coupon?.Id, existingDiscountCouponIds ?? [], IsNullOrEmpty guards, the coupon is not null filter before DistinctBy) is handled throughout. The validity-gating difference between the merge helper (carries an active customer.discount regardless of Coupon.Valid) and the tax-preview query (which filters on validity) affects only the displayed estimate and matches the pre-existing behavior of that path.

@sonarqubecloud

Copy link
Copy Markdown

@amorask-bitwarden amorask-bitwarden marked this pull request as ready for review June 17, 2026 19:45
@amorask-bitwarden amorask-bitwarden requested a review from a team as a code owner June 17, 2026 19:45
@codecov

codecov Bot commented Jun 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.29730% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.23%. Comparing base (19cb812) to head (18fd786).

Files with missing lines Patch % Lines
...tion/Commands/RedeemChurnMitigationOfferCommand.cs 85.71% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7831      +/-   ##
==========================================
+ Coverage   61.22%   61.23%   +0.01%     
==========================================
  Files        2209     2209              
  Lines       97716    97723       +7     
  Branches     8815     8818       +3     
==========================================
+ Hits        59824    59841      +17     
+ Misses      35768    35760       -8     
+ Partials     2124     2122       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@kdenney kdenney left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 Very clean design; nice!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants