[PM-38333] feat: Annual billing savings offer on cancel - #8010
Conversation
- Bound Phase 2 to one annual term (start at Phase 1 end, end one year later); Stripe rejects unbounded phases - Preserve Phase 1 discounts and carry merged customer and subscription coupons into Phase 2 - Map storage and Secrets Manager line items to their annual prices via OrganizationPlanMigrationPriceMapper instead of collapsing everything onto the Password Manager seat price - Validate the full item mapping before releasing any existing schedule so a failed redemption cannot destroy Track A migration state - Expand discounts.coupon on the subscription fetch and refuse to proceed when discounts are unexpanded
- Gate eligibility on the pm-35215-business-plan-price-migration flag as an ops kill switch; the renewal webhook stays ungated so schedules created before a flag kill still flip PlanType - Suppress the offer once an active schedule already targets the annual-latest seat price, so a redeemed org reopening the cancel dialog is not offered the switch again before renewal - Quote savings from the subscription's billed seat quantity instead of occupied seats, matching what the renewal invoice will charge
…vent loss - Remove TeamsMonthly2019 from the annual-upgrade plan map: its packaged price structure (base bundle plus seat overage) cannot be quoted or mapped by this flow's per-seat logic - Rethrow BillingException from the schedule-triggered annual upgrade webhook leg so Stripe retries the one-shot activation event instead of silently leaving PlanType monthly
…pending annual upgrade query
…eOfferSubscriptionHelpers
…zationSubscriptionHelpers
… content-checked phase)
…gradeLineItemResponseModel
…d CI time-bomb Hardcoded absolute dates in Run_RedeemedSchedule_ReturnsTargetPlanAndLineItems and Run_UpcomingPhaseLacksAnnualSeatPrice_ReturnsNull broke once the real wall clock (no TestClock is set) passed the hardcoded renewal date, collapsing the future phase into the past. Switch to DateTime.UtcNow.AddMonths(-1)/(1) so the past/future split stays stable regardless of run date.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #8010 +/- ##
==========================================
+ Coverage 63.12% 63.35% +0.22%
==========================================
Files 2321 2337 +16
Lines 100802 101412 +610
Branches 9077 9159 +82
==========================================
+ Hits 63636 64249 +613
+ Misses 34972 34950 -22
- Partials 2194 2213 +19 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The AnnualUpgradePreviewRequests doc comment still claimed both sides carry the same coupons, contradicting the updated class and method summaries. Correct it to state that the annual side also carries the customer's coupon.
Merging the customer-level coupon into the annual phase unconditionally re-redeemed a temporary coupon: a once coupon already consumed got applied again, and a repeating coupon restarted its window. The savings quote already filtered to forever coupons, so this was a revenue leak between the quoted and billed figure. Extracted the forever-duration check from the savings calculator into a shared DiscountExtensions.IsForever and reused it at redemption time to gate the merge.
Move the identical ApplyChangesToPhaseItems and PhaseUsesTargetPlanPrices (previously IsPostMigrationPhase) out of AnnualUpgradeSchedulePhaseRebuilder and UpdateOrganizationSubscriptionCommand into a shared internal SchedulePhaseMapper with direct unit tests. Only the phase-level discount projection stays path-specific (reuse for annual upgrade, merge for migration). Also terse the annual-upgrade billing doc comments.
amorask-bitwarden
left a comment
There was a problem hiding this comment.
Excellent work here and thanks for working through all of the Stripe issues. Left some completely non-blocking feedback, but looks great to me.
| return; | ||
| } | ||
|
|
||
| organization.ChangePlan(targetPlan); |
There was a problem hiding this comment.
⛏️ [Non-Blocking] When a business price migration runs, the handler will grant service account grace to an organization that had a plan where their old base service accounts was more than their new base. If an annual upgrade runs on an organization in a migration cohort, it will release the price migration schedule and set up the annual upgrade schedule, which will drop that grace.
In my opinion, I think that's perfectly reasonable given we do the exact same thing when a user upgrades their plan, but wanted to ask @micahblut:
If an organization with a migration cohort schedule that's going to get service account grace (I think it's only like Teams Monthly 2019) does the annual upgrade path, are we cool with them losing the grace we originally allotted?
There was a problem hiding this comment.
@kdenney don't block merge based on this when you get your last review btw. The chances of it ever happening are slim to none and we already made the choice to forfeit grace elsewhere. I just wanted to call it out.
🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-38333
Associated PRs
📔 Objective
Adds a self-serve path for Teams and Enterprise monthly organizations to switch to annual billing at their next renewal, surfaced as a savings callout when an admin selects cost as their cancellation reason on the offboarding survey. Accepting schedules a two-phase Stripe subscription schedule (the current monthly term is left untouched as phase 1, the annual-latest plan starts at renewal as phase 2) with no immediate charge or proration. Declining falls through to the existing cancel behavior.
This server PR also keeps the organization subscription page and in-flight subscription edits consistent while a switch is pending, and syncs the org's plan at renewal. The offboarding survey UI and client wiring ship in the companion clients PR.
Changes
Core/Billing/Organizations/AnnualUpgradeOffer/:GetAnnualUpgradeOfferQuery(savings quote) andRedeemAnnualUpgradeOfferCommand, exposed asGETandPOST .../billing/vnext/annual-upgrade-offer[/redeem]onOrganizationBillingVNextController.AnnualUpgradeOfferPlansmaps each monthly Teams/Enterprise vintage to its annual-latest plan (the same target the price-migration program would move the org to).duration: forevercoupons are passed, since a temporary coupon discounts one whole annual invoice against only a few monthly ones and would present a first-year artifact as a recurring saving.SubscriptionScheduleOwnershipMapperclassifies an attached subscription schedule from its phase metadata as an annual upgrade, a price migration, or foreign. The offer is suppressed at page load and redemption returns a conflict when the schedule is foreign, so a hand-built schedule held for a negotiated renewal is never released. Redemption stampsannual_upgradeon both phases it builds, andUpdateOrganizationSubscriptionCommandrecognizes an annual-upgrade schedule by that metadata rather than by its contents.AnnualUpgradeLineMapperholds the checks the quote and the redemption share (usable discounts, schedule ownership, and every line item resolving to an annual equivalent) as a pure function over a subscription and its plan pair. Redemption no longer re-runs the offer query to revalidate, which takes a redemption from seven Stripe calls to four. Every ineligible redemption answers the same 400, with the specific reason logged rather than returned.RedeemAnnualUpgradeOfferCommandreleases any existing price-migration schedule, then builds the two-phase schedule withend_behavior: release: phase 1 mirrors the current term including its discounts, phase 2 maps to annual-latest prices. Phase 2 merges the customer-level coupon with the subscription's own, customer first and deduplicated, so a customer coupon is never left dormant while the subscription carries its own. Onlyforevercustomer coupons merge: a phase discount is redeemed fresh, so a temporary one would restart its window. Phase 1 keeps mirroring the current term, since merging into the live phase would change the current bill. Item-bound discounts are copied onto the phase items so they survive to renewal. The org's cohort assignment is dropped only after the new schedule is configured, so a failed create leaves the org still identifiable as a migration member.SubscriptionUpdatedHandlerdetects the schedule-triggered monthly to annual transition at renewal and syncsOrganization.PlanType. Every failure rethrows, so the webhook returns a non-200 and Stripe redelivers: the price swap arrives on exactly one event, and a swallowed failure would lose the transition permanently. The expected non-matching cases are early returns rather than exceptions, so they cost nothing. Redelivery is a no-op oncePlanTypeis annual, becauseResolveAnnualLatestPlanTypethen returns null and the handler returns before touching the organization.GetPendingAnnualUpgradeQueryplus a new nullablependingAnnualUpgradefield on the organization subscription response, so the subscription page can show the scheduled annual plan, amounts, and effective date during the window between redemption and renewal. Added additively rather than mutating existing plan fields, which several other subscription-response consumers depend on.UpdateOrganizationSubscriptionCommand: seat, storage, and Secrets Manager edits made while an annual-upgrade schedule is active now carry into the annual phase. Plan resolution is split intoResolveAnnualUpgradePhasePlansAsyncwithResolveCohortMigrationPhasePlansAsyncas the fallback. Annual-upgrade schedules are rebuilt byAnnualUpgradeSchedulePhaseRebuilder, which reproduces the existing discount state (phase-level discounts by reuse, item-bound coupons by copy) and never merges the customer coupon; the price-migration path keeps its own merge behavior. Both paths share the phase-item and price-translation mechanics throughSchedulePhaseMapperand differ only in the phase-level discount projection. This also carries item-bound coupons onto the rebuilt phase items, which the rewriter previously dropped, silently stripping them from the annual phase and from the live subscription.IGetChurnOfferCohortMembershipQueryout ofGetChurnMitigationOfferQuery(behavior-preserving) so the annual-upgrade offer and the churn offer share the same cohort-membership check and stay mutually exclusive.Note: one change reaches outside this feature's footprint.
StripePaymentService.ApplySchedulePhase2DataAsyncoverwrote the subscription's displayed line-item amounts for any active two-phase schedule, which meant every org that redeemed saw annual amounts reported under a still-monthly interval. It is now gated on the schedule not originating from an annual upgrade, leavingpendingAnnualUpgradeas the only surface for an annual-upgrade phase 2.Depends on PM-41066 (server #8078), which is already on main. That change stops
UpdateBillingAddressCommandandUpcomingInvoiceHandlerfrom dropping schedule phase metadata on a phase rebuild. This branch switchesUpdateOrganizationSubscriptionCommandfrom recognizing a schedule by its contents to recognizing it by that metadata, and unlike price IDs, metadata does not survive a rebuild.Feature flags: both endpoints are gated on the new
PM38333_AnnualBillingSavingsflag viaRequireFeature, and the web client checks the same flag before calling.GetPendingAnnualUpgradeQueryand the renewal webhook are deliberately not gated on it, so a switch that was already scheduled still activates and still displays if the flag is later turned off.📸 Screenshots