Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesPlatform BillingPlatform Billing — QA Plan

Platform Billing — QA Plan

Smoke

#ScenarioExpected
S1Owner starts Pro checkoutPOST /billing/checkout { tier: 'pro' }. platform_billing_transactions row inserted status='pending', amount=249, metadata carries tier and paymentPageUrl. Cardcom URL returned.
S2Buyer pays Pro via Cardcom test cardWebhook fires → sub active, period set to now+1mo, org platform_tier='pro'. Token stored in provider_token.
S3Buyer pays + webhook fails to deliverBuyer returns to /dashboard/settings/checkout-return → page calls verify-checkout → same activation happens synchronously.
S4Reuse windowOwner clicks Upgrade twice within 10 min → second call returns the same URL (no duplicate row).
S5Switch tier mid-checkoutOwner clicks Pro then immediately Elite → Pro pending row → cancelled, Elite pending row created.
S6Get billing statusGET /billing returns tier, status, period, failure counter, cancellation effective-at.
S7Free tier checkout refusedPOST /billing/checkout { tier: 'lite' } → 400 Cannot create checkout for a free tier.

Critical money flows

[C] Webhook idempotency

#ScenarioExpected
W1Same IPN posted twiceFirst flips txn → completed, sub upserted. Second hits guard status !== 'pending' → early-return; no duplicate sub mutation.
W2Webhook + verify-return raceBoth paths call handlePaymentCompleted. SELECT FOR UPDATE serialises; one wins; the other early-returns.
W3Webhook + reconciler raceSame as W2; reconciler verifies via the same getTransactionStatus.
W4Cardcom returns “not finalised”Webhook handler logs deferred; row stays pending. Reconciler retries.
W5Webhook arrives after manual cancelcancelSubscriptionInternal flipped the pending row to cancelled. Webhook handler refuses transition out of cancelled. Org stays on Lite.
W6Webhook body missing LowProfileIdController returns { status: 'ignored' }; no processing.

[C] Recurring renewal (FIT-136 surface)

#ScenarioExpected
R1Renewal at period end with valid tokenCardcom charge succeeds; sub active, period advanced, failed_payment_count=0, lastChargedAt updated, new recurring completed txn inserted.
R2Renewal fails (1st)Sub past_due, count=1, failed txn inserted. Today no email is sent — gap.
R3Renewal fails (3rd)Sub cancelled, org → Lite. cancelReason='max-failed-payments'.
R4No token, period expiredexpireToLite — sub cancelled (cancelReason='expired-no-token'), org → Lite.
R5cancelAtPeriodEnd=true + period expiredcancelSubscriptionInternal('scheduled-cancel-period-end') — sub cancelled, org → Lite. No charge attempted.
R6Per-tick limit (100)More than 100 due subs → first 100 by currentPeriodEnd ASC; rest wait for next day.

[C] Cancellation

#ScenarioExpected
C1Owner cancels ProcancelAtPeriodEnd=true. Sub stays active. Org keeps Pro until currentPeriodEnd.
C2Owner resumes before cron sweepsFlag cleared; sub continues normally.
C3Owner cancels then waits past periodCron finalises; sub cancelled, org → Lite.
C4Cancel sub with no currentPeriodEndFalls back to cancelSubscriptionInternal (immediate) with a warning log.
C5Admin force-cancels mid-periodSub cancelled immediately; org → Lite. In-flight pending charges marked cancelled.
C6PATCH /tier { tier: 'lite' } while ProRoutes through cancelSubscription (period-end).
C7PATCH /tier { tier: 'pro' } while Lite400 PAYMENT_REQUIRED — owner must use /checkout.
C8PATCH /tier { tier: 'elite' } while Pro400 PAID_DOWNGRADE_UNSUPPORTED — must cancel + re-checkout.

[C] Reconciler

#ScenarioExpected
RC1Pending row aged 2 min, Cardcom says completedReconciler flips it via handlePaymentCompleted. Outcome counted as completed.
RC2Pending row aged 11 min, Cardcom still pendingFires Sentry warning Platform billing checkout hung. Outcome hang_alerted.
RC3Pending row aged 11 min, alertedAt set 30 min agoWithin dedupe window; no Sentry call. Outcome still_pending.
RC4Pending row aged > 1 hourSelector excludes (RECONCILE_BEFORE_MS = 60min); the stale-pending cron at 02:30 picks it up after 7 days and marks cancelled.
RC5Cardcom unreachableSentry.captureException; outcome unknown. Next 5-min tick retries.

Tier guard

#ScenarioExpected
T1Lite org calls POST /plansAllowed (membership_plans is in Lite).
T2Lite org creates a class_pack plan403 Feature "class_packs" requires a higher platform tier (service-level check).
T3Lite org calls POST /organizations/:orgId/courses403 Feature "courses" requires a higher platform tier.
T4Pro org calls POST /organizations/:orgId/locations (new)403 once multiple_locations is enforced; Elite required.
T5Tier downgrade revokes features mid-sessionNext request after the cron flip returns 403; the running session loses feature access immediately.
T6Missing orgId URL paramGuard short-circuits to true — relies on the route to enforce its own org binding.

Permission tests

#ScenarioExpected
P1Admin (not owner) calls /billing/checkout403 (only owner).
P2Coach calls /billing/cancel403.
P3Member calls GET /billing200 (any active member).
P4Owner of org A calls /verify-checkout with org B’s lowProfileId403 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_token plaintext — 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.