Payments — QA Plan
Money is paranoid. Each scenario below is a must-pass before any release that touches apps/api/src/payments/. Critical scenarios marked [C] are FIT-133 / FIT-134 / FIT-136 surface area.
Smoke
| # | Scenario | Steps | Expected |
|---|---|---|---|
| S1 | Configure provider | Owner: POST /organizations/:orgId/payment-config with Cardcom test creds. | GET returns config with credentials redacted. Row in payment_provider_configs with encrypted blob. |
| S2 | Purchase a paid plan | Member: POST /plans/:id/purchase → follow paymentPageUrl → pay with Cardcom test card. | Webhook fires; sub pending → active; payment_transactions.status='completed'; card saved as member_payment_methods row; receipt email sent. |
| S3 | Purchase a free plan | Member purchases plan with priceInCents=0. | Sub created with status='active' directly; no paymentPageUrl; no txn row. |
| S4 | Cancel a paid plan (admin) | Owner: POST /subscriptions/:id/cancel. | Sub active → cancelled. Membership payment_status unchanged. No refund. |
| S5 | Staff record a member’s notice | Owner: POST /subscriptions/:id/cancel { initiator: 'member_request' }. | cancelAtPeriodEnd=true; sub stays active; cancellation_review task created (priority=low). Email sent. |
| S6 | Member tries the old self-serve routes | Member: POST /subscriptions/my/:id/cancel-at-period-end or POST /subscriptions/my/:id/resume. | 404 — no member-facing cancellation route exists. |
| S7 | Owner downgrades from Pro to Lite | PATCH /tier { tier: 'lite' }. | Routes through cancelSubscription; sub scheduled to cancel at period end; org keeps Pro tier until then. |
Critical money flows
[C] Refund — automatic capability
Today every provider is manual. When flipping any provider to automatic (FIT-134), run this.
| # | Scenario | Expected |
|---|---|---|
| R-A1 | Full refund on completed charge | Original txn → refunded; new refund type txn inserted with subscription_id, plan_id, metadata.refundedTransactionId; member receives refund email; observability emits payment.refund_completed. |
| R-A2 | Partial refund | New refund txn with amount_in_cents < original.amount_in_cents. Original stays completed; a second partial that brings the total to the charge flips it to refunded. |
| R-A3 | Refund more than is left | 409 refund_amount_exceeds_cap { capInCents } before the provider is called; nothing written. Two parallel 60% refunds of one sale: one succeeds, the other 409; adapter called once. |
| R-A5 | Card credit document | Refund row carries document_number / document_type / document_provider / document_url; Cardcom request has product החזר תשלום – <plan>, Name ≤ 50, ExternalId = tkn-ccredit-<refund row id>. Provider refusal → refund row document_error, refund still completed. |
| R-A4 | Refund failed | Adapter success=false (outcome: 'refused') or a timeout/HTTP error (outcome: 'unknown'). The pending refund row (written before the call) becomes failed; original stays completed; payment.refund_failed; a manual task opens with origin refused / unknown (R-M6b). |
[C] Refund — manual capability
| # | Scenario | Expected |
|---|---|---|
| R-M1 | Open manual refund | POST /payments/:txnId/refund. Original txn → refund_pending, refund_task_id set. tasks row created (type='manual_refund', priority='high', 3-day due date). Observability payment.refund_task_opened. |
| R-M2 | Re-open same txn | Refuses with 400 Refund already in progress for this transaction (payment.service.ts:508). |
| R-M3 | Close task with amount + document number | GET …/payments/refund-tasks/:taskId shows requested amount, prior refunds, cap, origin. POST …/refund-tasks/:taskId/complete { externalReference, amountInCents }: task → completed; a completed refund row with the document number and metadata.source = 'manual_refund_task'; full amount → original refunded; member refundIssuedForTransaction; audit stage: 'settled'. |
| R-M4 | Close already-closed task (idempotency) | Same POST again returns the earlier result. No second refund row, observability event or email. |
| R-M5 | Close task missing amount or document number | No externalReference → 400 (validator). No amountInCents → 400 refund_task_amount_required. Amount above the cap → 409 refund_amount_exceeds_cap. Nothing written in any case. |
| R-M6 | Partial manual refunds | Close 40% → refund row, original completed, refund_task_id null; open a second task and close 60% → second row, original refunded. |
| R-M6b | Task origins | Provider refusal → metadata.manualRefund.origin = 'refused'; timeout → unknown, description starts “Check the provider’s portal first”, preview checkPortalFirst: true; manual-capability active provider → non_cardcom; charge on another provider → no_adapter; flagTransactionForManualReview → review (requests the remaining cap). |
| R-M6 | Cross-org task close attempt | Task belongs to org A; user from org B closes it. Service guards task.organizationId !== orgId → 400 Task not found. |
[C] Subscription renewal failure (FIT-136)
| # | Scenario | Expected |
|---|---|---|
| RF1 | First renewal fails (N = 7) | Status active → debt, debt_reason='charge_failed', debt_amount_in_cents = agreed price, debt_since = decline instant, period = the missed period (credits refilled), next_charge_date = org midnight of day 3, payment_status='debt', SUBSCRIPTION_DEBT_OPENED → one debt-opened email + push. No booking sweep. |
| RF2 | Retry on day 3 declines | attempts+1, next_charge_date = day-6 midnight, debt unchanged, paymentFailedEmail with the pay-by day. Day-6 decline → next_charge_date=NULL. |
| RF3 | Day N still unpaid | Lifecycle pass after day-N org midnight: cancelled, cancellation_reason='non_payment', debt kept, audit + churn event, future bookings swept, cancellation notice; membership stays debt. A pass before that midnight does nothing. |
| RF4 | Retry succeeds in grace | Debt cleared, status active, period unchanged, next_charge_date = current_period_end, attempts → 0, SUBSCRIPTION_RENEWED. |
| RF4b | Full payment after cancellation | clear-debt or member pay-debt on the cancelled row → active, period = the debt period, cancellation fields null, next charge on the normal date. pay-debt with nothing owed → 400. |
| RF4c | N variants | N=0: no retries, cancelled on the next pass. N=1: one retry halfway. N=2: one retry on day 1. Across the 2026-10-25 Jerusalem DST change midnights move 21:00Z → 22:00Z. |
| RF4d | Our-side failures | Circuit open, terminal busy/blocked, unknown outcome, card on another terminal, no provider: row unchanged, no debt, never cancelled while a charge is pending. |
| RF5 | Pending sub gets failed first charge | Sub pending → cancelled immediately. No retry. (webhook-processing.service.ts:379) |
| RF6 | Concurrent cron worker collision | Two workers run the daily cron simultaneously. FOR UPDATE SKIP LOCKED ensures each sub is processed exactly once. |
[C] Staff immediate cancel + refund
A member-facing cancellation request opens a staff obligation but performs no payment mutation. Staff issuance creates the legal period-end cancellation and retains the existing settlement path. Staff may separately end a subscription immediately for the business and refund in one call.
| # | Scenario | Expected |
|---|---|---|
| MC1 | Staff cancels immediately with a refund (initiator: 'business', refund: true) | Sub canceled immediately. Most-recent charge/recurring txn is refunded via the capability-aware, provider-matched flow (see Refund flows). Member email (membershipEnded); refund task opened if the capability is manual. |
| MC2 | Refund resolved against a provider the org has since switched away from | Credentials resolved for the transaction’s OWN provider (getCredentialsForProvider), not the org’s current active one; 409 REFUND_PROVIDER_MISMATCH if no historical config is on file. |
| MC3 | Staff cancels immediately, no completed charge exists | Sub canceled; refund step is skipped — nothing to refund. |
| MC4 | Member requests twice on the same subscription | Second pending request 409s CANCELLATION_REQUEST_ALREADY_PENDING; no provider call, task duplication, or timestamp reset. |
| MC5 | Member calls the staff issuance/immediate-cancel route | 403 — only owner/admin staff with subscriptions:manage can issue or terminate. |
| MC6 | Overdue manual_refund task | Daily sweep bumps it high → urgent, emits payment.refund_task_overdue — see Overdue refund-task sweep. |
[C] Webhook idempotency (FIT-133)
| # | Scenario | Expected |
|---|---|---|
| W1 | Same webhook fired twice | Second arrival hits status === 'completed' short-circuit; no duplicate sub mutation. |
| W2 | verify-return + webhook race | Both call processWebhookEvent for the same processId. Outcomes converge: sub active, txn completed, period advanced exactly once. Today this is best-effort — no row lock between the two reads (webhook-processing.service.ts:121). |
| W3 | Webhook with cross-org metadata | Org A’s webhook URL carries metadata referencing entitlement from org B. Service logs Cross-org webhook abuse blocked, captures Sentry, does not mutate (webhook-processing.service.ts:215). |
| W4 | Webhook arrives before transaction row exists | findByProviderTransactionId returns null; falls through to completePendingBySubscriptionId using metadata.subscriptionId; if no metadata, no-op + warning. |
| W5 | Webhook signature invalid (Meshulam, iCredit) | Controller throws 400; processor never called. |
| W6 | Cardcom webhook (unsigned) | Every body is re-checked with GetLpResult and Cardcom’s answer is processed. A failure Cardcom reports for its own page (5119/5118 timeout, 2006 refusal) → processed, 200. A page Cardcom cannot place (5096, zero page id) → 400 under enforcement. A success claim for a page Cardcom reports unfinished → 503, Cardcom redelivers. |
| W7 | Ignored event type | Adapter sets metadata.ignored; controller emits payment.webhook_ignored and returns 200 without processing. |
Charge succeeded but DB write failed
| # | Scenario | Expected |
|---|---|---|
| PS1 | Provider returns success → DB transaction throws after adapter.createCharge (recurring-charge.service.ts:153) | Pending txn row stays pending; subscription untouched. Card was debited. Recovery: next cron tick will attempt to re-charge (double-charge risk — see below). |
| PS2 | Pending row exists but next cron tick fires | Today no reconciler for per-org payments (gap; platform-billing has one). Risk: double-charge unless the provider dedupes on the same idempotency key. |
Mitigation today: upsertPending (payment-transaction.service.ts:154) reuses the pending row by subscriptionId, so the second cron sees the same row and skips the insert. But the adapter.createCharge call has no idempotency key — duplication risk lives at the provider boundary.
Network failures
| # | Scenario | Expected |
|---|---|---|
| NF1 | Webhook controller times out mid-processing | Provider retries (per provider). Idempotency guards handle the replay (W1). |
| NF2 | Adapter HTTP fails | createCharge returns success=false, errorMessage. Txn → failed. Membership flags accordingly. |
| NF3 | DB advisory-lock contention on platform billing checkout | Two parallel createCheckoutSession calls serialize on pg_advisory_xact_lock(hashtext('platform-billing:checkout:<orgId>')) (platform-billing.service.ts:155). Second waits for the first; both end with one Cardcom session. |
[C] Taikan-managed terminal provisioning (FIT-286)
Nothing here has been run against Cardcom yet — dealer credentials are outstanding. Run the whole block before the first real gym applies.
| # | Scenario | Expected |
|---|---|---|
| T1 | Owner picks Taikan-managed | Settings → Payments opens on the mode choice; managed path shows the explanation step with no fields and no credential inputs anywhere. |
| T2 | Submit a valid application | POST …/provision → row written with status='pending_kyc', isActive=true. Provider dashboard login shown once; dialog cannot be dismissed until acknowledged. |
| T3 | Member view while pending | Member sees a gym with no shop — identical to a gym with no provider at all. |
| T4 | Staff view while pending | Plans page shows the pending alert; owner can still create and price plans. Settings card shows the pending badge and no Configure button. |
| T5 | Charge attempt while pending | Every charging path (purchase, recurring cron, desk charge, debt collection, card registration, platform-billing) fails closed — getDecryptedCredentials filters on status='active'. |
| T6 | Second application while pending | 409 Conflict. Verify no second company was created at Cardcom — this is the expensive failure mode. |
| T7 | KYC approved | Platform admin POST …/managed-status {outcome:'active'} → status flips, activation email arrives, shop appears for members, a real card charges successfully. |
| T7a | Status check activates | With a terminal pending, call POST …/managed-status/refresh (or run the sweep). Once Cardcom answers IsDone: true: status flips, the stored credentials match APIUserName/APISecret from the status response, activation email arrives exactly once, and a repeat check is a no-op. |
| T7b | Owner checks status early | Pending card shows Check status. Before approval it reports still-under-review and lists any outstanding requirements from Cardcom’s error arrays; after approval it activates. Works with managed-terminal-kyc-poll OFF — the button is never flag-gated. |
| T7c | Status query fails | GetCompanyStatus returns a non-zero ResponseCode → terminal stays pending and nothing is written. Unreachable → the on-demand check surfaces the error to the owner rather than reading back as “still under review”; the sweep logs it and moves to the next org. |
| T7d | Company number is the right field | After NewCompany, the stored config carries companyNumber = the response’s CompanyNumber (e.g. 21351), NOT CompanyInternalID (e.g. 21348, a UUID in practice). Confirm the outbound GetCompanyStatus URL carries that value and that Cardcom answers ResponseCode: 0 — this is the assertion the whole sweep rests on and it has never been seen pass against Cardcom. |
| T7e | Sweep flag rollout | managed-terminal-kyc-poll OFF (or PostHog unreachable) → the tick contacts Cardcom for zero orgs; only the button activates. ON for one org with two pending → exactly that org is polled. Verify against the sweep’s own log line (Checked N …, skipped N …). |
| T7f | Terminal with no company number | A pending_kyc row whose config has no companyNumber (provisioned before the fix, or a Cardcom response without the field) is never sent to GetCompanyStatus, even with the flag ON — it counts as skipped and waits for Check status. This is the regression guard for FITKIT-BACKEND-3W’s hourly error loop. |
| T8 | KYC rejected | {outcome:'rejected', reason} → rejected badge with the reason; owner can submit a new application. |
| T9 | Missing dealer env | Any CARDCOM_SUPPLIER_* unset → 503, no partial row written. |
| T10 | Module check | Cardcom opens fewer modules than requested → warning logged. Confirm tokenisation is among them — without it recurring billing fails at the first renewal a month later, not at setup. |
| T11 | Derived KYC estimates | Provision for an org with plans → payload’s min/avg/max match the catalogue; org with no plans → non-zero fallbacks. |
| T12 | Non-Israeli owner | Validation rejects with the support message before any Cardcom call. |
[C] Cash credit notes (TKN-425)
Covered by credit-note.int.spec.ts, cash-plan-payment.int.spec.ts, cash-credit-note.service.unit.spec.ts, credit-document-kind.unit.spec.ts, cardcom.provider.unit.spec.ts, morning-issue-document.unit.spec.ts, and the web credit-note-dialog / confirm-cash-returned-dialog / member-payments-tab / transactions-table / refund-task-complete-dialog int specs. Live-terminal rows: runbook.
| # | Scenario | Expected |
|---|---|---|
| CN1 | Credit a Cardcom TaxInvoiceAndReceipt cash sale | Cardcom CreateDocument with DocumentTypeToCreate: TaxInvoiceAndReceiptRefund, positive Cash, Comments: זיכוי למסמך מס' <N>, ExternalId: tkn-credit-<refund row id>. A Receipt original files ReceiptRefund. |
| CN2 | Morning org | Both credit kinds file 330 with a negative price and linkedDocumentIds; no ExternalId / number / date fields in the body. |
| CN3 | Credit a card charge, or a refund row | 409 credit_note_not_available; nothing written. |
| CN4 | Credit a sale whose document was skipped, failed or never numbered | 409 credit_note_no_document; nothing written. |
| CN5 | Original document_type unrecognised (e.g. TaxInvoice, null) | 409 credit_note_kind_unknown; nothing written. |
| CN6 | Partial credits | 40 % then 60 % → original completed after the first, refunded after the second; any further amount → 409 credit_note_amount_exceeds_cap { capInCents: 0, defaultInCents }. |
| CN7 | Two credits race on one sale with different keys | One commits; the other gets the cap 409 (row lock on the original). |
| CN8 | Retry with the same key | Same refund row, replayed: true, no second document, audit row or event. Same key with another amount → 409 credit_note_idempotency_conflict. |
| CN9 | Suggested amounts (preview defaultAmountInCents) | Scheduled or never-started pack → full; started pack → unused credit share; unlimited one-time not started → full, mid-period → time-based share; cash plan → unused periods (freezes rewound); non-plan money → full; never above the cap. |
| CN10 | Org switched Cardcom → Morning, sale filed by Cardcom | Credit filed with the Cardcom credentials on file; with none on file → 409 credit_note_provider_credentials_missing, no refund row. |
| CN11 | Member email | Credit request carries the email and sendByEmail: true; a 51-character email is dropped with IsSendByEmail: false and the document is still issued. |
| CN12 | Cash never received (cashReturned: false) | Same type-2 reversal; refund row metadata.cashReturned = false, metadata.reason = 'invoice_cancelled'; revenue nets the credit. |
| CN13 | Provider rejects the credit document | Refund row completed with document_error; response documentStatus: 'failed'; row in the owed-documents queue; UI shows Document failed. Retrying the same key does not file again. |
| CN14 | Standalone refund of a manual transaction | No idempotencyKey → 400 manual_refund_idempotency_required; double-click with one key → one refund row; a cash-plan sale is credited (no cash_prepayment_cancel_to_refund) and access is unchanged. |
| CN15 | Staff ends a cash plan with a credit | Preview carries creditableInCents and capInCents (no 409 for a scheduled pack or unlimited one-time sale); edited amount ≤ cap accepted and spread newest sale first; > cap → 409 credit_note_amount_exceeds_cap; cashReturned: false tags the rows; audit payment.cash_plan_cancelled; documents filed after commit. |
| CN16 | Member notice on a cash plan matures | One refund_pending reservation per sale with unused value + a manual_refund task due three org days later; a non-metered one-time sale gets a general review task and no reservation. |
| CN17 | Staff close the reservation’s task with a reference | 409 refund_task_use_cash_settlement { transactionId, membershipId }; task and rows untouched; dialog shows the copy and the member link. A card manual_refund task still closes. |
| CN18 | Confirm cash returned | Reservation → completed, its task completed, credit document filed, original refunded when the reservation covered all of it, member refundCompleted email. A second confirm replays; confirming a non-reservation → 409 credit_note_not_available. |
| CN19 | Audit | Cash sale payment.cash_recorded, credit payment.credit_note_issued, cash-plan cancel payment.cash_plan_cancelled, settle payment.unpaid_term_settled, write-off payment.unpaid_term_written_off. |
| CN20 | Web gates | Manual completed/refunded sale: Credit note and no refund button; card row unchanged; dialog defaults to the suggestion, caps the amount, requires a reason, shows the document type; rows show the document number; a reservation shows Confirm cash returned; coach sees neither action. |
| CN21 | Desk sale ExternalId | Cash sale / settlement documents carry ExternalId: tkn-sale-<transaction id>. |
| CN22 | Back-dated document | Settling an unpaid term with paidOn before org today → Cardcom DocumentDate MM/dd/yyyy; a same-day document omits DocumentDate. |
[C] Pay-later terms (TKN-426)
Covered by enroll-member-billing.unit.spec.ts, plan-duration.unit.spec.ts, cash-plan-payment.int.spec.ts, cash-renewal-task.unit.spec.ts, and the web enroll-member-dialog, member-memberships-tab, settle-unpaid-term-dialog, write-off-unpaid-term-dialog, unpaid-term-banner int specs (also run with TZ=America/Los_Angeles).
| # | Scenario | Expected |
|---|---|---|
| PL1 | Asia/Jerusalem org, startsOn 2026-10-20, grace 7 days (across the 2026-10-25 DST change) | payment_last_day = '2026-10-26', payment_due_at = 2026-10-26T22:00:00Z, payment_follow_up task due 2026-10-26. |
| PL2 | No start date, enrolled at 2026-10-03T22:30Z (already 4 Oct in Jerusalem), 7 days | Last day 2026-10-10, due 2026-10-10T21:00:00Z. |
| PL3 | Grace of one month | Counted as 30 org calendar days. |
| PL4 | Enroll dialog copy | Future start: The plan starts {start}. Pay {amount} by {lastDay}. (enroll-unpaid-last-day), start hint hidden; otherwise the dated “last day” sentence. No “now” / “from today” wording in en/he/ru. |
| PL5 | Cash + future start | enroll-cash-timing-now keeps the cash quote body; enroll-cash-timing-later sends { planId, startsOn?, unpaidGrace } with no payment method, quote or key, and locks the period count at 1. |
| PL6 | Unpaid-term badge | Shows paymentLastDay verbatim under any browser timezone; Record payment / Write off shown only with payments/manage. |
| PL7 | Settle with a back-dated day | paidOn sent only when earlier than org today; paid_at = org-local noon of that day; a future paidOn → 400; the date picker stops at org today. |
| PL8 | Write off | Optional reason kept on the closed task; no document; audit payment.unpaid_term_written_off. |
| PL9 | record() replay | record() with key K settles the term; a second record() with K returns the same transaction — no second payment, no period roll. |
| PL10 | Settle after the granted period ended | Coverage row written (refund basis); no cash_plan_renewal task; rail and cash_prepaid_through unchanged; unpaid columns (incl. payment_last_day) cleared. A row cancelled for non-payment stays cancelled with its debt cleared; an active row is left to the lifecycle job’s natural-end rules. |
| PL11 | Task dates | Cash renewal task due on the org-calendar day 7 days before coverage ends; cash-notice task three org days out; escalation compares with each org’s own today (a 21:30Z run in Jerusalem escalates the next org day’s tasks). |
| PL12 | Migration backfill | Existing unpaid rows get payment_last_day = org-local day before payment_due_at; their open collection tasks move to that day; completed/cancelled tasks untouched. |
[C] Unpaid payment: grace and cancellation
Covered by billing-debt.unit.spec.ts, debt-entry.unit.spec.ts, subscription-lifecycle.service.{unit,int}.spec.ts, billing-debt-notifications.service.unit.spec.ts, booking-horizon.unit.spec.ts, bookings.service.unit.spec.ts (billing gate), billing-settlement.unit.spec.ts, recurring-charge.service.unit.spec.ts, presale-promotion.unit.spec.ts, webhook-processing.service.unit.spec.ts, debt.service.unit.spec.ts, cancellation-tail-settlement.service.unit.spec.ts, subscriptions.controller.unit.spec.ts, member-status.unit.spec.ts, cash-plan-payment.int.spec.ts, organizations-audit-diff.unit.spec.ts, and the web booking-settings / session-drawer / billing-debt-banner int specs.
| # | Scenario | Expected |
|---|---|---|
| GL1 | Pay-later term passes its due date | Lifecycle: debt, debt_reason='payment_due', amount = unpaid amount, debt_since = payment_due_at, unpaid columns kept; books until debtCancelsAt(payment_due_at); member status amount not doubled. |
| GL2 | Cash plan coverage ends unpaid | debt, cash_lapsed, amount = next period at the agreed price, period [cash_prepaid_through, +interval]; a bounded gift → cancelled gift_ended, no debt, renewal task completed; a ₪0 plan rolls. Bookable in the window before the pass stamps it. |
| GL3 | Card plan with no renewal and no card | Paid → cancelled term_ended; ₪0 → period rolled; a row with next_charge_date or a card on file is never touched. Pack/drop-in past validity → expired with audit. |
| GL4 | Once per org day | Two ticks in the same org day run one pass (billing_lifecycle_day); a failed row is retried on the next tick; no second cancellation or notice. Frozen (paused) rows are never selected. |
| GL5 | Legacy renewal, card expired | No claim or charge; row debt card_unusable, membership debt, retries scheduled, exactly one billing.card_unusable event after commit. No card → card_missing. A card with no expiry on file is charged. An already-debt row advances its retry instead. |
| GL6 | Protected collection, card expired or no expiry | card_expired_or_expiry_missing block recorded, row debt card_unusable, one event. |
| GL7 | Presale first charge declined | Presale starts that day (starts_on null), debt for the agreed price, retries per f(N), presale_charge_failed retention case + urgent owner task on day 0; day N → cancelled for non-payment. |
| GL8 | Booking gate | Debt plan books (credits from the debt period) until its cancellation instant, then booking_beyond_subscription_end; plan cancelled for non-payment + healthy plan → 409 booking_blocked_debt with billingDebt; presales only → 409 plan_not_started { startsOn }; no plan + plan_required_to_book → 409 plan_required_to_book; staff bypass. Schedule eligibility shows debt_blocked / plan_not_started for the same members. |
| GL9 | billingDebt exposure | GET …/subscriptions/my and the staff list carry billingDebt (state grace/cancelled, payBy, cancelsAt) and pay_debt in memberActions on card rows; member status training with atDate = payBy, or non_payment. |
| GL10 | Notices | Debt opened (day 0), last day (pay-by day, N ≥ 2) and cancelled each sent once (email + push, billing prefs); card-unusable counts as opened; after payment a new debt starts over; card-expiry reminders 14 / 7 / 1 org days ahead, once each. |
| GL11 | Settings | Booking settings page shows one billing-grace-days field (default 7); 31 shows the inline error and sends nothing; a saved change appears in the org audit diff. |
| GL12 | Desk restore | Cash for the lapsed period on a cash_lapsed debt → active, period unchanged, coverage row; wrong amount refused; other debt shapes still cash_prepayment_incompatible_state. Pay-later settle on a debt / cancelled term restores it (period live) or leaves it cancelled with debt cleared. |
Permission tests
| # | Scenario | Expected |
|---|---|---|
| P1 | Coach attempts refund | 403 — only owner/admin |
| P2 | Member attempts admin cancel | 403 |
| P3 | Member cancels another member’s sub | 403 |
| P4 | Cross-org access to refund task | 400 Task not found |
| P5 | Owner of org A reads txns of org B | Filtered out by organization_id clause; returns empty list (no error). |
| P6 | Update tier by admin (not owner) | 403 (platform-tiers.controller.ts:64). |
| P7 | Coach attempts terminal provisioning | 403 — owner/admin only. |
| P8 | Owner calls managed-status | 403 — @PlatformAdmin only; a gym must not mark its own terminal live. |
| P9 | Coach calls managed-status/refresh | 403 — owner/admin only. The refresh asks Cardcom whether it approved, so it cannot be used to self-activate. |
| P10 | Coach calls credit-note preview / credit-note / settle-cash-refund | 403 — owner/admin with payments/manage only. |
| P11 | Coach settles or writes off an unpaid term | 403. |
E2E (Playwright)
apps/web/e2e/specs/payments-*.spec.ts— owner refund flow.apps/web/e2e/specs/buy-course.spec.ts— full course-checkout journey (Clerk email-code → Cardcom test card → entitlement live).
Open gaps requiring manual QA today
- No unique index on
payment_transactions.(organization_id, provider_transaction_id)— duplicate insert is technically allowed (FIT-133). - Manual refund external reference is not validated against the provider’s actual credit document — wrong number lands in the DB. Audit by reconciling Morning’s portal monthly.
- Tranzila + Morning webhook signatures are stubs — only deploy these providers behind an IP allowlist at the proxy until validation is real.
Manual charges & debt collection (FIT-254 §3.3/§3.4)
| # | Scenario | Expected |
|---|---|---|
| MCH2 | Non-owner/admin charges | 403 — payments/manage required. |
| MCH3 | No provider configured | 400 "No payment provider configured for this organization" — not the FIT-254 spec’s originally-proposed 409 no_payment_provider. Known delta, see ADR-0016. |
| MCH4 | Provider not in CHARGE_VERIFIED_PROVIDERS (e.g. Morning) | 409 provider_charge_unverified. |
| MCH5 | Membership suspended/cancelled | 409 membership_not_active. |
| MCH6 | No active card on membership | 409 no_active_payment_method; UI shows Register-card CTA. |
| MCH7 | [C] Double-charge via retry | No idempotency key on the charge path — two rapid POSTs (double-click, or a client retry after a timeout) each insert their own pending row and each get charged. The confirm dialog’s in-flight button-disable is the only client-side guard; there is no server-side dedupe. Must be covered by an explicit “click twice fast” e2e/manual test before rollout, and called out to org owners as “one click, wait for the result.” |
| MCH8 | Crash between adapter call and status update | Pending row stays pending — card may have been debited with no completed record. No reconciler exists for this table (same gap as FIT-133/PS1); manual reconciliation only. |
| MCH9 | Successful charge | Txn completed; receipt email sent with description as a line item; payment.manual_charge event emitted (amount/ids/actor only — description never reaches PostHog); no SUBSCRIPTION_RENEWED emitted. |
| MCH10 | Provider decline | Txn failed with errorMessage; no receipt; retry allowed (produces a new txn, see MCH7). |
| MCH11 | Clear debt success | Full debtAmountInCents charged; sub → active; nextChargeDate = currentPeriodEnd and failedChargeAttempts=0 (A6 — previously nextChargeDate stayed NULL forever); period unchanged; debt_reason / debt_notice_stage cleared; a non-payment cancellation is restored to active; membership payment_status='current' unless another plan still owes. Pending-row-first: the txn row exists (status pending → completed) before the adapter call, never after (A6). |
| MCH12 | Clear debt failure | Sub stays debt; inline error; no partial collection possible (v1 scope). |
| MCH12b | Clear debt double-collection race (A6) | Two concurrent clear-debt calls on the same debt sub both pass the pre-charge checks and both charge the provider; only the first’s atomic post-charge claim (WHERE status IN ('debt','cancelled') AND debt_amount_in_cents=<charged amount>) succeeds. The second’s txn flips to refund_pending and a manual_refund task opens (via PaymentService.flagTransactionForManualReview) instead of throwing to that caller — the debt genuinely is cleared (by the other request), so this surfaces as a reconciliation item, not a client-facing error. |
| MCH13 | Allowlist expansion procedure | Adding a provider to CHARGE_VERIFIED_PROVIDERS (manual-charge.service.ts:34) is a one-line code change, but must only happen after: (1) the provider’s createCharge (token MIT) has been sandbox-verified end-to-end, (2) tax-document (חשבונית/קבלה) issuance on that call path is confirmed for the IL market. Treat as a release-gated change, not a config flip — it is compiled into the binary, shared between manual-charge and plan-change. |
| MCH14 | Cross-org membership id | 404 (membership lookup scoped to orgId). |
| MCH15 | Garbage (non-UUID) :membershipId/:subscriptionId/:orgId on charge/clear-debt/payment-methods-list (B, Wave B) | 400 via ParseUUIDPipe, never a Postgres-level 500. |
| MCH16 | Plan-change charge succeeds but the swap fails (B1/B2, Wave B) | Same reconciliation pattern as MCH12b — flagTransactionForManualReview flips the txn to refund_pending and opens a manual_refund task; see subscriptions-plans/qa-plan.md PC34/PC35. |
| MCH17 | Clear-debt with no active card on the membership (C6, Wave C) | 409 no_active_payment_method (structured code, same as MCH6’s manual-charge check) — previously a plain, uncoded 400 from DebtService.clearDebt itself. The collect-debt dialog now maps this to localized copy instead of the raw English message. |
Checkout decision — cancelled / failed return (unflagged)
Covered by apps/api/src/subscriptions/checkout-decision.int.spec.ts,
apps/api/src/payments/checkout-urls.unit.spec.ts,
cardcom.provider.unit.spec.ts and webhook-failure-races.int.spec.ts.
| # | Scenario | Expected |
|---|---|---|
| CD1 | Member closes the hosted Cardcom form | Lands on the return page with status=cancelled. Subscription stays pending. POST …/checkout-return emits payment.checkout_cancel_returned + payment.return_landed; nothing is mutated (re-firing it on every mount is safe). |
| CD2 | Card is DECLINED on the hosted form | Cardcom fires FailedRedirectUrl, which now carries status=failed — distinct copy, not “you cancelled”. The transaction is failed, the subscription stays pending, and payment.activation_failed { reason: 'first_charge_failed' } is emitted. Regression watch: it used to flip to cancelled with no marker, which killed both retry and revival. |
| CD3 | Declined on mobile | status=failed survives the payments/app-return deep-link bridge into taikan://shop/payment-return (the bridge forwards every query param). |
| CD4 | Member taps Resume payment | POST …/resume-checkout returns { subscription, paymentPageUrl, resuming: true } for the SAME subscription id. payment.checkout_resumed { source: 'resume_endpoint' }. |
| CD5 | Resume after the plan sold out / was retired / gained a signing requirement | The same refusal a fresh purchase would give (409 form_signature_required, seat/cap error, “Plan is not available”) — never a payment page for something unbuyable. |
| CD6 | Resume after the 24h sweep already released the row | 409 checkout_not_resumable, body carries status: 'cancelled'. No new subscription is created. |
| CD7 | Resume, withdraw, or report a return against somebody else’s subscription | 404 on all three — never a 403, which confirms the id exists and hands out an enumeration oracle. (The pre-existing cancel-pending route keeps its 403 so deployed clients don’t shift.) |
| CD8 | Resume an externally-billed plan (ADR-0017) | paymentPageUrl is the plan’s own paymentLinkUrl; no provider API call. |
| CD8b | Resume a checkout whose payment already settled, or whose charge is in flight | 409 checkout_not_resumable — issuing a second payment page would invite the member to pay twice. Mirror image of voidPendingSubscription’s refusal to release the same row. |
| CD8c | Payment-page creation fails while RESUMING | The pending subscription is left intact (only a row that call created is rolled back). Regression watch: deleting it would destroy the very checkout the member asked to resume, and orphan its transactions. |
| CD8d | Return URLs | successUrl, cancelUrl and failedUrl all carry sub=<id>; a client that already appended one does not get a second. |
| CD9 | Member taps Cancel purchase | POST …/cancel-pending { intent: 'regret', source: 'return_page' }. Row → cancelled + abandoned_checkout + cancellation_requested_by = member, cancellation_requested_at stays NULL. Disappears from GET …/subscriptions/my; still listed for staff; the plan is purchasable again in the shop. |
| CD10 | Cancel purchase when a payment already settled | 409 cancel_pending_unsafe (“contact the gym”); the row is untouched. |
| CD11 | Cancel purchase while a charge is in flight (charge_started_at) | 409 cancel_pending_unsafe; the row is untouched. |
| CD12 | A late payment.completed arrives for a rolled-back checkout | The subscription is REVIVED (revivingAbandonedCheckout) and reappears in the member’s list. This is why the rollback must keep the abandoned_checkout reason. |
| CD13 | Row released by the 24h sweep, or dismissed by staff | Stays VISIBLE to the member with the checkout_abandoned chip. Only the member’s own rollback hides. checkoutReleasedBy says which one it was: sweep / staff / member. |
| CD13b | Declined checkout still pending after an hour | Swept (seat released), payment.checkout_abandoned with reason: 'declined charge, idle > 1h'. Within the hour, or once a new attempt is open, it is left alone. |
| CD14 | Cardcom payment.failed with NO TranzactionId (the payment page expired, ~25 min after creation) | The attempt keyed on that lowProfileId is marked cancelled — never failed; the subscription stays pending; payment.webhook_ignored with reason: 'checkout_page_expired'; no activation_failed, no Sentry. |
| CD14b | Same expiry against a LIVE subscription, another org’s checkout, or a page already closed | Nothing touched. |
| CD14c | Expiry for an OLD page while a newer attempt is open on the same subscription | Only the old page’s attempt is closed. |
| CD14d | Genuine decline: payment.failed WITH a TranzactionId we never stored | Resolved through lowProfileId to the pending attempt (not transaction_not_on_file); attempt failed with the gateway’s errorMessage; subscription stays pending. |
| CD15 | detectStuckPending over a declined checkout | outcome: 'ignored', reason names the decline. A checkout stuck with no decline behind it still emits failure. |
| CD16 | Join-link sweep releases a declined vs an abandoned checkout | cause: 'declined' / 'abandoned'; the CRM note names the decline and says to offer another payment method. Membership is released either way. |
| CD17 | Join-link checkout released unpaid | Seat released and a recovery mail sent: the declined member gets the decline letter with the bank’s own reason; the abandoner never sees the word “declined”. |
| CD18 | Recovery mail with no live join link anywhere | Mail still sends, button dropped — never a link to a page that 404s. |
| CD19 | Card declined (a failed attempt exists) | The release sweep sends the decline letter with the bank’s own reason at the release. Nothing is sent at the refusal — Cardcom gives us no event for it. |
| CD20 | Expired page | The release sweep sends the ABANDON letter; the word “declined” never appears. |
| CD22 | Released join-link registrant signs in | ”Membership inactive” screen offers a working way back; the contact-your-gym line is replaced, not stacked. |
| CD23 | Suspended member, or one removed by staff, signs in | No rejoin button — the screen keeps today’s copy. |
| CD13c | Two-day-old checkout resumed minutes ago | NOT swept — idleness is measured from the newest open attempt. |
| CD14 | Staff dismiss from the member page | POST /organizations/:orgId/subscriptions/:id/void-pending, owner/admin only (member → 403, coach → 403). Same money-safety refusals as the member path. |
| CD15 | Deployed mobile build posts cancel-pending with no body | Identical to today: row cancelled, checkoutCancel.intent null. No client is broken by the new field. |
| CD16 | deriveFailedUrl on a cancel URL with no status=cancelled | Returns undefined; the adapter falls back to cancelUrl, i.e. exactly today’s behavior. Never a guessed URL. |
| CD17 | Morning-backed org | Unchanged — Morning has a single failureUrl for both legs, so no failed/cancelled split is attempted there. Verify the return page still reads as “cancelled” and Resume still works. |
Card issuer validation (card-issuer-validation)
Covered by cardcom.provider.unit.spec.ts (operation + classifier),
payment.service.unit.spec.ts (policy → request) and
card-validation-policy.service.unit.spec.ts (flag → policy). The live-terminal
rows need a Cardcom test terminal with the tokens module enabled.
| # | Scenario | Expected |
|---|---|---|
| CV1 | Flag OFF / unset / PostHog unreachable, presale checkout | LowProfile/Create carries Operation: CreateTokenOnly and no AdvancedDefinition — byte-for-byte today’s request. |
| CV2 | Flag ON, presale checkout | Operation: SuspendedDeal, AdvancedDefinition.JValidateType: 5, Amount = the plan price. Ledger row still recorded at 0 with metadata.tokenOnly. |
| CV3 | Flag ON, card registration | Same operation, Amount: 1 — the member’s statement shows a ₪1 hold, not the plan price. |
| CV4 | Flag ON, ordinary (charging) checkout | Untouched: ChargeAndCreateToken, policy never consulted. |
| CV5 | Live terminal: valid card on a suspended deal | GetLpResult answers ResponseCode: 0 and TranzactionInfo.ResponseCode 700 or 701; classified completed; token + expiry stored; subscription lands scheduled; the member’s bank shows a pending authorization for the plan price that lapses without a charge. |
| CV6 | Live terminal: wrong CVV / expiry / no credit | Cardcom refuses on the page. Whatever reaches us is a payment.failed, filed as card_validation_failed like today’s presale decline; the checkout stays the member’s to retry. |
| CV7 | A 700/701 on a ChargeAndCreateToken deal | Classified failed — the J-codes are trusted only on CreateTokenOnly / SuspendedDeal. |
| CV8 | Suspended deal whose token sits in TranzactionInfo only | Token, CardMonth and CardYear still land in the event metadata and on the payment method. |
| CV9 | Opening day after a suspended deal | The renewal sweep charges the stored token with Transactions/Transaction as before. The hold is never captured (ApprovalNumber unused); if it is still live the bank shows both for a few days. |