Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesPaymentsPayments — QA Plan

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

#ScenarioStepsExpected
S1Configure providerOwner: POST /organizations/:orgId/payment-config with Cardcom test creds.GET returns config with credentials redacted. Row in payment_provider_configs with encrypted blob.
S2Purchase a paid planMember: 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.
S3Purchase a free planMember purchases plan with priceInCents=0.Sub created with status='active' directly; no paymentPageUrl; no txn row.
S4Cancel a paid plan (admin)Owner: POST /subscriptions/:id/cancel.Sub active → cancelled. Membership payment_status unchanged. No refund.
S5Staff record a member’s noticeOwner: POST /subscriptions/:id/cancel { initiator: 'member_request' }.cancelAtPeriodEnd=true; sub stays active; cancellation_review task created (priority=low). Email sent.
S6Member tries the old self-serve routesMember: POST /subscriptions/my/:id/cancel-at-period-end or POST /subscriptions/my/:id/resume.404 — no member-facing cancellation route exists.
S7Owner downgrades from Pro to LitePATCH /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.

#ScenarioExpected
R-A1Full refund on completed chargeOriginal txn → refunded; new refund type txn inserted with subscription_id, plan_id, metadata.refundedTransactionId; member receives refund email; observability emits payment.refund_completed.
R-A2Partial refundNew 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-A3Refund more than is left409 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-A5Card credit documentRefund 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-A4Refund failedAdapter 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

#ScenarioExpected
R-M1Open manual refundPOST /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-M2Re-open same txnRefuses with 400 Refund already in progress for this transaction (payment.service.ts:508).
R-M3Close task with amount + document numberGET …/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-M4Close already-closed task (idempotency)Same POST again returns the earlier result. No second refund row, observability event or email.
R-M5Close task missing amount or document numberNo 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-M6Partial manual refundsClose 40% → refund row, original completed, refund_task_id null; open a second task and close 60% → second row, original refunded.
R-M6bTask originsProvider 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-M6Cross-org task close attemptTask 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)

#ScenarioExpected
RF1First 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.
RF2Retry on day 3 declinesattempts+1, next_charge_date = day-6 midnight, debt unchanged, paymentFailedEmail with the pay-by day. Day-6 decline → next_charge_date=NULL.
RF3Day N still unpaidLifecycle 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.
RF4Retry succeeds in graceDebt cleared, status active, period unchanged, next_charge_date = current_period_end, attempts → 0, SUBSCRIPTION_RENEWED.
RF4bFull payment after cancellationclear-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.
RF4cN variantsN=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.
RF4dOur-side failuresCircuit open, terminal busy/blocked, unknown outcome, card on another terminal, no provider: row unchanged, no debt, never cancelled while a charge is pending.
RF5Pending sub gets failed first chargeSub pending → cancelled immediately. No retry. (webhook-processing.service.ts:379)
RF6Concurrent cron worker collisionTwo 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.

#ScenarioExpected
MC1Staff 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.
MC2Refund resolved against a provider the org has since switched away fromCredentials 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.
MC3Staff cancels immediately, no completed charge existsSub canceled; refund step is skipped — nothing to refund.
MC4Member requests twice on the same subscriptionSecond pending request 409s CANCELLATION_REQUEST_ALREADY_PENDING; no provider call, task duplication, or timestamp reset.
MC5Member calls the staff issuance/immediate-cancel route403 — only owner/admin staff with subscriptions:manage can issue or terminate.
MC6Overdue manual_refund taskDaily sweep bumps it high → urgent, emits payment.refund_task_overdue — see Overdue refund-task sweep.

[C] Webhook idempotency (FIT-133)

#ScenarioExpected
W1Same webhook fired twiceSecond arrival hits status === 'completed' short-circuit; no duplicate sub mutation.
W2verify-return + webhook raceBoth 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).
W3Webhook with cross-org metadataOrg 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).
W4Webhook arrives before transaction row existsfindByProviderTransactionId returns null; falls through to completePendingBySubscriptionId using metadata.subscriptionId; if no metadata, no-op + warning.
W5Webhook signature invalid (Meshulam, iCredit)Controller throws 400; processor never called.
W6Cardcom 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.
W7Ignored event typeAdapter sets metadata.ignored; controller emits payment.webhook_ignored and returns 200 without processing.

Charge succeeded but DB write failed

#ScenarioExpected
PS1Provider 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).
PS2Pending row exists but next cron tick firesToday 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

#ScenarioExpected
NF1Webhook controller times out mid-processingProvider retries (per provider). Idempotency guards handle the replay (W1).
NF2Adapter HTTP failscreateCharge returns success=false, errorMessage. Txn → failed. Membership flags accordingly.
NF3DB advisory-lock contention on platform billing checkoutTwo 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.

#ScenarioExpected
T1Owner picks Taikan-managedSettings → Payments opens on the mode choice; managed path shows the explanation step with no fields and no credential inputs anywhere.
T2Submit a valid applicationPOST …/provision → row written with status='pending_kyc', isActive=true. Provider dashboard login shown once; dialog cannot be dismissed until acknowledged.
T3Member view while pendingMember sees a gym with no shop — identical to a gym with no provider at all.
T4Staff view while pendingPlans page shows the pending alert; owner can still create and price plans. Settings card shows the pending badge and no Configure button.
T5Charge attempt while pendingEvery charging path (purchase, recurring cron, desk charge, debt collection, card registration, platform-billing) fails closed — getDecryptedCredentials filters on status='active'.
T6Second application while pending409 Conflict. Verify no second company was created at Cardcom — this is the expensive failure mode.
T7KYC approvedPlatform admin POST …/managed-status {outcome:'active'} → status flips, activation email arrives, shop appears for members, a real card charges successfully.
T7aStatus check activatesWith 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.
T7bOwner checks status earlyPending 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.
T7cStatus query failsGetCompanyStatus 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.
T7dCompany number is the right fieldAfter 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.
T7eSweep flag rolloutmanaged-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 …).
T7fTerminal with no company numberA 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.
T8KYC rejected{outcome:'rejected', reason} → rejected badge with the reason; owner can submit a new application.
T9Missing dealer envAny CARDCOM_SUPPLIER_* unset → 503, no partial row written.
T10Module checkCardcom 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.
T11Derived KYC estimatesProvision for an org with plans → payload’s min/avg/max match the catalogue; org with no plans → non-zero fallbacks.
T12Non-Israeli ownerValidation 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.

#ScenarioExpected
CN1Credit a Cardcom TaxInvoiceAndReceipt cash saleCardcom CreateDocument with DocumentTypeToCreate: TaxInvoiceAndReceiptRefund, positive Cash, Comments: זיכוי למסמך מס' <N>, ExternalId: tkn-credit-<refund row id>. A Receipt original files ReceiptRefund.
CN2Morning orgBoth credit kinds file 330 with a negative price and linkedDocumentIds; no ExternalId / number / date fields in the body.
CN3Credit a card charge, or a refund row409 credit_note_not_available; nothing written.
CN4Credit a sale whose document was skipped, failed or never numbered409 credit_note_no_document; nothing written.
CN5Original document_type unrecognised (e.g. TaxInvoice, null)409 credit_note_kind_unknown; nothing written.
CN6Partial credits40 % then 60 % → original completed after the first, refunded after the second; any further amount → 409 credit_note_amount_exceeds_cap { capInCents: 0, defaultInCents }.
CN7Two credits race on one sale with different keysOne commits; the other gets the cap 409 (row lock on the original).
CN8Retry with the same keySame refund row, replayed: true, no second document, audit row or event. Same key with another amount → 409 credit_note_idempotency_conflict.
CN9Suggested 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.
CN10Org switched Cardcom → Morning, sale filed by CardcomCredit filed with the Cardcom credentials on file; with none on file → 409 credit_note_provider_credentials_missing, no refund row.
CN11Member emailCredit request carries the email and sendByEmail: true; a 51-character email is dropped with IsSendByEmail: false and the document is still issued.
CN12Cash never received (cashReturned: false)Same type-2 reversal; refund row metadata.cashReturned = false, metadata.reason = 'invoice_cancelled'; revenue nets the credit.
CN13Provider rejects the credit documentRefund 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.
CN14Standalone refund of a manual transactionNo 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.
CN15Staff ends a cash plan with a creditPreview 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.
CN16Member notice on a cash plan maturesOne 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.
CN17Staff close the reservation’s task with a reference409 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.
CN18Confirm cash returnedReservation → 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.
CN19AuditCash 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.
CN20Web gatesManual 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.
CN21Desk sale ExternalIdCash sale / settlement documents carry ExternalId: tkn-sale-<transaction id>.
CN22Back-dated documentSettling 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).

#ScenarioExpected
PL1Asia/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.
PL2No start date, enrolled at 2026-10-03T22:30Z (already 4 Oct in Jerusalem), 7 daysLast day 2026-10-10, due 2026-10-10T21:00:00Z.
PL3Grace of one monthCounted as 30 org calendar days.
PL4Enroll dialog copyFuture 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.
PL5Cash + future startenroll-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.
PL6Unpaid-term badgeShows paymentLastDay verbatim under any browser timezone; Record payment / Write off shown only with payments/manage.
PL7Settle with a back-dated daypaidOn 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.
PL8Write offOptional reason kept on the closed task; no document; audit payment.unpaid_term_written_off.
PL9record() replayrecord() with key K settles the term; a second record() with K returns the same transaction — no second payment, no period roll.
PL10Settle after the granted period endedCoverage 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.
PL11Task datesCash 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).
PL12Migration backfillExisting 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.

#ScenarioExpected
GL1Pay-later term passes its due dateLifecycle: 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.
GL2Cash plan coverage ends unpaiddebt, 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.
GL3Card plan with no renewal and no cardPaid → 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.
GL4Once per org dayTwo 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.
GL5Legacy renewal, card expiredNo 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.
GL6Protected collection, card expired or no expirycard_expired_or_expiry_missing block recorded, row debt card_unusable, one event.
GL7Presale first charge declinedPresale 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.
GL8Booking gateDebt 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.
GL9billingDebt exposureGET …/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.
GL10NoticesDebt 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.
GL11SettingsBooking 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.
GL12Desk restoreCash 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

#ScenarioExpected
P1Coach attempts refund403 — only owner/admin
P2Member attempts admin cancel403
P3Member cancels another member’s sub403
P4Cross-org access to refund task400 Task not found
P5Owner of org A reads txns of org BFiltered out by organization_id clause; returns empty list (no error).
P6Update tier by admin (not owner)403 (platform-tiers.controller.ts:64).
P7Coach attempts terminal provisioning403 — owner/admin only.
P8Owner calls managed-status403 — @PlatformAdmin only; a gym must not mark its own terminal live.
P9Coach calls managed-status/refresh403 — owner/admin only. The refresh asks Cardcom whether it approved, so it cannot be used to self-activate.
P10Coach calls credit-note preview / credit-note / settle-cash-refund403 — owner/admin with payments/manage only.
P11Coach settles or writes off an unpaid term403.

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)

#ScenarioExpected
MCH2Non-owner/admin charges403 — payments/manage required.
MCH3No provider configured400 "No payment provider configured for this organization" — not the FIT-254 spec’s originally-proposed 409 no_payment_provider. Known delta, see ADR-0016.
MCH4Provider not in CHARGE_VERIFIED_PROVIDERS (e.g. Morning)409 provider_charge_unverified.
MCH5Membership suspended/cancelled409 membership_not_active.
MCH6No active card on membership409 no_active_payment_method; UI shows Register-card CTA.
MCH7[C] Double-charge via retryNo 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.”
MCH8Crash between adapter call and status updatePending 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.
MCH9Successful chargeTxn 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.
MCH10Provider declineTxn failed with errorMessage; no receipt; retry allowed (produces a new txn, see MCH7).
MCH11Clear debt successFull 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).
MCH12Clear debt failureSub stays debt; inline error; no partial collection possible (v1 scope).
MCH12bClear 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.
MCH13Allowlist expansion procedureAdding 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.
MCH14Cross-org membership id404 (membership lookup scoped to orgId).
MCH15Garbage (non-UUID) :membershipId/:subscriptionId/:orgId on charge/clear-debt/payment-methods-list (B, Wave B)400 via ParseUUIDPipe, never a Postgres-level 500.
MCH16Plan-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.
MCH17Clear-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.

#ScenarioExpected
CD1Member closes the hosted Cardcom formLands 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).
CD2Card is DECLINED on the hosted formCardcom 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.
CD3Declined on mobilestatus=failed survives the payments/app-return deep-link bridge into taikan://shop/payment-return (the bridge forwards every query param).
CD4Member taps Resume paymentPOST …/resume-checkout returns { subscription, paymentPageUrl, resuming: true } for the SAME subscription id. payment.checkout_resumed { source: 'resume_endpoint' }.
CD5Resume after the plan sold out / was retired / gained a signing requirementThe 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.
CD6Resume after the 24h sweep already released the row409 checkout_not_resumable, body carries status: 'cancelled'. No new subscription is created.
CD7Resume, withdraw, or report a return against somebody else’s subscription404 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.)
CD8Resume an externally-billed plan (ADR-0017)paymentPageUrl is the plan’s own paymentLinkUrl; no provider API call.
CD8bResume a checkout whose payment already settled, or whose charge is in flight409 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.
CD8cPayment-page creation fails while RESUMINGThe 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.
CD8dReturn URLssuccessUrl, cancelUrl and failedUrl all carry sub=<id>; a client that already appended one does not get a second.
CD9Member taps Cancel purchasePOST …/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.
CD10Cancel purchase when a payment already settled409 cancel_pending_unsafe (“contact the gym”); the row is untouched.
CD11Cancel purchase while a charge is in flight (charge_started_at)409 cancel_pending_unsafe; the row is untouched.
CD12A late payment.completed arrives for a rolled-back checkoutThe subscription is REVIVED (revivingAbandonedCheckout) and reappears in the member’s list. This is why the rollback must keep the abandoned_checkout reason.
CD13Row released by the 24h sweep, or dismissed by staffStays 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.
CD13bDeclined checkout still pending after an hourSwept (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.
CD14Cardcom 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.
CD14bSame expiry against a LIVE subscription, another org’s checkout, or a page already closedNothing touched.
CD14cExpiry for an OLD page while a newer attempt is open on the same subscriptionOnly the old page’s attempt is closed.
CD14dGenuine decline: payment.failed WITH a TranzactionId we never storedResolved through lowProfileId to the pending attempt (not transaction_not_on_file); attempt failed with the gateway’s errorMessage; subscription stays pending.
CD15detectStuckPending over a declined checkoutoutcome: 'ignored', reason names the decline. A checkout stuck with no decline behind it still emits failure.
CD16Join-link sweep releases a declined vs an abandoned checkoutcause: 'declined' / 'abandoned'; the CRM note names the decline and says to offer another payment method. Membership is released either way.
CD17Join-link checkout released unpaidSeat 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”.
CD18Recovery mail with no live join link anywhereMail still sends, button dropped — never a link to a page that 404s.
CD19Card 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.
CD20Expired pageThe release sweep sends the ABANDON letter; the word “declined” never appears.
CD22Released join-link registrant signs in”Membership inactive” screen offers a working way back; the contact-your-gym line is replaced, not stacked.
CD23Suspended member, or one removed by staff, signs inNo rejoin button — the screen keeps today’s copy.
CD13cTwo-day-old checkout resumed minutes agoNOT swept — idleness is measured from the newest open attempt.
CD14Staff dismiss from the member pagePOST /organizations/:orgId/subscriptions/:id/void-pending, owner/admin only (member → 403, coach → 403). Same money-safety refusals as the member path.
CD15Deployed mobile build posts cancel-pending with no bodyIdentical to today: row cancelled, checkoutCancel.intent null. No client is broken by the new field.
CD16deriveFailedUrl on a cancel URL with no status=cancelledReturns undefined; the adapter falls back to cancelUrl, i.e. exactly today’s behavior. Never a guessed URL.
CD17Morning-backed orgUnchanged — 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.

#ScenarioExpected
CV1Flag OFF / unset / PostHog unreachable, presale checkoutLowProfile/Create carries Operation: CreateTokenOnly and no AdvancedDefinition — byte-for-byte today’s request.
CV2Flag ON, presale checkoutOperation: SuspendedDeal, AdvancedDefinition.JValidateType: 5, Amount = the plan price. Ledger row still recorded at 0 with metadata.tokenOnly.
CV3Flag ON, card registrationSame operation, Amount: 1 — the member’s statement shows a ₪1 hold, not the plan price.
CV4Flag ON, ordinary (charging) checkoutUntouched: ChargeAndCreateToken, policy never consulted.
CV5Live terminal: valid card on a suspended dealGetLpResult 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.
CV6Live terminal: wrong CVV / expiry / no creditCardcom 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.
CV7A 700/701 on a ChargeAndCreateToken dealClassified failed — the J-codes are trusted only on CreateTokenOnly / SuspendedDeal.
CV8Suspended deal whose token sits in TranzactionInfo onlyToken, CardMonth and CardYear still land in the event metadata and on the payment method.
CV9Opening day after a suspended dealThe 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.