ADR-0016: Plan change, booking-entitlement sweep, and manual charges (FIT-254)
Status: Accepted
Date: 2026-07-22
Context owner: Saar
Issue: FIT-254 · full product reasoning + market research in plans/fit-254-product-spec.md
Context
Three codebase deep-dives (payments, subscriptions/plans, bookings/notifications/automations) plus market research (TeamUp, PushPress, Wodify, Zen Planner, Mindbody, Glofox, Stripe Billing, Arbox, Israeli compliance) surfaced a connected set of gaps:
- Staff had no way to charge a member’s saved card ad-hoc (a desk sale, a make-good, a one-off fee) or collect an accumulated debt — every competitor supports this; Taikan didn’t.
- Switching a member’s plan meant cancel-and-repurchase — no proration, no continuity, no staff upsell flow.
- Nothing stopped a member from booking arbitrarily far into the future on a subscription that was about to end, get downgraded, or was already unpaid — bookings quietly outlived the entitlement that paid for them.
- Two adjacent bugs:
renewSubscriptioncharged with no payment token (worked by accident on some providers, silently granted free periods on failure) and payment methods could be deleted while a live subscription still pointed at them.
This ADR records the load-bearing structural decisions from that work, with the alternatives considered. Full behavioral detail lives in docs/features/payments/behavior.md and docs/features/subscriptions-plans/behavior.md; this document is the “why,” not the “what.”
Decision
1. Proration: day-based unused-credit, upgrades only, never negative
Upgrades charge dueNow = newPriceInCents − floor(oldPriceInCents × daysRemaining / periodDays) immediately, via checkout or a staff-initiated saved-card charge. Downgrades and laterals (dueNow < ₪1) always wait for the period boundary — no proration credit is ever paid back as money, and no credit-balance/wallet concept exists.
Alternatives considered:
- No proration at all (charge full new price on upgrade). Rejected — every surveyed competitor prorates upgrades; charging full price while forfeiting paid old-plan time would be the single most complained-about behavior in a money feature, and it’s a competitive opening (Arbox doesn’t document proration).
- Symmetric proration (refund the difference on downgrade). Rejected — refunding cash on a downgrade opens a straightforward “downgrade for cash” abuse surface and requires a credit-note/wallet system with no product need behind it. PushPress’s “amounts under $1 don’t trigger Now” pattern generalizes cleanly to “any negative/near-zero
dueNowjust waits for the boundary.” - Credit balances that roll forward instead of a one-time proration. Rejected — adds a whole new ledger concept (balance, expiry, multi-currency edge cases) to solve a problem the day-based formula already solves with data we have on hand.
2. Immediate change = cancel-old + create-new (lineage column), not an in-place price swap
An immediate plan change cancels the old subscription row (cancellation_reason='plan_change') and inserts a new one, linked by changed_from_subscription_id. A scheduled change, by contrast, swaps plan_id in place at the boundary — no new row.
Alternatives considered:
- In-place swap for immediate changes too (mutate
plan_id/priceon the same row). Rejected — the existing renewal cron, analytics (MRR/trend), payment-transaction linkage, and automations all assume a subscription row’s plan is stable for its lifetime; an in-place swap would need every one of those to become plan-change-aware. Cancel+create reuses 100% of the existing “subscription lifecycle” machinery (webhook activation, receipt, analytics queries that read liveactiverows) with zero query changes — the lineage column is the only new concept, and it’s purely for audit/support (“what did this used to be”). - Soft “plan history” table instead of a lineage FK on the row itself. Rejected as unnecessary indirection — a single self-referencing FK is sufficient for the “what did this replace” question support/QA actually asks, and a separate table would need its own multi-org isolation and query surface for no additional capability.
The two mechanics (cancel+create for immediate, in-place swap for scheduled) are asymmetric on purpose: an immediate change’s new row needs a fresh period computed from now, which naturally wants a new row anyway (a fresh currentPeriodStart); a scheduled change is, structurally, exactly what the renewal cron already does every day (advance the period on the existing row) — plugging a plan swap into that exact code path before the charge is the smallest possible diff (PD-B5a, “swap-first-then-bill”).
3. replacesSubscriptionId rides the existing checkout; activation-hook replay safety via the activated flag
The member-checkout upgrade path is not a new payment flow — PlansService.purchase gained an optional replacesSubscriptionId, and the webhook activation handler gained a hook that cancels the old subscription only on the literal pending → active transition (captured as a local activated boolean, gated on sub.changedFromSubscriptionId being set). A webhook replay or a verify-return race lands on an already-active sub, so activated stays false and the hook never re-fires.
Alternatives considered:
- A dedicated
replaces-checkoutendpoint/table, separate frompurchase. Rejected — would duplicate pending-row reuse, abandoned-checkout cleanup, and thesuccessUrl/cancelUrlbridge logic thatpurchasealready has correctly. The webhook layer already has a single, well-tested idempotency primitive (status === 'completed'short-circuit / thepending → activetransition); reusing it means the plan-change hook inherits the exact replay safety the rest of the payment system already relies on, for free. - A separate “commit the swap” endpoint called by the client after checkout succeeds. Rejected — trusts the client for a money-adjacent state transition; the webhook (server-to-server, provider-authenticated) is the only trustworthy signal that money actually moved.
4. The booking-entitlement sweep is a first-class abuse control, not a side effect
No surveyed competitor auto-revokes future bookings on a plan change (TeamUp explicitly documents “remove manually”). Taikan’s sweep — soonest-first re-attribution against the new plan’s rules, revoke-with-admin-cancel-semantics for what doesn’t fit, one consolidated email instead of N cancellation emails — was built as a deliberate differentiator, requested explicitly rather than a defensive afterthought.
Alternatives considered:
- Do nothing (match the industry — leave future bookings alone). Rejected — a member on a 2-classes/week plan who upgrades briefly to unlimited, books 15 sessions, then downgrades back would keep all 15 under the old plan’s math forever. This is the same shape of hole class-pack sites without proper entitlement tracking suffer from; the team judged automatic reconciliation more valuable than parity-with-competitors here specifically because “nobody else does it” was read as opportunity, not risk.
- Manual “review bookings” prompt for staff instead of automatic revocation. Rejected — doesn’t scale (a busy front desk won’t audit booking lists on every plan change) and reintroduces the exact quota-banking hole the sweep exists to close.
- Silently cap without notifying. Rejected outright — the mandatory preview (§4.7) and the consolidated post-change email are non-negotiable: a member should never discover a cancelled booking by showing up to class. Preview-before-consent and notify-after-apply are both load-bearing, not nice-to-haves.
The sweep’s two most consequential sub-decisions:
- Deterministic ordering (soonest-first). The alternative (booking-creation-order, or random) either surprises members (“why did my July class survive but my next-week one didn’t?”) or is unauditable. Soonest-first matches “the bookings you keep are the ones nearest” — the most defensible framing when a member calls support.
- Freeze is exempt from the C4 sweep in v1. Freezes are short, staff-mediated, and reversible; revoking bookings during a pause was judged a goodwill risk not worth taking without real usage data. This is the one place C4’s “every subscription-ending path sweeps” claim has a carve-out — documented, not silent.
5. Provider charge allowlist (CHARGE_VERIFIED_PROVIDERS) gates synchronous token charges
Manual charge, clear-debt, and the plan-change saved-card-charge variant only work for providers on a hardcoded allowlist (cardcom, meshulam, test). Morning, iCredit, and Tranzila are excluded until sandbox-verified.
Alternatives considered:
- Allow all configured providers immediately. Rejected on two independent grounds. First,
createCharge(token merchant-initiated transaction) is only actually exercised in production today by the renewal cron for the providers on the allowlist — Morning’s token endpoint is explicitly marked unverified in code (morning.provider.ts:605), so a manual charge against it is untested surface with real money attached. Second, Israeli tax law requires a חשבונית מס/קבלה (tax invoice/receipt) per charge; Cardcom/Meshulam’s document-issuance behavior oncreateChargeis already proven by renewal traffic, while Morning’s needs to be verified as one unit together with its endpoint fix. - Per-org opt-in toggle instead of a global code-level allowlist. Rejected for v1 — the risk isn’t “should this org be allowed,” it’s “does this provider integration actually work for this call shape yet.” A per-org toggle would let an org enable a broken code path for themselves; the allowlist is a statement about provider readiness, not org trust, so it belongs in code, expanded only after verification (a one-line change per the code comment).
6. Event separation from churn automations; sweepEnded exclusions
Plan-change flows never call CancellationNotificationsService, and never emit anything that would trigger membership_ended win-back automations for a plan-changed subscription. AutomationSchedulerService.sweepEnded’s selector — which scans currentPeriodEnd in a trailing window, blind to why a subscription ended — gained two exclusions: cancellation_reason != 'plan_change', and NOT EXISTS another non-terminal subscription-type subscription on the same membership.
Alternatives considered:
- Suppress the automation at the dispatch layer instead of the selector. Rejected — the selector is where the false-positive actually originates (it enrolls the member into the automation queue at all); fixing it there is a smaller, more auditable diff than adding a second gate downstream, and it also happens to fix a pre-existing false positive (a member who independently bought a new plan) for free, since both cases share the same underlying “does this membership actually have no live subscription” question.
- Leave
sweepEndedunchanged and rely on staff to notice/cancel the misfired automation. Rejected outright — silently sending a win-back email to someone who just upgraded is a trust-eroding, easily-avoided bug, not an acceptable trade-off.
7. Flag topology: three feature flags + one hardening flag, deliberately not four independent toggles
admin-card-on-file, admin-plan-change, and member-plan-change gate their own surfaces independently. booking-entitlement-sweep gates only the two hardening call sites (C3’s booking-horizon guard, C4’s sweep-on-subscription-end) — the sweep engine itself, when invoked from inside a plan-change flow, is unconditional. All four are PostHog flags, per-org (organization group), default OFF, fail-closed (=== true required; unset/unreachable/false all mean “off”).
Alternatives considered:
- One flag for all of FIT-254. Rejected — card-on-file, org plan change, and member self-serve plan change are usable independently and have different rollout risk profiles (member-facing self-serve is the highest-risk surface and deliberately rolls out last); bundling them removes the ability to ship card-on-file to a pilot org without also exposing member self-serve plan change.
- Gate the sweep engine itself behind
booking-entitlement-sweepeverywhere, including inside plan-change flows. Rejected — a plan change that runs without its sweep is exactly the quota-banking abuse hole the sweep exists to close; shippingadmin-plan-change/member-plan-changewithout a mandatory sweep would be shipping the vulnerability. The sweep is therefore inseparable from those two flags by design, and only hardens further (C3/C4) behind its own flag. - Rollout order — A (card-on-file, pure addition) → C (booking-horizon hardening) → B org-initiated → B member self-serve. Chosen because member self-serve plan change needs both A (so members have a card-registration path) and C (so booking-horizon behavior is honest) already live to make sense; shipping it first would put the highest-risk, most customer-visible surface in front of unverified plumbing.
Two adjacent fixes — the renewSubscription token/failure-mutation fix (C1) and the payment-method delete guard (C2) — ship unflagged. Both fail toward correctness/safety rather than adding a new capability: the pre-fix behavior (charging with no token; silently advancing a period on a declined card) was judged a bug unsafe to preserve behind a toggle, not a behavior change to gate. This is a narrow exception to the repo’s “propose a flag for every behavioral change” policy (CLAUDE.md) — applied only because the alternative was knowingly shipping a known-broken renewal path a while longer.
Consequences
Positive
- Upgrades feel fair (prorated, immediate) and downgrades can’t be gamed for cash — closes the biggest complaint surface in money features without adding a wallet/credit-note system.
- The sweep closes booking-quota abuse platform-wide (C3+C4), not just inside plan change — a genuine product differentiator the market research didn’t find anywhere else.
- Manual charges + debt collection bring Taikan to parity with every competitor’s desk-charge capability, gated behind role + a verified-provider allowlist + full receipt/refund reuse — no new refund surface to build or QA.
- The
activated-flag replay safety and the cancel+create lineage mechanic reuse existing, already-battle-tested subsystems (webhook idempotency, renewal cron, analytics queries) instead of inventing parallel ones.
Negative
- Two different plan-change mechanics (cancel+create vs. in-place swap) is real cognitive load for anyone reading the code cold — a future engineer has to know which one applies before reasoning about a given subscription row. Documented explicitly in
subscriptions-plans/behavior.mdto mitigate. - The provider allowlist means Morning-only orgs (a real subset of the customer base) can’t use manual charges or plan-change saved-card-charging until Morning’s token endpoint is verified — a real capability gap until that ships.
- Freeze’s exemption from the C4 sweep means a subscription can be paused indefinitely while its future bookings sit un-reconciled — a narrower version of the same hole the sweep otherwise closes, deliberately left open pending usage data.
- The staff scheduled-change badge and the member scheduled-change banner show two different dates today (
planChangeScheduledAtvs.currentPeriodEnd) — a real, live inconsistency, not just a future risk. Tracked for a W7 fix, not blocking this rollout. - Sweep-triggered waitlist-promotion emails bypass the notification-prefs opt-out check that every other booking email respects — a deliberate scope cut (avoiding a new BullMQ/Redis dependency in the sweep engine) that is nonetheless a real behavioral inconsistency members could notice.
metadata.sourceas the only thing distinguishing a manual charge from a future marketplace order is a soft contract (a JSON field, not a schema-enforced discriminator) — cheap now, but anyone touchingpayment_transactionsmetadata later needs to know this convention exists.
Related
plans/fit-254-product-spec.md— full product spec (PD-A1 through PD-B9), market research, resolved-decisions index.docs/features/payments/behavior.md— manual charges, debt collection.docs/features/subscriptions-plans/behavior.md— plan change, booking-entitlement sweep, C1/C3/C4, flag gating.docs/features/subscriptions-plans/data-model.md— the three newsubscriptionscolumns, migration0090_naive_roughhouse.- ADR-0013 — precedent for a fail-closed, per-org PostHog flag gating a new capability (though that ADR’s gate is unconditional/no-flag; this one uses the flag pattern documented in
docs/architecture/auth.md’srbac-v2example). - ADR-0008 — the tier-gate primitive this feature does not use (FIT-254 is flag-gated per-org for staged rollout, not tier-gated by pricing package).