Subscriptions & Plans — QA Plan
Smoke
| # | Scenario | Expected |
|---|---|---|
| S1 | Create monthly subscription plan | Row created with interval='monthly', currency inherits from org. |
| S2 | Create class-pack plan without classCredits | 400 Class pack plans require classCredits. |
| S3 | Create subscription plan without interval | 400 Subscription plans require an interval. |
| S4 | Create class-pack on Lite tier | 403 — class_packs feature requires Pro. |
| S5 | Soft-delete plan | isActive=false. Existing subs continue to charge; new purchases blocked. |
| S6 | Member buys free plan | Sub created active immediately; no paymentPageUrl; no payment_transactions row. |
| S7 | Member buys paid plan | Sub pending + paymentPageUrl returned; on webhook, sub flips active. |
| S8 | Member retries abandoned checkout | Same pending sub row reused; fresh paymentPageUrl. No duplicate. |
| S9 | Admin enrolls member with skipPayment | Sub active; no payment provider call. |
Lifecycle
| # | Scenario | Expected |
|---|---|---|
| L1 | Freeze active sub | status='paused', pausedAt=now. Renewal cron skips it. Bookings reject. |
| L2 | Resume paused sub after 7 days | status='active', pausedAt=null, currentPeriodEnd += 7d. |
| L3 | Cancel active sub (admin) | status='cancelled', cancelledAt=now. If providerSubscriptionId was set, cancelRecurring is called. |
| L4 | Cancel already-cancelled sub | 400 Subscription is already cancelled. |
| L5 | Member requests cancellation from mobile | Requires 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. |
| L6 | Duplicate request / unsupported plan | Duplicate 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. |
| L7 | Staff 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. |
| L7a | Staff tries to replace a pending request with initiator: 'business' or a legacy immediate-cancel body | 409 CANCELLATION_REQUEST_ALREADY_PENDING; provider, refund, status and request state remain untouched. |
| L8 | Member or staff withdraws the issued notice before it takes effect | Notice fields clear, retention closes saved, task completes, unsigned cancellation form archives, signed evidence remains. Externally billed or already-ended notices are refused. |
| L9 | Cancellation cron sweeps | At 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:
| # | Scenario | Expected |
|---|---|---|
| RN1 | Past_due sub renews on retry | status='active', attempts=0, period advanced. Membership flips to payment_status='current'. |
| RN2 | Sub enters debt | First 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). |
| RN3 | Clear debt | Owner 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. |
| RN3b | Clear 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.
| # | Scenario | Expected |
|---|---|---|
| RC2 | Member requests cancellation | Required 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. |
| RC2a | Staff issue that request | Same 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. |
| RC3 | Staff acknowledges an open case | retentionAcknowledgedAt set once; a second call is a no-op (no error, no re-stamp). cancellation.case_acknowledged { hours_since_notice }. Audit RETENTION_CASE_ACKNOWLEDGED. |
| RC4 | Staff works a case through acknowledgement, multiple contacts, and a saved outcome | retentionContactedAt 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. |
| RC5 | Staff sets outcome saved_change_plan while the notice is still pending | retentionOutcome 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. |
| RC6 | Staff attempts outcome lost_no_contact (a system-only value) | Rejected by retentionOutcomeSchema — only saved* values are staff-settable. |
| RC7 | Staff withdraws a notice on the member’s word | Notice 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. |
| RC7a | Member taps I want to stay during a pending request or before an issued notice takes effect | POST …/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. |
| RC8 | Member taps I want to stay on an externally billed pending request or notice | Same 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. |
| RC9 | Staff or member withdraws when no pending request or live notice exists | 400; ended rows preserve historical notice fields. |
| RC10 | Sweep ends a membership with retentionContactedAt set | retentionOutcome='lost_contacted'. Without contact logged: 'lost_no_contact'. Either way the task completes and cancellation.effective { retention_outcome, contact_attempts } fires. |
| RC11 | Member withdraws a presale | presaleWithdrawnForMember email + cancellation.presale_withdrawn { had_money: false } fire. |
| RC12 | Presale withdrawal staff case | Immediate 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. |
| RC13 | Member who withdrew a presale re-purchases in the same org | Latest open withdrawal case closes as rebought; its task completes if present; cancellation.presale_rebought { days_to_rebuy } fires. |
| RC14 | Presale opening-day charge fails, attempt 1 | Urgent 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 }. |
| RC15 | Presale’s opening-day charge declines | Presale 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. |
| RC16 | Escalation cron, eligible case unacknowledged past SLA | No per-event email; cancellation.case_escalated and audit stage unacknowledged recorded. Case appears in next eligible digest (RC24). |
| RC17 | Escalation cron, case within 7 days of cancellationEffectiveAt with no contact logged | Same as RC16 with stage: 'uncontacted_final_week' — independent of RC16; a case can escalate on both stages. |
| RC18 | Escalation cron re-runs on an already-escalated (subscription, stage) pair | No second event/audit row — the existing RETENTION_CASE_ESCALATED audit row for that stage is the idempotency check. |
| RC20 | GET .../subscriptions/:id response shape | Carries cancellationReasonCode and a retention object ({ kind, taskId, ownerId, acknowledgedAt, contactedAt, contactAttempts, outcome, outcomeAt }); retention: null when no case was ever opened. |
| RC21 | Member cancellation-request reason validation | reasonCode 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. |
| RC22 | GET /organizations/:orgId/members?paymentStatus=ending | Returns 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. |
| RC23 | Org has a fresh notice, an overdue case and a final-week case | The staff newsletter’s daily update carries all three, per active owner/admin — see staff-newsletter/qa-plan.md §5. No separate retention mail. |
| RC24 | Daily digest, an org with only one of the three conditions true | Only that section renders — the other two are omitted entirely (no heading, no empty-state text). |
| RC25 | Daily digest runs a second time the same (UTC) day | Sends nothing — RETENTION_DIGEST_SENT audit claim for (org, day) already exists. |
| RC26 | Daily digest, all three sections empty | No email and no audit claim; quiet days do not consume the per-org/day claim. |
Temporary retention pricing
| # | Scenario | Expected |
|---|---|---|
| TP1 | Staff selects saved_discount on a pending request/notice | Opens 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. |
| TP2 | Staff offers 20% for 3 months on active Cardcom/Meshulam/test billing | Stores 20, 3, the next-charge start, month-clamped exclusive end, quoted amount, reason and actor/time; response/refetch shows exact dates and discounted amount. |
| TP3 | Plan uses an external link/provider, missing config, inactive/pending-KYC config, or unsupported provider | Temporary mode is unavailable in UI and the API refuses a direct request. Permanent/manual price behavior remains unchanged. |
| TP4 | Renewal occurs before start, inside the window, exactly at expiry, and after expiry | Before 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. |
| TP5 | Live list price changes during a bounded offer | The next in-window charge uses the new list price with the stored percentage. |
| TP6 | Staff replaces or clears a price arrangement | Setting temporary clears the prior permanent mode; setting permanent clears all bounded fields; sending negotiatedPriceInCents:null clears both and restores ordinary pricing. |
| TP7 | 100% bounded offer renews inside the window | Recurring renewal advances the period without calling a payment gateway; after expiry normal pricing resumes. |
Settlement at the effective date
| # | Scenario | Expected |
|---|---|---|
| ST2 | Member paid through a date AFTER their effective date, org refunds automatically | Exactly one proportional refund computed against stored cancellation_effective_at, not sweep date; settlement audit claim and cancellation.settled record the outcome. |
| ST3 | Same, org’s resolveExecution is task | A 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). |
| ST4 | Same, org is externally billed / unavailable | No 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. |
| ST5 | Paid-through boundary equals effective date | No 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. |
| ST6 | Sweep runs twice over the same due row | The test that matters. One refund total. The second run finds the audit claim and skips. |
| ST7 | Staff already refunded the paying charge before the effective date | No second refund — the charge’s refund_pending/refunded status is checked first. |
| ST8 | Two sweep runs overlap on the same subscription | The advisory lock serialises them; the loser skips and writes no claim of its own. |
| ST9 | Most recent transaction on the subscription is a FAILED renewal | It is not the paying charge — the older completed charge is. Verify the refund amount is proportional to money that actually arrived. |
| ST10 | Gateway throws mid-settlement | Membership 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. |
| ST11 | Member emails for a settled cancellation | Exactly ONE: membershipEnded carrying the refund block. No refundCompleted/refundProcessing receipt (the sweep passes notifyMember: false). |
| ST12 | Any other refund caller (staff dialog, refund task completion) | Unchanged — notifyMember defaults true, so the member still gets the provider receipt. |
Credits
| # | Scenario | Expected |
|---|---|---|
| C1 | Booking deducts credit from class-pack sub | remaining_credits--. |
| C2 | Cancel booking refunds credit | remaining_credits++. |
| C3 | Booking when credits = 0 | Service throws No credits remaining. |
| C4 | Booking on unlimited sub | No-op deduct. |
| C5 | Admin adjusts credits by +5 | remaining_credits += 5. |
| C6 | Admin adjusts credits by −100 (more than balance) | Clamped to 0. |
| C7 | Adjust credits on cancelled sub | 400 Subscription must be active or paused. |
| C8 | Adjust credits on unlimited plan | 400 This plan has unlimited credits. |
Permissions
| # | Scenario | Expected |
|---|---|---|
| P1 | Member creates plan | 403 |
| P2 | Coach creates plan | 403 |
| P3 | Owner creates class_pack on Lite | 403 (feature-gated) |
| P4 | Member force-cancels another member’s sub | 403 |
| P5 | Coach reads member’s subscriptions | 200 (allowed) |
| P6 | Owner reads cross-org sub | 404 (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.tsandpresale-withdraw.int.spec.ts. Gap: add one.
Renew fix (C1) + status gate (A1)
| # | Scenario | Expected |
|---|---|---|
| RN4 | Renew with no card on file | 409 NO_ACTIVE_PAYMENT_METHOD before any charge attempt. |
| RN5 | Renew charge declines | 402 RENEWAL_CHARGE_FAILED; nothing written to the row (no debt opened, period and credits untouched). |
| RN6 | Renew 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). |
| RN6b | Renew charge succeeds (from cancelled) | Same as RN6 — a fully-ended subscription can be renewed with a fresh period. |
| RN7 | Renew on Cardcom/iCredit/Morning with the token now attached | Charge actually reaches the provider (pre-fix: silently no-token, provider-dependent failure or accidental success). |
| RN8 | Renew an active subscription (A1) | 409 SUBSCRIPTION_ALREADY_ACTIVE, no charge attempted. |
| RN9 | Renew 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. |
| RN10 | Renew a paused subscription (A1) | 409 RESUME_FIRST, no charge attempted. |
| RN11 | Renew 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 state | Timing | Expected |
|---|---|---|---|
| PC1 | active | immediate | Allowed. |
| PC2 | active | scheduled | Allowed. |
| PC3 | active + cancel_at_period_end=true | immediate | Allowed; flag implicitly cleared on the new sub (n/a — old sub is terminal; verify no stray flag survives). |
| PC4 | active + cancel_at_period_end=true | scheduled | 409 pending_action_conflict. |
| PC5 | active + pending cancellation request | immediate or scheduled | 409 pending_action_conflict. |
| PC6 | active + existing scheduled change | immediate or scheduled | Allowed; overwrites the prior schedule (verify old scheduled_plan_id is replaced, not stacked). |
| PC7 | past_due | immediate or scheduled | 409 outstanding_balance. |
| PC8 | debt | immediate or scheduled | 409 outstanding_balance. |
| PC9 | paused | immediate or scheduled | 409 resume_first. |
| PC10 | pending | immediate or scheduled | 400 “checkout still pending”. |
| PC11 | cancelled | immediate or scheduled | 400 “already cancelled”. |
| PC12 | Target plan not isActive | any | 400 “Target plan is not available”. |
| PC13 | Target plan = current plan | any | 400 “Already on this plan”. |
| PC14 | Target plan already held (non-cancelled) on the same membership | any | 409 “Already subscribed to this plan”. |
| PC15 | Target plan is class_pack/drop_in/course | any | 400 “must be a subscription-type plan”. |
| PC16 | Source plan is class_pack/drop_in/course | any | 400 “Only subscription-type plans support plan change”. |
Plan change — money & mechanics
| # | Scenario | Expected |
|---|---|---|
| PC20 | computeDueNow property test | unusedCredit always in [0, oldPriceInCents]; dueNow never produces a charge below 0 (callers clamp); integer cents throughout, no floating point. |
| PC21 | Upgrade via org saved-card charge | Pending-row-first; charge fails → 402 PLAN_CHANGE_CHARGE_FAILED, DB diff is zero (old sub untouched, no new row). |
| PC22 | Upgrade via org saved-card charge, success | Old sub cancelled (plan_change), new sub active with fresh period from now, changedFromSubscriptionId set, sweep ran, receipt email sent. |
| PC23 | Comp change | No charge; new sub inherits old sub’s currentPeriodEnd/nextChargeDate exactly; dueNowInCents: 0 in the emitted event. |
| PC24 | Upgrade via member checkout, webhook activates | Old sub cancelled only on the true pending→active transition (activated flag); webhook replay does not re-cancel or re-sweep. |
| PC25 | Webhook replay / verify-return race on a replaces-checkout | Old-sub cancel + sweep run at most once (gated on activated). |
| PC26 | Concurrent change vs. organic booking | Both run in SERIALIZABLE transactions with retry on 40001 — no lost updates. |
| PC27 | Concurrent double change-checkout on the same sub | Creating a second replaces-checkout deletes the prior pending replaces-sub — at most one in-flight change. |
| PC28 | Scheduled change fires at the boundary, charge succeeds | plan_id swapped, credits reset, sweep ran (applyInPlace), then charged at the new plan’s live price; SUBSCRIPTION_RENEWED emitted. |
| PC29 | Scheduled change fires at the boundary, charge fails | Sub → debt on the new plan (already swapped) — no rollback of the swap, no parallel billing path. |
| PC30 | Provider not on CHARGE_VERIFIED_PROVIDERS | 409 provider_charge_unverified (org saved-card charge variant only — checkout/comp are unaffected). |
| PC31 | dueNow < ₪1 submitted with billing=charge_saved_card | 400 — “use period_end or skip_payment instead”. |
Plan change — Wave B hardening (FIT-254 review Wave B)
| # | Scenario | Expected |
|---|---|---|
| PC32 | Double-click org saved-card charge on the same sub | Second call can’t acquire the per-sub advisory lock → 409 plan_change_in_progress, adapter never called on the second call. |
| PC33 | Sub 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. |
| PC34 | Old-sub cancel’s guarded UPDATE matches zero rows (raced past the lock — e.g. the renewal cron) after a successful charge | No new sub row; txn flagged for manual review (pending → refund_pending); 409 plan_change_conflict. |
| PC35 | Swap transaction itself throws after a successful charge (serialization exhaustion/crash-adjacent) | Same recovery as PC34 — flagged, 409, txn never marked completed. |
| PC36 | Happy-path org saved-card charge | Ordering: charge → swap tx (cancel+insert) → txn completed → receipt/events — never completed before the swap commits. |
| PC37 | Cross-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. |
| PC38 | Leftover pending replaces-sub already on the target plan | Does NOT conflict (exclusivity check now excludes pending) — retry via memberChangePlan or purchase reuses the row instead of 409ing. |
| PC39 | Cancellation request while a plan change is scheduled | Request coexists and does not clear the schedule. Staff issuance clears the scheduled plan change before setting the legal cancellation notice. |
| PC41 | purchase({ replacesSubscriptionId }) computing dueNow < ₪1 | 400 plan_change_use_schedule — the ≥0 clamp is no longer the only guard. |
| PC42 | A 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. |
| PC43 | Non-owner, non-staff caller previews/changes a sub whose plan is NOT subscription-type | Gets the same 403/404 as for any other sub — never a 400 revealing the plan-type (ownership/permission resolved before the target-plan lookup). |
| PC44 | Non-member of the org cancels a scheduled change | 403 before the subscription lookup — no existence oracle via 404. |
| PC45 | Garbage (non-UUID) :orgId/:subscriptionId/:membershipId on any plan-change/manual-charge/clear-debt/card-registration-list route, or garbage newPlanId query on preview | 400, never a Postgres-level 500. |
| PC46 | Coach previews a plan change (any matrix version) | 403 — subscriptions:manage required; the legacy view carve-out is gone. |
| PC47 | Waitlist promotion inside an immediate charge/comp sweep | sweepEngine.sendPendingPromotionEmails fires once, after the swap transaction commits — not before, not at all if the swap transaction throws. |
| PC48 | Booking-entitlement sweep throws after an admin cancel / cron flip / webhook plan-change activation already committed | The 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. |
| PC49 | Webhook replay on an already-active, plan-change-REPLACED subscription | remainingCredits 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)
| # | Scenario | Expected |
|---|---|---|
| PC50 | Scheduled-change confirmation email, recipient locale = he/ru | Effective date rendered in he-IL/ru-RU format, not hardcoded en-US; no scheduled boundary (defensive) renders the localized “next billing date” fallback. |
| PC51 | Entitlement-sweep/plan-change-scheduled email with a malicious session title / plan name / user name (<script>…) | Rendered HTML-escaped (<script>), never executable markup. |
| PC52 | Same as PC51 but the value contains a literal $& | Output preserved intact — no $-pattern corruption from a bare .replace(token, str). |
| PC53 | Subscription enters DEBT (3rd consecutive failed charge), revoked bookings > 0 | Consolidated email uses the payment_issue variant — never subscription_ended’s “membership has ended” copy. |
| PC54 | Subscription ends for real (immediate cancel / period-end cron), revoked bookings > 0 | Consolidated email uses the subscription_ended variant, unchanged. |
| PC55 | Any subscription-ended/payment_issue sweep with ZERO revoked bookings | Email is skipped entirely — no empty-handed email stacked on the cancellation/debt-warning email already sent for the same event. |
| PC56 | Plan-change confirmation sweep with zero kept/revoked | Still sends — it’s the change confirmation, not conditional on the sweep’s outcome. |
| PC57 | Debt-clearance with no active payment method on file | 409 no_active_payment_method (structured code — the web collect-debt dialog can now map it, previously a plain uncoded 400). |
| PC58 | Change-plan dialog, member has no active card | Billing selection defaults to skip_payment (comp), not the disabled charge_saved_card radio (previously selected-but-disabled by default). |
| PC59 | Change-plan dialog receives plan_change_in_progress / plan_change_conflict / plan_change_currency_mismatch / no_active_payment_method | Localized inline copy shown — previously fell through to the generic message. |
| PC60 | Member books a session beyond a subscription’s pending end, sees the resulting error | Localized, 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)
| # | Scenario | Expected |
|---|---|---|
| SW1 | Upgrade to an unlimited/looser plan | Everything re-attributes; zero revocations; preview matches apply. |
| SW2 | New plan disallows overlap where old plan allowed it (or vice versa) | New plan’s overlap rule governs re-attribution. |
| SW3 | Two same-day sessions, new plan maxBookingsPerDay=1 | Soonest kept, second revoked (deterministic soonest-first ordering). |
| SW4 | Waitlisted booking, new sub’s credits exhausted by kept confirmed bookings | Revoked — a member who can never get a credit shouldn’t squat a waitlist slot. |
| SW5 | Booking’s session gets cancelled between preview and apply | Simply absent from the sweep set at apply time; no error. |
| SW6 | Concurrent member booking during the change transaction | SERIALIZABLE + retry; no lost updates, sweep and booking converge. |
| SW7 | Member books between preview and confirm | Preview is advisory; apply recomputes from live data — the confirmation email is the source of truth, not the earlier preview. |
| SW8 | Revocation | status='cancelled' + cancelledAt, no cancellation-window check (admin-cancel semantics), no credit refund to the old sub, one waitlist-promotion attempt per freed confirmed seat. |
| SW9 | Untracked (subscription_id IS NULL) or staff booking | Never touched by the sweep, regardless of plan/entitlement changes. |
| SW10 | Preview vs. apply on identical (frozen) fixtures | Byte-identical kept/revoked classification — both share computeSweep/classifyForNewSub. |
| SW11 | Consolidated email content | Kept/revoked lists in the email match the sweep result exactly; no per-booking cancellation email sent for swept revocations. |
| SW12 | Waitlist promotion during a sweep | Promoted member gets the existing “You’re in!” email — without a notification-prefs opt-out check (documented gap, W7 audit item). |
| SW13 | C3 pending-cancellation horizon | Booking beyond currentPeriodEnd on a cancelAtPeriodEnd=true sub → 409 booking_beyond_subscription_end. |
| SW14 | C3 scheduled-change horizon | Booking 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. |
| SW16 | C4 immediate admin-cancel | Future bookings swept (newSubscriptionId: null, effectiveAt=now); consolidated “membership ended” email sent. |
| SW17 | C4 period-end cancellation cron | Sweep effectiveAt = the period boundary that just passed, not the cron’s wall-clock time. |
| SW18 | Non-payment cancellation by the lifecycle | Sweep (payment_issue) runs after the cancellation commits; entering debt sweeps nothing. |
| SW19 | Lifecycle natural end (expired / gift_ended / term_ended) | Same sweep (subscription_ended) after the end commits. |
| SW20 | C4, freeze | No sweep — freeze is explicitly exempt in v1. |
| SW22 | sweepEnded automation, plan-changed old sub | Excluded (cancellation_reason='plan_change') — no win-back automation misfires. |
| SW23 | sweepEnded automation, member independently bought a new plan | Excluded (membership holds another non-terminal subscription-type sub) — fixes the pre-existing false positive too. |
| SW24 | sweepEnded automation, genuinely churned member | Still enrolled — the exclusions are additive, not a blanket suppression. |
| SW25 | C3-guard: booking a post-boundary session on a sub with a scheduled plan change, straddling week/day window contains a PRE-boundary booking | Pre-boundary booking does NOT consume the target plan’s day/week quota — post-boundary counting mirrors the sweep’s own post-boundary-only counting. |
| SW26 | C4: candidate overlaps another booking elsewhere on the SAME membership (different subscription, or untracked), target plan disallows overlap | Revoked — the overlap baseline is membership-wide, matching book()’s own overlap check, not scoped to the target subscription’s own existing bookings. |
| SW27 | C4: untracked booking elsewhere on the membership, same UTC day as a kept candidate | Day/week caps unaffected — those stay scoped to the target subscription only. |
| SW28 | C5: subscription cancelled/swept between book()’s pre-tx resolve and its write transaction | Booking 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.
| # | Scenario | Expected |
|---|---|---|
| PW1 | Member withdraws from a scheduled presale | Row → cancelled / presale_withdrawn, cancelAtPeriodEnd=false, cancellationEffectiveAt=null, filer = the member. Pending (₪1 tokenisation) transactions → cancelled. Nothing charged, nothing refunded. |
| PW2 | Seat release | The plan’s seatsTaken drops immediately (seatStatusCondition stops counting cancelled), so a capped presale reopens the seat for someone else. |
| PW3 | Member buys the plan again after withdrawing | Allowed — purchase only treats active|pending|paused|past_due|debt as blocking. |
| PW4 | No cancellation artifacts | No 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). |
| PW5 | Withdraw after a REAL charge settled | 409 withdraw_requires_cancellation — the member’s path is the notice flow with its refund math. |
| PW5b | Withdraw 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. |
| PW6 | Withdraw while a charge is in flight | 409 withdraw_charge_in_flight; the row is untouched. |
| PW7 | Withdraw an active/paused/past_due/debt subscription | 409 withdraw_requires_cancellation, body carries the current status. |
| PW8 | Withdraw a pending or cancelled row | 400 — a different kind of row; the lever is cancel-pending / resume-checkout. |
| PW9 | Withdraw somebody else’s subscription | 404, not 403 — a 403 confirms the id exists. Same for checkout-return and resume-checkout. |
| PW9b | Staff cancel a scheduled presale outright | Works: 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. |
| PW10 | Staff record a member’s notice on a scheduled subscription | 409 use_withdraw. Regression watch: it used to succeed and strand the row forever. Verify no cancelAtPeriodEnd / cancellationEffectiveAt is written and no form is issued. |
| PW10b | Staff record a member’s notice on an active punch card (class_pack) or drop-in | 409 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. |
| PW10c | Member holds ONLY a punch card, on web and on mobile | No cancel affordance anywhere (there is none for any plan type); the card still shows credits, expiry and history. |
| PW10d | Staff end a punch card from the member page | Still 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. |
| PW11 | Cancellation cron repair pass | Every 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. |
| PW11b | Repair pass meets a stranded row that HAS since been charged, or has a charge in flight | Left 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. |
| PW12 | Repair pass throws | Logged, and the matured-cancellation sweep still runs — a stranded presale must never block a real cancellation from taking effect. |
| PW13 | Healthy scheduled presale during the cron | Untouched, seat still held. |
| PW14 | Withdraw races the opening-day promotion | The FOR UPDATE re-check refuses (409) rather than closing a membership that just started. |
| PW15 | Display status | A 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. |
| PW16 | Staff view of a withdrawn row | Visible on the member page with the withdrawn label; no refund affordance. |
| PW17 | memberActions array | pending → ['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. |
| PW18 | Legacy memberAction | scheduled → '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.
| # | Scenario | Expected |
|---|---|---|
| PSW1 | scheduled sub swaps to another plan in the SAME planGroupId | Same 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. |
| PSW2 | scheduled sub swaps to a plan in a DIFFERENT group, or an ungrouped plan | 409 PRESALE_SWAP_REQUIRES_SAME_GROUP. |
| PSW3 | scheduled sub with a group-less plan attempts any swap | Same 409 — nothing to swap to. memberActions never offers change_plan for it in the first place. |
| PSW4 | Swap attempted while a charge is already in flight for the subscription (pending transaction with chargeStartedAt set) | 409 PLAN_CHANGE_CHARGE_IN_FLIGHT. |
| PSW5 | Double-click / concurrent swap on the same subscription | Second call can’t acquire the per-subscription advisory lock → 409 PLAN_CHANGE_IN_PROGRESS. |
| PSW6 | Subscription’s status changes between dispatch and the lock being acquired | Re-verify-inside-the-lock (re-checks status === 'scheduled' and the group match against the freshly-reloaded row) → 409 PLAN_CHANGE_CONFLICT. |
| PSW7 | Preview 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). |
| PSW8 | Target variant’s own seat cap is full, but the member already holds a seat in the group | Refused 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). |