Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesSubscriptions PlansSubscriptions & Plans — QA Plan

Subscriptions & Plans — QA Plan

Smoke

#ScenarioExpected
S1Create monthly subscription planRow created with interval='monthly', currency inherits from org.
S2Create class-pack plan without classCredits400 Class pack plans require classCredits.
S3Create subscription plan without interval400 Subscription plans require an interval.
S4Create class-pack on Lite tier403 — class_packs feature requires Pro.
S5Soft-delete planisActive=false. Existing subs continue to charge; new purchases blocked.
S6Member buys free planSub created active immediately; no paymentPageUrl; no payment_transactions row.
S7Member buys paid planSub pending + paymentPageUrl returned; on webhook, sub flips active.
S8Member retries abandoned checkoutSame pending sub row reused; fresh paymentPageUrl. No duplicate.
S9Admin enrolls member with skipPaymentSub active; no payment provider call.

Lifecycle

#ScenarioExpected
L1Freeze active substatus='paused', pausedAt=now. Renewal cron skips it. Bookings reject.
L2Resume paused sub after 7 daysstatus='active', pausedAt=null, currentPeriodEnd += 7d.
L3Cancel active sub (admin)status='cancelled', cancelledAt=now. If providerSubscriptionId was set, cancelRecurring is called.
L4Cancel already-cancelled sub400 Subscription is already cancelled.
L5Staff record a member’s notice (initiator: 'member_request')cancelAtPeriodEnd=true, sub stays active. Email + low-priority task.
L6Member looks for a cancel or resume action on web / mobileNone offered; POST /subscriptions/my/:id/cancel-at-period-end and POST /subscriptions/my/:id/resume answer 404.
L8Cancellation cron sweepsAt 02:30 UTC daily, all subs with cancelAtPeriodEnd=true and currentPeriodEnd ≤ now flip to cancelled; member email fires.

Renewal failures (FIT-136 surface)

See payments/qa-plan.md for the same scenarios. Subscription-side check:

#ScenarioExpected
RN1Past_due sub renews on retrystatus='active', attempts=0, period advanced. Membership flips to payment_status='current'.
RN2Sub enters debtstatus='debt', debt_since=now, debt_amount_in_cents=plan.price. Renewal cron will NOT pick it up (selector excludes debt).
RN3Clear debtOwner triggers DebtService.clearDebt. Charge fires for debt_amount_in_cents; on success, sub returns to active, debt zeroed, nextChargeDate restored to max(now, currentPeriodEnd) (A6 — previously stayed NULL forever).
RN3bClear debt double-collection race (A6)Two concurrent clearDebt calls on the same debt sub both charge; the second’s atomic post-charge claim matches 0 rows → that txn flips to refund_pending + a manual_refund task opens, no exception surfaced to that caller.

Cancellation request workflow (mid-cycle refund)

#ScenarioExpected
CR1Member submits request with refundRow created, owner email, member email, urgent cancellation_review task.
CR2Duplicate request400 A cancellation request is already pending.
CR3Owner approves with refundSub canceled, refund flow runs (capability-aware), request → approved, refundTaskId filled if manual. Member email.
CR4Owner rejectsRequest → rejected. Sub stays active. Member email.
CR5Member cancels someone else’s requestService guard (cancellation-requests.service.ts:76): 403 You can only cancel your own subscription.
CR6Approve request whose sub has no completed chargeSub still cancels; refund skipped silently.
CR7Approve request whose linked refund task already existsRefused upstream by payment.service.ts:508 (Refund already in progress).

Credits

#ScenarioExpected
C1Booking deducts credit from class-pack subremaining_credits--.
C2Cancel booking refunds creditremaining_credits++.
C3Booking when credits = 0Service throws No credits remaining.
C4Booking on unlimited subNo-op deduct.
C5Admin adjusts credits by +5remaining_credits += 5.
C6Admin adjusts credits by −100 (more than balance)Clamped to 0.
C7Adjust credits on cancelled sub400 Subscription must be active or paused.
C8Adjust credits on unlimited plan400 This plan has unlimited credits.

Permissions

#ScenarioExpected
P1Member creates plan403
P2Coach creates plan403
P3Owner creates class_pack on Lite403 (feature-gated)
P4Member force-cancels another member’s sub403
P5Coach reads member’s subscriptions200 (allowed)
P6Owner reads cross-org sub404 (filtered out)

E2E

  • apps/web/e2e/specs/plans-purchase.spec.ts — full member purchase including Cardcom test payment.
  • apps/web/e2e/specs/cancellation-flow.spec.ts — member submit → owner approve → refund task.

Renew fix (C1) + status gate (A1)

#ScenarioExpected
RN4Renew with no card on file409 NO_ACTIVE_PAYMENT_METHOD before any charge attempt.
RN5Renew charge declinesSub → past_due only; currentPeriodEnd/remainingCredits untouched (behavior change from pre-fix — old code advanced the period even on failure); 402 RENEWAL_CHARGE_FAILED.
RN6Renew charge succeeds (from past_due)Period advances, credits refill, sub → active, and now also: nextChargeDate = currentPeriodEnd, failedChargeAttempts = 0 (A1 — previously left stale, causing the very next cron tick to double-charge).
RN6bRenew charge succeeds (from cancelled)Same as RN6 — a fully-ended subscription can be renewed with a fresh period.
RN7Renew on Cardcom/iCredit/Morning with the token now attachedCharge actually reaches the provider (pre-fix: silently no-token, provider-dependent failure or accidental success).
RN8Renew an active subscription (A1)409 SUBSCRIPTION_ALREADY_ACTIVE, no charge attempted.
RN9Renew a debt subscription (A1)409 DEBT_CLEARANCE_REQUIRED, no charge attempted — clear-debt is the only path back from debt.
RN10Renew a paused subscription (A1)409 RESUME_FIRST, no charge attempted.
RN11Renew a pending subscription (A1)400, no charge attempted.

Plan change — eligibility matrix (FIT-254 §4.1)

Every row of the eligibility matrix in behavior.md’s Plan change section as an explicit api-e2e assertion — both immediate and scheduled timing, both org-initiated and member self-serve where applicable:

#Old sub stateTimingExpected
PC1activeimmediateAllowed.
PC2activescheduledAllowed.
PC3active + cancel_at_period_end=trueimmediateAllowed; flag implicitly cleared on the new sub (n/a — old sub is terminal; verify no stray flag survives).
PC4active + cancel_at_period_end=truescheduled409 pending_action_conflict.
PC5active + pending cancellation requestimmediate or scheduled409 pending_action_conflict.
PC6active + existing scheduled changeimmediate or scheduledAllowed; overwrites the prior schedule (verify old scheduled_plan_id is replaced, not stacked).
PC7past_dueimmediate or scheduled409 outstanding_balance.
PC8debtimmediate or scheduled409 outstanding_balance.
PC9pausedimmediate or scheduled409 resume_first.
PC10pendingimmediate or scheduled400 “checkout still pending”.
PC11cancelledimmediate or scheduled400 “already cancelled”.
PC12Target plan not isActiveany400 “Target plan is not available”.
PC13Target plan = current planany400 “Already on this plan”.
PC14Target plan already held (non-cancelled) on the same membershipany409 “Already subscribed to this plan”.
PC15Target plan is class_pack/drop_in/courseany400 “must be a subscription-type plan”.
PC16Source plan is class_pack/drop_in/courseany400 “Only subscription-type plans support plan change”.

Plan change — money & mechanics

#ScenarioExpected
PC20computeDueNow property testunusedCredit always in [0, oldPriceInCents]; dueNow never produces a charge below 0 (callers clamp); integer cents throughout, no floating point.
PC21Upgrade via org saved-card chargePending-row-first; charge fails → 402 PLAN_CHANGE_CHARGE_FAILED, DB diff is zero (old sub untouched, no new row).
PC22Upgrade via org saved-card charge, successOld sub cancelled (plan_change), new sub active with fresh period from now, changedFromSubscriptionId set, sweep ran, receipt email sent.
PC23Comp changeNo charge; new sub inherits old sub’s currentPeriodEnd/nextChargeDate exactly; dueNowInCents: 0 in the emitted event.
PC24Upgrade via member checkout, webhook activatesOld sub cancelled only on the true pending→active transition (activated flag); webhook replay does not re-cancel or re-sweep.
PC25Webhook replay / verify-return race on a replaces-checkoutOld-sub cancel + sweep run at most once (gated on activated).
PC26Concurrent change vs. organic bookingBoth run in SERIALIZABLE transactions with retry on 40001 — no lost updates.
PC27Concurrent double change-checkout on the same subCreating a second replaces-checkout deletes the prior pending replaces-sub — at most one in-flight change.
PC28Scheduled change fires at the boundary, charge succeedsplan_id swapped, credits reset, sweep ran (applyInPlace), then charged at the new plan’s live price; SUBSCRIPTION_RENEWED emitted.
PC29Scheduled change fires at the boundary, charge failsSub → past_due on the new plan (already swapped) — no rollback of the swap, no parallel billing path.
PC30Provider not on CHARGE_VERIFIED_PROVIDERS409 provider_charge_unverified (org saved-card charge variant only — checkout/comp are unaffected).
PC31dueNow < ₪1 submitted with billing=charge_saved_card400 — “use period_end or skip_payment instead”.

Plan change — Wave B hardening (FIT-254 review Wave B)

#ScenarioExpected
PC32Double-click org saved-card charge on the same subSecond call can’t acquire the per-sub advisory lock → 409 plan_change_in_progress, adapter never called on the second call.
PC33Sub changes status between dispatch and the lock being acquired (e.g. a concurrent staff cancel)Re-verify-inside-the-lock catches it → 409 plan_change_conflict before any charge attempt.
PC34Old-sub cancel’s guarded UPDATE matches zero rows (raced past the lock — e.g. the renewal cron) after a successful chargeNo new sub row; txn flagged for manual review (pending → refund_pending); 409 plan_change_conflict.
PC35Swap transaction itself throws after a successful charge (serialization exhaustion/crash-adjacent)Same recovery as PC34 — flagged, 409, txn never marked completed.
PC36Happy-path org saved-card chargeOrdering: charge → swap tx (cancel+insert) → txn completed → receipt/events — never completed before the swap commits.
PC37Cross-currency org/member/checkout plan change (old plan currency ≠ target plan currency)409 plan_change_currency_mismatch at every computeDueNow call site (preview, member, org charge, purchase-replaces), before the math runs.
PC38Leftover pending replaces-sub already on the target planDoes NOT conflict (exclusivity check now excludes pending) — retry via memberChangePlan or purchase reuses the row instead of 409ing.
PC39scheduleChange’s guarded write races a concurrent memberCancelAtPeriodEnd (or vice versa)Whichever write loses the race gets 409 pending_action_conflict — the mutual-exclusion invariant can never be violated, not just checked-then-raced.
PC40purchase({ replacesSubscriptionId }) with member-plan-change OFF403, before any read of the replaced subscription.
PC41purchase({ replacesSubscriptionId }) computing dueNow < ₪1400 plan_change_use_schedule — the ≥0 clamp is no longer the only guard.
PC42A second in-flight replaces-checkout supersedes the first (different target plan)The superseded sub’s own pending payment_transactions rows flip to cancelled — a stale hosted-page payment later no-ops with a clear trace.
PC43Non-owner, non-staff caller previews/changes a sub whose plan is NOT subscription-typeGets the same 403/404 as for any other sub — never a 400 revealing the plan-type (ownership/permission resolved before the target-plan lookup).
PC44Non-member of the org cancels a scheduled change403 before the subscription lookup — no existence oracle via 404.
PC45Garbage (non-UUID) :orgId/:subscriptionId/:membershipId on any plan-change/manual-charge/clear-debt/card-registration-list route, or garbage newPlanId query on preview400, never a Postgres-level 500.
PC46Coach previews a plan change (any matrix version)403 — subscriptions:manage required; the legacy view carve-out is gone.
PC47Waitlist promotion inside an immediate charge/comp sweepsweepEngine.sendPendingPromotionEmails fires once, after the swap transaction commits — not before, not at all if the swap transaction throws.
PC48Booking-entitlement sweep throws after an admin cancel / cron flip / webhook plan-change activation already committedThe triggering action still “succeeds” (cancel returns normally; webhook returns 200); a high-priority general task is opened naming the affected subscription(s); payment.sweep_failed (subscriptions path) or payment.activation_failed (webhook path) observability emitted.
PC49Webhook replay on an already-active, plan-change-REPLACED subscriptionremainingCredits is left alone — a plain (non-replaces) renewal replay still resets it to the plan’s allotment as before.

Plan change — Wave C hardening (FIT-254 review Wave C)

#ScenarioExpected
PC50Scheduled-change confirmation email, recipient locale = he/ruEffective date rendered in he-IL/ru-RU format, not hardcoded en-US; no scheduled boundary (defensive) renders the localized “next billing date” fallback.
PC51Entitlement-sweep/plan-change-scheduled email with a malicious session title / plan name / user name (<script>…)Rendered HTML-escaped (&lt;script&gt;), never executable markup.
PC52Same as PC51 but the value contains a literal $&Output preserved intact — no $-pattern corruption from a bare .replace(token, str).
PC53Subscription enters DEBT (3rd consecutive failed charge), revoked bookings > 0Consolidated email uses the payment_issue variant — never subscription_ended’s “membership has ended” copy.
PC54Subscription ends for real (immediate cancel / period-end cron), revoked bookings > 0Consolidated email uses the subscription_ended variant, unchanged.
PC55Any subscription-ended/payment_issue sweep with ZERO revoked bookingsEmail is skipped entirely — no empty-handed email stacked on the cancellation/debt-warning email already sent for the same event.
PC56Plan-change confirmation sweep with zero kept/revokedStill sends — it’s the change confirmation, not conditional on the sweep’s outcome.
PC57Debt-clearance with no active payment method on file409 no_active_payment_method (structured code — the web collect-debt dialog can now map it, previously a plain uncoded 400).
PC58Change-plan dialog, member has no active cardBilling selection defaults to skip_payment (comp), not the disabled charge_saved_card radio (previously selected-but-disabled by default).
PC59Change-plan dialog receives plan_change_in_progress / plan_change_conflict / plan_change_currency_mismatch / no_active_payment_methodLocalized inline copy shown — previously fell through to the generic message.
PC60Member books a session beyond a subscription’s pending end, sees the resulting errorLocalized, locale-date-formatted toast (“your subscription ends on ‹date›…”) — not the raw English fallback. Every other booking error (full session, daily/weekly limit, etc.) is unaffected, still shows the raw message.

Booking-entitlement sweep (FIT-254 §4.6/§5)

#ScenarioExpected
SW1Upgrade to an unlimited/looser planEverything re-attributes; zero revocations; preview matches apply.
SW2New plan disallows overlap where old plan allowed it (or vice versa)New plan’s overlap rule governs re-attribution.
SW3Two same-day sessions, new plan maxBookingsPerDay=1Soonest kept, second revoked (deterministic soonest-first ordering).
SW4Waitlisted booking, new sub’s credits exhausted by kept confirmed bookingsRevoked — a member who can never get a credit shouldn’t squat a waitlist slot.
SW5Booking’s session gets cancelled between preview and applySimply absent from the sweep set at apply time; no error.
SW6Concurrent member booking during the change transactionSERIALIZABLE + retry; no lost updates, sweep and booking converge.
SW7Member books between preview and confirmPreview is advisory; apply recomputes from live data — the confirmation email is the source of truth, not the earlier preview.
SW8Revocationstatus='cancelled' + cancelledAt, no cancellation-window check (admin-cancel semantics), no credit refund to the old sub, one waitlist-promotion attempt per freed confirmed seat.
SW9Untracked (subscription_id IS NULL) or staff bookingNever touched by the sweep, regardless of plan/entitlement changes.
SW10Preview vs. apply on identical (frozen) fixturesByte-identical kept/revoked classification — both share computeSweep/classifyForNewSub.
SW11Consolidated email contentKept/revoked lists in the email match the sweep result exactly; no per-booking cancellation email sent for swept revocations.
SW12Waitlist promotion during a sweepPromoted member gets the existing “You’re in!” email — without a notification-prefs opt-out check (documented gap, W7 audit item).
SW13C3 pending-cancellation horizon, flag onBooking beyond currentPeriodEnd on a cancelAtPeriodEnd=true sub → 409 booking_beyond_subscription_end.
SW14C3 scheduled-change horizon, flag onBooking beyond currentPeriodEnd on a sub with scheduledPlanId set → allowed; day/week caps evaluated against the target plan; overlap + credits still evaluated against the current sub.
SW15C3, flag offBoth checks are skipped entirely — pre-FIT-254 behavior (unlimited horizon booking).
SW16C4 immediate admin-cancel, flag onFuture bookings swept (newSubscriptionId: null, effectiveAt=now); consolidated “membership ended” email sent.
SW17C4 period-end cancellation cron, flag onSweep effectiveAt = the period boundary that just passed, not the cron’s wall-clock time.
SW18C4 debt entry via renewal cron (3rd failure), flag onSweep runs after the debt transition commits.
SW19C4 debt entry via provider webhook, flag onSame sweep as SW18, triggered by WebhookProcessingService.handlePaymentFailed.
SW20C4, freezeNo sweep — freeze is explicitly exempt in v1, regardless of flag state.
SW21C4, flag offNo-op on every trigger — today’s behavior (bookings outlive the ended subscription), unchanged.
SW22sweepEnded automation, plan-changed old subExcluded (cancellation_reason='plan_change') — no win-back automation misfires.
SW23sweepEnded automation, member independently bought a new planExcluded (membership holds another non-terminal subscription-type sub) — fixes the pre-existing false positive too.
SW24sweepEnded automation, genuinely churned memberStill enrolled — the exclusions are additive, not a blanket suppression.
SW25C3-guard: booking a post-boundary session on a sub with a scheduled plan change, straddling week/day window contains a PRE-boundary bookingPre-boundary booking does NOT consume the target plan’s day/week quota — post-boundary counting mirrors the sweep’s own post-boundary-only counting.
SW26C4: candidate overlaps another booking elsewhere on the SAME membership (different subscription, or untracked), target plan disallows overlapRevoked — the overlap baseline is membership-wide, matching book()’s own overlap check, not scoped to the target subscription’s own existing bookings.
SW27C4: untracked booking elsewhere on the membership, same UTC day as a kept candidateDay/week caps unaffected — those stay scoped to the target subscription only.
SW28C5: subscription cancelled/swept between book()’s pre-tx resolve and its write transactionBooking rejected inside the transaction (re-verified status), not silently written against a terminal subscription.

Manual charge / plan change flags

See payments/qa-plan.md for the manual-charge/debt-collection scenarios (MCH1-14). Flag-off behavior for every plan-change endpoint (preview, org change, member change, cancel-scheduled): 403 before any DB/provider work; web UI renders nothing (no button, no menu entry). i18n: en/he/ru parity required for every new key, including RTL rendering of the preview sheet and the new sweep/plan-change emails.

Presale withdraw + member action array (unflagged)

Covered by apps/api/src/subscriptions/presale-withdraw.int.spec.ts, member-action.unit.spec.ts and subscription-display-status.unit.spec.ts.

#ScenarioExpected
PW1Member withdraws from a scheduled presaleRow → cancelled / presale_withdrawn, cancelAtPeriodEnd=false, cancellationEffectiveAt=null, filer = the member. Pending (₪1 tokenisation) transactions → cancelled. Nothing charged, nothing refunded.
PW2Seat releaseThe plan’s seatsTaken drops immediately (seatStatusCondition stops counting cancelled), so a capped presale reopens the seat for someone else.
PW3Member buys the plan again after withdrawingAllowed — purchase only treats active|pending|paused|past_due|debt as blocking.
PW4No cancellation artifactsNo membership_cancellation form issued, no cancellation_review task, no “membership ended” email. Every one of them would assert something untrue about a membership that never started.
PW5Withdraw after a REAL charge settled409 withdraw_requires_cancellation — the member’s path is the notice flow with its refund math.
PW5bWithdraw when the only settled transaction is the ₪1 presale tokenisation (metadata.tokenOnly)Succeeds. Nothing was charged. Regression watch: without the marker this refuses every presale withdrawal, since the tokenisation row is completed/type: 'charge' like any sale.
PW6Withdraw while a charge is in flight409 withdraw_charge_in_flight; the row is untouched.
PW7Withdraw an active/paused/past_due/debt subscription409 withdraw_requires_cancellation, body carries the current status.
PW8Withdraw a pending or cancelled row400 — a different kind of row; the lever is cancel-pending / resume-checkout.
PW9Withdraw somebody else’s subscription404, not 403 — a 403 confirms the id exists. Same for checkout-return and resume-checkout.
PW9bStaff cancel a scheduled presale outrightWorks: cancelled, no refund transaction, seat released. This is what the use_withdraw 409 points staff at when they try to record a member’s notice on a presale.
PW10Staff record a member’s notice on a scheduled subscription409 use_withdraw. Regression watch: it used to succeed and strand the row forever. Verify no cancelAtPeriodEnd / cancellationEffectiveAt is written and no form is issued.
PW11Cancellation cron repair passEvery pre-existing scheduled + cancelAtPeriodEnd=true row is withdrawn (source: 'repair_stuck_scheduled'), regardless of its fabricated effective date; the member’s ORIGINAL cancellation_requested_at/by is preserved; seats are released.
PW11bRepair pass meets a stranded row that HAS since been charged, or has a charge in flightLeft untouched (scheduled, flag intact, in-flight transaction not cancelled) and payment.presale_withdraw_skipped emitted. A background job must not close a membership somebody paid for because a stale flag says so.
PW12Repair pass throwsLogged, and the matured-cancellation sweep still runs — a stranded presale must never block a real cancellation from taking effect.
PW13Healthy scheduled presale during the cronUntouched, seat still held.
PW14Withdraw races the opening-day promotionThe FOR UPDATE re-check refuses (409) rather than closing a membership that just started.
PW15Display statusA withdrawn row reads withdrawn — never cancelled (implies a refund conversation) and never checkout_abandoned (the sale did complete). It stays VISIBLE in the member’s own history.
PW16Staff view of a withdrawn rowVisible on the member page with the withdrawn label; no refund affordance.
PW17memberActions arraypending → ['complete_checkout','cancel_pending']; scheduled → ['withdraw_scheduled']; past_due → ['renew']; debt → ['update_card']; active/paused/externally-billed → []. Never contains an action the endpoint would refuse.
PW18Legacy memberActionscheduled → 'none' (deployed mobile builds are unaffected by withdraw); pending → 'cancel_pending' for every org, now that the cancel-pending flag is gone.
PW19Cancel-pending with no PostHog reachable (e2e, local dev)Works. The flag gate is deleted, so there is nothing left to fail closed — this is what the e2e “cancelled checkout → Cancel purchase” journey exercises.