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; 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 (gap — see below).
R-A3Refund a refunded txnSecond call to refund(): adapter call may succeed; original status already refunded; no schema-level constraint prevents over-refunding (FIT-134).
R-A4Refund failedAdapter success=false. Original stays completed; emits payment.refund_failed; a failed refund-type txn is recorded.

[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 externalReferencePOST /payments/refund-tasks/:taskId/complete { externalReference }. Task → completed; txn → refunded; refundExternalReference stored; member email sent.
R-M4Close already-closed task (idempotency)Same POST again. Returns { alreadyCompleted: true }. No additional observability event, no duplicate email.
R-M5Close task missing externalReferenceReturns 400.
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 failsfailed_charge_attempts=1, status active → past_due, nextChargeDate = now + 3d, payment_status='past_due', “payment failed” email.
RF2Second renewal failsattempts=2, nextChargeDate = now + 7d.
RF3Third renewal failsattempts=3, status → debt, debt_amount_in_cents += plan.price_in_cents, debt_since stamped, nextChargeDate=NULL, “debt warning” email, payment_status='debt'.
RF4Retry succeeds after past_dueattempts → 0, status past_due → active, period advanced, credits refilled.
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] Mid-cycle cancellation + refund

#ScenarioExpected
MC1Member requests immediate cancel with refundcancellation_requests row created (status='pending', refund_requested=true). Owner email + member email + cancellation_review task (priority=urgent).
MC2Owner approves with refundSub canceled immediately. Most-recent completed charge txn is refunded via capability-aware flow. Request → approved; refund_task_id filled if manual.
MC3Owner rejectsRequest → rejected; sub stays active. Member email.
MC4Approve when no completed charge existsSub canceled; refund step is skipped (cancellation-requests.service.ts:282); request still moves to approved.
MC5Duplicate pending requestSecond POST to /cancellation-requests for same sub: 400 A cancellation request is already pending.
MC6Member cancels someone else’s sub403 You can only cancel your own subscription.

[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.
W6Webhook signature missing (Cardcom)Controller passes (Cardcom returns true unconditionally). Defence comes from URL-secrecy + re-fetch on verify-return.
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: 0this 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.

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.

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.
  • Partial-refund sum check is application-only and not enforced (R-A2 / R-A3). The closing manual task can over-refund the original charge if the gym owner enters wrong amounts.

Manual charges & debt collection (FIT-254 §3.3/§3.4)

#ScenarioExpected
MCH1Flag offAny org without admin-card-on-file evaluated true → 403 before any DB/provider call. UI hides the charge button/dialog entirely.
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 = max(now, currentPeriodEnd) and failedChargeAttempts=0 (A6 — previously nextChargeDate stayed NULL forever); membership payment_status='current'. Pending-row-first: the txn row exists (status pendingcompleted) 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='debt' 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.
CD17checkout-recovery-emails OFFSeat still released, no mail. Flag ON: 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), checkout-recovery-emails ONThe 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 page, checkout-recovery-emails ONThe 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 body, in an org that never had the flagIdentical 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.