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.
S5Member self-cancel at period endMember: POST /subscriptions/my/:id/cancel-at-period-end with reason.cancelAtPeriodEnd=true; sub stays active; cancellation_review task created (priority=low). Email sent.
S6Member resumes a scheduled cancellationContinue S5: POST /subscriptions/my/:id/resume.cancelAtPeriodEnd=false; reason cleared.
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.

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).

E2E (Playwright)

  • apps/web/e2e/specs/payments-*.spec.ts — owner refund flow, member self-cancel.
  • 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.