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.
L5Member requests cancellation from mobileRequires a structured reason code and an explanation for other; stores original timestamp/actor, opens one owner-assigned request task, and returns any published cancellation form for immediate signing. Subscription remains live; no cancelAtPeriodEnd, notice/effective date, provider cancellation, plan-change clearing, or cancellation email.
L6Duplicate request / unsupported planDuplicate pending request returns CANCELLATION_REQUEST_ALREADY_PENDING without resetting timestamp or opening another task. Class packs/drop-ins/courses and scheduled/pending/terminal rows are rejected. Externally billed recurring subscriptions are accepted.
L7Staff issue a pending request (initiator: 'member_request')Uses the original request timestamp/reason and channel mobile, computes the statutory effective date, sets cancelAtPeriodEnd=true, promotes the same task to notice, sends confirmation, reuses an unsigned request form, and avoids a duplicate when it was already signed. Replacement staff reason/date/channel and immediate:true cannot alter this path.
L7aStaff tries to replace a pending request with initiator: 'business' or a legacy immediate-cancel body409 CANCELLATION_REQUEST_ALREADY_PENDING; provider, refund, status and request state remain untouched.
L8Member or staff withdraws the issued notice before it takes effectNotice fields clear, retention closes saved, task completes, unsigned cancellation form archives, signed evidence remains. Externally billed or already-ended notices are refused.
L9Cancellation cron sweepsAt 02:30 UTC, due live issued notices become cancelled; membership-ended notification, provider cancellation, retention outcome, settlement, and sweep telemetry retain existing behavior.

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 debtFirst decline: status='debt', debt_since=now, debt_reason='charge_failed', debt_amount_in_cents = agreed price, next_charge_date = first f(N) retry; the renewal cron retries it. Day N unpaid → lifecycle cancels it (non_payment, debt kept).
RN3Clear debtOwner triggers DebtService.clearDebt (or the member pay-debt). Charge fires for debt_amount_in_cents; on success, sub returns to active (also from cancelled for non-payment), debt zeroed, period unchanged, nextChargeDate = currentPeriodEnd.
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.

Retention case (RetentionCaseService)

Request and notice share one retention task. The request creates the owner obligation; staff issuance promotes it rather than creating a second task.

#ScenarioExpected
RC2Member requests cancellationRequired reason code and conditional other explanation are preserved; urgent owner-assigned cancellation_review task due from request timestamp; kind request; published cancellation form returned for signing; no cancellation/end date exists.
RC2aStaff issue that requestSame retentionCaseTaskId and owner; kind becomes notice; original request timestamp anchors legal effective date and notice digest/settlement lifecycle. Pending request form is reused; a request form already signed is not reissued.
RC3Staff acknowledges an open caseretentionAcknowledgedAt set once; a second call is a no-op (no error, no re-stamp). cancellation.case_acknowledged { hours_since_notice }. Audit RETENTION_CASE_ACKNOWLEDGED.
RC4Staff works a case through acknowledgement, multiple contacts, and a saved outcomeretentionContactedAt is set on the FIRST contact only; retentionContactAttempts increments on every contact. GET …/retention/activity returns the current case’s acknowledgement, every contact, and the outcome in chronological order with action kind, actor type/id/name, timestamp, channel, note, sequence, and structured outcome as applicable; prior-case rows are excluded. The staff panel renders the same ledger and identifies that the member stayed. cancellation.case_contacted { attempt, contact_channel, hours_to_first_contact }; audits RETENTION_CASE_ACKNOWLEDGED, every RETENTION_CASE_CONTACTED, and RETENTION_OUTCOME_SET.
RC5Staff sets outcome saved_change_plan while the notice is still pendingretentionOutcome set, task completes, AND the pending notice is withdrawn in the same action (cancelAtPeriodEnd cleared). The member’s unsigned org-wide cancellation form is archived; signed forms remain. cancellation.offer_accepted { retention_outcome, offer_kind: 'change_plan' } + cancellation.notice_withdrawn { withdrawn_by: 'staff_on_behalf' }. Audit RETENTION_OUTCOME_SET.
RC6Staff attempts outcome lost_no_contact (a system-only value)Rejected by retentionOutcomeSchema — only saved* values are staff-settable.
RC7Staff withdraws a notice on the member’s wordNotice columns clear, retentionOutcome='saved' unless already set, task completes, and the member’s unsigned org-wide cancellation form is archived while signed forms remain. noticeWithdrawnByStaff emailed to the member. Audit SUBSCRIPTION_NOTICE_WITHDRAWN_BY_STAFF.
RC7aMember taps I want to stay during a pending request or before an issued notice takes effectPOST …/subscriptions/my/:id/resume; request/notice fields clear, the task completes, unsigned paperwork is revoked, saved outcome is audited with the member actor, and cancellation.notice_withdrawn { withdrawn_by: 'member' } is emitted. The prominent current-subscription action disappears after the refreshed server response.
RC8Member taps I want to stay on an externally billed pending request or noticeSame successful request/task/form cleanup as RC7a. Taikan does not mutate the provider billing agreement. Staff notice withdrawal remains notice-only; staff can close a request through a saved outcome.
RC9Staff or member withdraws when no pending request or live notice exists400; ended rows preserve historical notice fields.
RC10Sweep ends a membership with retentionContactedAt setretentionOutcome='lost_contacted'. Without contact logged: 'lost_no_contact'. Either way the task completes and cancellation.effective { retention_outcome, contact_attempts } fires.
RC11Member withdraws a presalepresaleWithdrawnForMember email + cancellation.presale_withdrawn { had_money: false } fire.
RC12Presale withdrawal staff caseImmediate presaleWithdrawnForStaff email to case owner (fallback: org owner); case kind, task and owner set. Low-priority cancellation_review task has no deadline; no urgent save to chase.
RC13Member who withdrew a presale re-purchases in the same orgLatest open withdrawal case closes as rebought; its task completes if present; cancellation.presale_rebought { days_to_rebuy } fires.
RC14Presale opening-day charge fails, attempt 1Urgent owner-assigned payment_follow_up task and staff email; retentionCaseTaskId prevents duplicate tasks. Member retry notification remains unchanged. cancellation.presale_charge_failed { attempt: 1, exhausted: false }.
RC15Presale’s opening-day charge declinesPresale starts that day, status='debt' for the agreed price, retentionCaseKind='presale_charge_failed' with an urgent owner task; retries per f(N); day N unpaid → cancelled non_payment.
RC16Escalation cron, eligible case unacknowledged past SLANo per-event email; cancellation.case_escalated and audit stage unacknowledged recorded. Case appears in next eligible digest (RC24).
RC17Escalation cron, case within 7 days of cancellationEffectiveAt with no contact loggedSame as RC16 with stage: 'uncontacted_final_week' — independent of RC16; a case can escalate on both stages.
RC18Escalation cron re-runs on an already-escalated (subscription, stage) pairNo second event/audit row — the existing RETENTION_CASE_ESCALATED audit row for that stage is the idempotency check.
RC20GET .../subscriptions/:id response shapeCarries cancellationReasonCode and a retention object ({ kind, taskId, ownerId, acknowledgedAt, contactedAt, contactAttempts, outcome, outcomeAt }); retention: null when no case was ever opened.
RC21Member cancellation-request reason validationreasonCode is required on memberCancellationRequestSchema; reason remains optional except when reasonCode='other'. Staff legal-notice inputs keep both optional. Staff issuance preserves the member request values and ignores replacements for a pending request.
RC22GET /organizations/:orgId/members?paymentStatus=endingReturns members with a live subscription (active|paused|past_due|debt) and cancelAtPeriodEnd=true; each row carries an ending: EndingSummary object (notice/effective dates, reason code, retention summary, owner name) — omitted on every other paymentStatus value. See memberships/behavior.md’s cancellations queue for the full contract and memberships/qa-plan.md for the filter’s own scenarios.
RC23Org has a fresh notice, an overdue case and a final-week caseThe staff newsletter’s daily update carries all three, per active owner/admin — see staff-newsletter/qa-plan.md §5. No separate retention mail.
RC24Daily digest, an org with only one of the three conditions trueOnly that section renders — the other two are omitted entirely (no heading, no empty-state text).
RC25Daily digest runs a second time the same (UTC) daySends nothing — RETENTION_DIGEST_SENT audit claim for (org, day) already exists.
RC26Daily digest, all three sections emptyNo email and no audit claim; quiet days do not consume the per-org/day claim.

Temporary retention pricing

#ScenarioExpected
TP1Staff selects saved_discount on a pending request/noticeOpens the temporary-offer editor; recording the outcome without a price mutation is impossible. Successful price save then records saved_discount, closes the case/task, and revokes unsigned paperwork.
TP2Staff offers 20% for 3 months on active Cardcom/Meshulam/test billingStores 20, 3, the next-charge start, month-clamped exclusive end, quoted amount, reason and actor/time; response/refetch shows exact dates and discounted amount.
TP3Plan uses an external link/provider, missing config, inactive/pending-KYC config, or unsupported providerTemporary mode is unavailable in UI and the API refuses a direct request. Permanent/manual price behavior remains unchanged.
TP4Renewal occurs before start, inside the window, exactly at expiry, and after expiryBefore start uses ordinary pricing; inside uses the live list price reduced by the stored percentage; at/after expiry automatically uses remaining intro pricing or list price. The stored quoted amount never becomes a permanent fallback.
TP5Live list price changes during a bounded offerThe next in-window charge uses the new list price with the stored percentage.
TP6Staff replaces or clears a price arrangementSetting temporary clears the prior permanent mode; setting permanent clears all bounded fields; sending negotiatedPriceInCents:null clears both and restores ordinary pricing.
TP7100% bounded offer renews inside the windowRecurring renewal advances the period without calling a payment gateway; after expiry normal pricing resumes.

Settlement at the effective date

#ScenarioExpected
ST2Member paid through a date AFTER their effective date, org refunds automaticallyExactly one proportional refund computed against stored cancellation_effective_at, not sweep date; settlement audit claim and cancellation.settled record the outcome.
ST3Same, org’s resolveExecution is taskA manual_refund task opens (and goes on to be chased by the overdue-refund sweep); the member is told the refund is on its way, not done (refundManual: true).
ST4Same, org is externally billed / unavailableNo refund attempt. Localized owner-assigned task naming member, plan, amount and effective date; cancellation.settled { refunded: false }; the member’s ended email does not claim money moved.
ST5Paid-through boundary equals effective dateNo charge or refund. When coverage ends earlier, the eligible managed-payment path collects one proportional final tail, never a full renewal; failure never postpones cancellation.
ST6Sweep runs twice over the same due rowThe test that matters. One refund total. The second run finds the audit claim and skips.
ST7Staff already refunded the paying charge before the effective dateNo second refund — the charge’s refund_pending/refunded status is checked first.
ST8Two sweep runs overlap on the same subscriptionThe advisory lock serialises them; the loser skips and writes no claim of its own.
ST9Most recent transaction on the subscription is a FAILED renewalIt is not the paying charge — the older completed charge is. Verify the refund amount is proportional to money that actually arrived.
ST10Gateway throws mid-settlementMembership still ends, the rest of the batch still matures, cancellation.settled { outcome: 'failure' } emitted, and the settlement is not retried on the next run (the claim is already on file) — a human picks it up.
ST11Member emails for a settled cancellationExactly ONE: membershipEnded carrying the refund block. No refundCompleted/refundProcessing receipt (the sweep passes notifyMember: false).
ST12Any other refund caller (staff dialog, refund task completion)Unchanged — notifyMember defaults true, so the member still gets the provider receipt.

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.
  • No e2e spec yet covers the notice/retention-case flow end to end — integration coverage lives in cancellation-flow.int.spec.ts and presale-withdraw.int.spec.ts. Gap: add one.

Renew fix (C1) + status gate (A1)

#ScenarioExpected
RN4Renew with no card on file409 NO_ACTIVE_PAYMENT_METHOD before any charge attempt.
RN5Renew charge declines402 RENEWAL_CHARGE_FAILED; nothing written to the row (no debt opened, period and credits untouched).
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, or a cancelled one still owing (A1)409 DEBT_CLEARANCE_REQUIRED, no charge attempted — paying the debt is the only path back.
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 → debt 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.
PC39Cancellation request while a plan change is scheduledRequest coexists and does not clear the schedule. Staff issuance clears the scheduled plan change before setting the legal cancellation notice.
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 horizonBooking beyond currentPeriodEnd on a cancelAtPeriodEnd=true sub → 409 booking_beyond_subscription_end.
SW14C3 scheduled-change horizonBooking 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.
SW16C4 immediate admin-cancelFuture bookings swept (newSubscriptionId: null, effectiveAt=now); consolidated “membership ended” email sent.
SW17C4 period-end cancellation cronSweep effectiveAt = the period boundary that just passed, not the cron’s wall-clock time.
SW18Non-payment cancellation by the lifecycleSweep (payment_issue) runs after the cancellation commits; entering debt sweeps nothing.
SW19Lifecycle natural end (expired / gift_ended / term_ended)Same sweep (subscription_ended) after the end commits.
SW20C4, freezeNo sweep — freeze is explicitly exempt in v1.
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

See payments/qa-plan.md for the manual-charge/debt-collection scenarios (MCH2-14). 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, refund or “membership ended” email for a membership that never started. presaleWithdrawnForMember confirms release; a low-priority, no-deadline retention record and staff email follow (RC11/RC12).
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.
PW10bStaff record a member’s notice on an active punch card (class_pack) or drop-in409 cancel_unsupported_plan_type, body carries planType. Verify nothing is written — no cancelAtPeriodEnd, no cancellationEffectiveAt, no retention case, and above all no cancellation form for a purchase that has no notice period. These are not continuous transactions (docs/reference/israeli-cancellation-rules.md §3) and only §4’s one-month rule is implemented.
PW10cMember holds ONLY a punch card, on web and on mobileNo cancel affordance anywhere (there is none for any plan type); the card still shows credits, expiry and history.
PW10dStaff end a punch card from the member pageStill works — POST /subscriptions/:id/cancel is immediate, computes no notice period, and is how a gym honours a refund by hand. Only the notice flow is closed to these plan types.
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', 'change_plan'] when the presale plan carries a planGroupId, else ['withdraw_scheduled'] alone; 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.

Presale swap within a plan group

Covered by plan-change.service.unit.spec.ts and plan-change-eligibility.unit.spec.ts. See behavior.md’s Presale swap.

#ScenarioExpected
PSW1scheduled sub swaps to another plan in the SAME planGroupIdSame subscription row (id, createdAt unchanged): planId updated in place. dueNowInCents: 0. No new sub row, no checkout. plan_change.presale_swap emitted; audit SUBSCRIPTION_PLAN_CHANGE_APPLIED { mode: 'presale_swap' }. Presale confirmation email re-sent with the new plan.
PSW2scheduled sub swaps to a plan in a DIFFERENT group, or an ungrouped plan409 PRESALE_SWAP_REQUIRES_SAME_GROUP.
PSW3scheduled sub with a group-less plan attempts any swapSame 409 — nothing to swap to. memberActions never offers change_plan for it in the first place.
PSW4Swap attempted while a charge is already in flight for the subscription (pending transaction with chargeStartedAt set)409 PLAN_CHANGE_CHARGE_IN_FLIGHT.
PSW5Double-click / concurrent swap on the same subscriptionSecond call can’t acquire the per-subscription advisory lock → 409 PLAN_CHANGE_IN_PROGRESS.
PSW6Subscription’s status changes between dispatch and the lock being acquiredRe-verify-inside-the-lock (re-checks status === 'scheduled' and the group match against the freshly-reloaded row) → 409 PLAN_CHANGE_CONFLICT.
PSW7Preview a presale swap (GET .../change-plan/preview)dueNowInCents: 0, openingDayChargeInCents (target plan’s own first-charge price via the same resolution the opening-day promotion charge uses), openingDayAt = the row’s unmoved nextChargeDate, creditsAfter = the row’s own remainingCredits (untouched), bookings: { kept: [], revoked: [] } (presale confers no access, nothing to sweep).
PSW8Target variant’s own seat cap is full, but the member already holds a seat in the groupRefused on the TARGET VARIANT’s own cap only — the group-wide cap is not re-checked (the member already holds a group seat; re-counting them against themselves would wrongly refuse an in-group move whenever the group itself is full).