Platform Billing — QA Plan
Smoke
| # | Scenario | Expected |
|---|---|---|
| S1 | Owner starts Pro checkout | POST /billing/checkout { tier: 'pro' }. platform_billing_transactions row inserted status='pending', amount=249, metadata carries tier and paymentPageUrl. Cardcom URL returned. |
| S2 | Buyer pays Pro via Cardcom test card | Webhook fires → sub active, period set to now+1mo, org platform_tier='pro'. Token stored in provider_token. |
| S3 | Buyer pays + webhook fails to deliver | Buyer returns to /dashboard/settings/checkout-return → page calls verify-checkout → same activation happens synchronously. |
| S4 | Reuse window | Owner clicks Upgrade twice within 10 min → second call returns the same URL (no duplicate row). |
| S5 | Switch tier mid-checkout | Owner clicks Pro then immediately Elite → Pro pending row → cancelled, Elite pending row created. |
| S6 | Get billing status | GET /billing returns tier, status, period, failure counter, cancellation effective-at. |
| S7 | Free tier checkout refused | POST /billing/checkout { tier: 'lite' } → 400 Cannot create checkout for a free tier. |
Critical money flows
[C] Webhook idempotency
| # | Scenario | Expected |
|---|---|---|
| W1 | Same IPN posted twice | First flips txn → completed, sub upserted. Second hits guard status !== 'pending' → early-return; no duplicate sub mutation. |
| W2 | Webhook + verify-return race | Both paths call handlePaymentCompleted. SELECT FOR UPDATE serialises; one wins; the other early-returns. |
| W3 | Webhook + reconciler race | Same as W2; reconciler verifies via the same getTransactionStatus. |
| W4 | Cardcom returns “not finalised” | Webhook handler logs deferred; row stays pending. Reconciler retries. |
| W5 | Webhook arrives after manual cancel | cancelSubscriptionInternal flipped the pending row to cancelled. Webhook handler refuses transition out of cancelled. Org stays on Lite. |
| W6 | Webhook body missing LowProfileId | Controller returns { status: 'ignored' }; no processing. |
[C] Recurring renewal (FIT-136 surface)
| # | Scenario | Expected |
|---|---|---|
| R1 | Renewal at period end with valid token | Cardcom charge succeeds; sub active, period advanced, failed_payment_count=0, lastChargedAt updated, new recurring completed txn inserted. |
| R2 | Renewal fails (1st) | Sub past_due, count=1, failed txn inserted. Today no email is sent — gap. |
| R3 | Renewal fails (3rd) | Sub cancelled, org → Lite. cancelReason='max-failed-payments'. |
| R4 | No token, period expired | expireToLite — sub cancelled (cancelReason='expired-no-token'), org → Lite. |
| R5 | cancelAtPeriodEnd=true + period expired | cancelSubscriptionInternal('scheduled-cancel-period-end') — sub cancelled, org → Lite. No charge attempted. |
| R6 | Per-tick limit (100) | More than 100 due subs → first 100 by currentPeriodEnd ASC; rest wait for next day. |
[C] Cancellation
| # | Scenario | Expected |
|---|---|---|
| C1 | Owner cancels Pro | cancelAtPeriodEnd=true. Sub stays active. Org keeps Pro until currentPeriodEnd. |
| C2 | Owner resumes before cron sweeps | Flag cleared; sub continues normally. |
| C3 | Owner cancels then waits past period | Cron finalises; sub cancelled, org → Lite. |
| C4 | Cancel sub with no currentPeriodEnd | Falls back to cancelSubscriptionInternal (immediate) with a warning log. |
| C5 | Admin force-cancels mid-period | Sub cancelled immediately; org → Lite. In-flight pending charges marked cancelled. |
| C6 | PATCH /tier { tier: 'lite' } while Pro | Routes through cancelSubscription (period-end). |
| C7 | PATCH /tier { tier: 'pro' } while Lite | 400 PAYMENT_REQUIRED — owner must use /checkout. |
| C8 | PATCH /tier { tier: 'elite' } while Pro | 400 PAID_DOWNGRADE_UNSUPPORTED — must cancel + re-checkout. |
[C] Reconciler
| # | Scenario | Expected |
|---|---|---|
| RC1 | Pending row aged 2 min, Cardcom says completed | Reconciler flips it via handlePaymentCompleted. Outcome counted as completed. |
| RC2 | Pending row aged 11 min, Cardcom still pending | Fires Sentry warning Platform billing checkout hung. Outcome hang_alerted. |
| RC3 | Pending row aged 11 min, alertedAt set 30 min ago | Within dedupe window; no Sentry call. Outcome still_pending. |
| RC4 | Pending row aged > 1 hour | Selector excludes (RECONCILE_BEFORE_MS = 60min); the stale-pending cron at 02:30 picks it up after 7 days and marks cancelled. |
| RC5 | Cardcom unreachable | Sentry.captureException; outcome unknown. Next 5-min tick retries. |
Tier guard
| # | Scenario | Expected |
|---|---|---|
| T1 | Lite org calls POST /plans | Allowed (membership_plans is in Lite). |
| T2 | Lite org creates a class_pack plan | 403 Feature "class_packs" requires a higher platform tier (service-level check). |
| T3 | Lite org calls POST /organizations/:orgId/courses | 403 Feature "courses" requires a higher platform tier. |
| T4 | Pro org calls POST /organizations/:orgId/locations (new) | 403 once multiple_locations is enforced; Elite required. |
| T5 | Tier downgrade revokes features mid-session | Next request after the cron flip returns 403; the running session loses feature access immediately. |
| T6 | Missing orgId URL param | Guard short-circuits to true — relies on the route to enforce its own org binding. |
Permission tests
| # | Scenario | Expected |
|---|---|---|
| P1 | Admin (not owner) calls /billing/checkout | 403 (only owner). |
| P2 | Coach calls /billing/cancel | 403. |
| P3 | Member calls GET /billing | 200 (any active member). |
| P4 | Owner of org A calls /verify-checkout with org B’s lowProfileId | 403 Transaction does not belong to org. |
E2E
apps/web/e2e/specs/platform-billing-checkout.spec.ts— owner upgrade Lite → Pro, including the Cardcom test-card flow and the return-page verify call.apps/web/e2e/specs/platform-billing-cancel.spec.ts— cancel + resume + period-end finalisation (cron simulated).
Gaps requiring manual QA
- No outbound emails on platform-billing events. QA must check the dashboard, not an inbox.
provider_tokenplaintext — manual review of DB dumps before sharing.- Past-due subs still grant feature access today. Verify per release whether business rules have changed.
- Single Cardcom terminal — failure in production blocks every gym’s renewal. Run-book: temporarily disable the cron (
cronsEnabled()), repair credentials, run a one-off reconciler sweep.