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 | Member self-cancel at period end | Member: POST /subscriptions/my/:id/cancel-at-period-end with reason. | cancelAtPeriodEnd=true; sub stays active; cancellation_review task created (priority=low). Email sent. |
| S6 | Member resumes a scheduled cancellation | Continue S5: POST /subscriptions/my/:id/resume. | cancelAtPeriodEnd=false; reason cleared. |
| 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; 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 (gap — see below). |
| R-A3 | Refund a refunded txn | Second call to refund(): adapter call may succeed; original status already refunded; no schema-level constraint prevents over-refunding (FIT-134). |
| R-A4 | Refund failed | Adapter success=false. Original stays completed; emits payment.refund_failed; a failed refund-type txn is recorded. |
[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 externalReference | POST /payments/refund-tasks/:taskId/complete { externalReference }. Task → completed; txn → refunded; refundExternalReference stored; member email sent. |
| R-M4 | Close already-closed task (idempotency) | Same POST again. Returns { alreadyCompleted: true }. No additional observability event, no duplicate email. |
| R-M5 | Close task missing externalReference | Returns 400. |
| 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 | failed_charge_attempts=1, status active → past_due, nextChargeDate = now + 3d, payment_status='past_due', “payment failed” email. |
| RF2 | Second renewal fails | attempts=2, nextChargeDate = now + 7d. |
| RF3 | Third renewal fails | attempts=3, status → debt, debt_amount_in_cents += plan.price_in_cents, debt_since stamped, nextChargeDate=NULL, “debt warning” email, payment_status='debt'. |
| RF4 | Retry succeeds after past_due | attempts → 0, status past_due → active, period advanced, credits refilled. |
| 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] Mid-cycle cancellation + refund
| # | Scenario | Expected |
|---|---|---|
| MC1 | Member requests immediate cancel with refund | cancellation_requests row created (status='pending', refund_requested=true). Owner email + member email + cancellation_review task (priority=urgent). |
| MC2 | Owner approves with refund | Sub canceled immediately. Most-recent completed charge txn is refunded via capability-aware flow. Request → approved; refund_task_id filled if manual. |
| MC3 | Owner rejects | Request → rejected; sub stays active. Member email. |
| MC4 | Approve when no completed charge exists | Sub canceled; refund step is skipped (cancellation-requests.service.ts:282); request still moves to approved. |
| MC5 | Duplicate pending request | Second POST to /cancellation-requests for same sub: 400 A cancellation request is already pending. |
| MC6 | Member cancels someone else’s sub | 403 You can only cancel your own subscription. |
[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 | Webhook signature missing (Cardcom) | Controller passes (Cardcom returns true unconditionally). Defence comes from URL-secrecy + re-fetch on verify-return. |
| 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. |
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). |
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.