Skip to content

[PM-38333] feat: Annual billing savings offer on cancel - #8010

Merged
kdenney merged 133 commits into
mainfrom
billing/PM-38333/annual-billing-savings-on-cancel
Aug 7, 2026
Merged

[PM-38333] feat: Annual billing savings offer on cancel#8010
kdenney merged 133 commits into
mainfrom
billing/PM-38333/annual-billing-savings-on-cancel

Conversation

@kdenney

@kdenney kdenney commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

🎟️ 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

  • New annual-upgrade offer feature under Core/Billing/Organizations/AnnualUpgradeOffer/: GetAnnualUpgradeOfferQuery (savings quote) and RedeemAnnualUpgradeOfferCommand, exposed as GET and POST .../billing/vnext/annual-upgrade-offer[/redeem] on OrganizationBillingVNextController.
  • AnnualUpgradeOfferPlans maps each monthly Teams/Enterprise vintage to its annual-latest plan (the same target the price-migration program would move the org to).
  • Savings are quoted from two Stripe invoice previews, one priced at the subscription's current line items and one at their annual-latest equivalents. Stripe does the arithmetic, so the figure covers every priced slot (password manager seats, additional storage, Secrets Manager seats and service accounts), reflects the amounts Stripe actually holds rather than catalog list prices, and applies discounts at both the invoice and line-item level with correct product scoping. Only duration: forever coupons 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.
  • SubscriptionScheduleOwnershipMapper classifies 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 stamps annual_upgrade on both phases it builds, and UpdateOrganizationSubscriptionCommand recognizes an annual-upgrade schedule by that metadata rather than by its contents.
  • AnnualUpgradeLineMapper holds 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.
  • RedeemAnnualUpgradeOfferCommand releases any existing price-migration schedule, then builds the two-phase schedule with end_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. Only forever customer 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.
  • SubscriptionUpdatedHandler detects the schedule-triggered monthly to annual transition at renewal and syncs Organization.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 once PlanType is annual, because ResolveAnnualLatestPlanType then returns null and the handler returns before touching the organization.
  • GetPendingAnnualUpgradeQuery plus a new nullable pendingAnnualUpgrade field 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 into ResolveAnnualUpgradePhasePlansAsync with ResolveCohortMigrationPhasePlansAsync as the fallback. Annual-upgrade schedules are rebuilt by AnnualUpgradeSchedulePhaseRebuilder, 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 through SchedulePhaseMapper and 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.
  • Extracted IGetChurnOfferCohortMembershipQuery out of GetChurnMitigationOfferQuery (behavior-preserving) so the annual-upgrade offer and the churn offer share the same cohort-membership check and stay mutually exclusive.
  • Unit tests added across the new queries, commands, mappers, handler, and plan mapping.

Note: one change reaches outside this feature's footprint. StripePaymentService.ApplySchedulePhase2DataAsync overwrote 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, leaving pendingAnnualUpgrade as the only surface for an annual-upgrade phase 2.

Depends on PM-41066 (server #8078), which is already on main. That change stops UpdateBillingAddressCommand and UpcomingInvoiceHandler from dropping schedule phase metadata on a phase rebuild. This branch switches UpdateOrganizationSubscriptionCommand from 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_AnnualBillingSavings flag via RequireFeature, and the web client checks the same flag before calling. GetPendingAnnualUpgradeQuery and 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

image image image

kdenney added 26 commits July 17, 2026 15:06
- 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
…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

codecov Bot commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.33028% with 28 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.35%. Comparing base (41cae44) to head (d8930ad).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...ions/AnnualUpgradeOffer/AnnualUpgradeLineMapper.cs 90.56% 4 Missing and 1 partial ⚠️
...ices/Implementations/SubscriptionUpdatedHandler.cs 91.11% 0 Missing and 4 partials ⚠️
...nualUpgradeOffer/AnnualUpgradeSavingsCalculator.cs 94.82% 0 Missing and 3 partials ⚠️
...gradeOffer/Queries/GetPendingAnnualUpgradeQuery.cs 94.82% 2 Missing and 1 partial ⚠️
.../Commands/UpdateOrganizationSubscriptionCommand.cs 94.28% 0 Missing and 2 partials ⚠️
...ing/Organizations/Schedules/SchedulePhaseMapper.cs 97.53% 0 Missing and 2 partials ⚠️
...Api/Billing/Controllers/OrganizationsController.cs 85.71% 0 Missing and 1 partial ⚠️
src/Core/Billing/Extensions/DiscountExtensions.cs 50.00% 0 Missing and 1 partial ⚠️
...pgradeOffer/AnnualUpgradeSchedulePhaseRebuilder.cs 95.83% 0 Missing and 1 partial ⚠️
...eOffer/Commands/RedeemAnnualUpgradeOfferCommand.cs 99.45% 0 Missing and 1 partial ⚠️
... and 5 more
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.
📢 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 added 4 commits August 5, 2026 12:23
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.
Comment thread src/Core/Billing/Organizations/Commands/UpdateOrganizationSubscriptionCommand.cs Outdated
kdenney added 8 commits August 5, 2026 18:49
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 amorask-bitwarden 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.

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);

@amorask-bitwarden amorask-bitwarden Aug 6, 2026

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.

⛏️ [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?

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.

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

Comment thread src/Api/Billing/Controllers/OrganizationsController.cs Outdated
Comment thread src/Core/Billing/Organizations/Schedules/SchedulePhaseMapper.cs
@kdenney
kdenney requested a review from JimmyVo16 August 7, 2026 18:11
@kdenney
kdenney enabled auto-merge (squash) August 7, 2026 19:04
@kdenney
kdenney merged commit 42d286e into main Aug 7, 2026
43 checks passed
@kdenney
kdenney deleted the billing/PM-38333/annual-billing-savings-on-cancel branch August 7, 2026 20:09
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 t:feature Change Type - Feature Development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants