[PM-38333] feat: Annual billing savings offer on cancel#8010
Draft
kdenney wants to merge 26 commits into
Draft
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.
| 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); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🎟️ 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). Savings are quoted from the billed seat quantity.RedeemAnnualUpgradeOfferCommandvalidates 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, andend_behavior: release.SubscriptionUpdatedHandlerdetects the schedule-triggered monthly to annual transition at renewal and syncsOrganization.PlanTypeto the annual-latest plan. Failures are rethrown so Stripe retries; the transition is idempotent on retry.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 intoResolveAnnualUpgradePhasePlansAsync(detects an annual-upgrade schedule by its annual-latest seat price) withResolveCohortMigrationPhasePlansAsyncas the fallback.IGetChurnOfferCohortMembershipQueryout ofGetChurnMitigationOfferQuery(behavior-preserving) so the annual-upgrade offer and the churn offer share the same cohort-membership check and stay mutually exclusive.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 thePM32581_UseUpdateOrganizationSubscriptionCommandpath.📸 Screenshots