Skip to content

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

Draft
kdenney wants to merge 26 commits into
mainfrom
billing/PM-38333/annual-billing-savings-on-cancel
Draft

[PM-38333] feat: Annual billing savings offer on cancel#8010
kdenney wants to merge 26 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 the billed seat quantity.
  • RedeemAnnualUpgradeOfferCommand validates the phase 2 price mapping before mutating anything, releases any existing price-migration schedule and drops the org's cohort assignment, then builds the two-phase schedule: phase 1 mirrored (items and discounts), phase 2 mapped to annual-latest prices with customer and subscription discounts merged, and end_behavior: release.
  • SubscriptionUpdatedHandler detects the schedule-triggered monthly to annual transition at renewal and syncs Organization.PlanType to the annual-latest plan. Failures are rethrown so Stripe retries; the transition is idempotent on retry.
  • 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 (detects an annual-upgrade schedule by its annual-latest seat price) with ResolveCohortMigrationPhasePlansAsync as the fallback.
  • 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, handler, and plan mapping.

Feature flags: the offer query and pending-upgrade display are gated on PM35215_BusinessPlanPriceMigration (the price-migration kill switch); the renewal webhook is deliberately not gated, so already-scheduled switches still activate if the flag is later turned off. The mid-schedule edit fix applies on the PM32581_UseUpdateOrganizationSubscriptionCommand path.

📸 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.
public async Task Run_RequestsExpandedPhaseItemPrices()
{
var organization = CreateOrganization(PlanType.TeamsMonthly);
var subscription = SetupSubscription(organization);
Comment on lines +115 to +122
catch (Exception exception)
{
logger.LogWarning(
exception,
"{Query}: Could not resolve pending annual upgrade for Organization ({OrganizationId})",
nameof(GetPendingAnnualUpgradeQuery), organization.Id);
return null;
}
Comment on lines +158 to +163
catch (Exception releaseEx)
{
_logger.LogError(releaseEx,
"{Command}: Failed to release orphaned annual-upgrade schedule ({ScheduleId}) for Organization ({OrganizationId})",
CommandName, schedule.Id, organization.Id);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant