Platform Billing — Behavior
State machine — platform_billing_subscriptions.status
platform_billing_status enum: active | past_due | cancelled (libs/db/src/lib/schema/enums.ts:192).
| From | To | Driver |
|---|---|---|
| new | active | First successful checkout in handlePaymentCompleted (platform-billing.service.ts:344 → upsertSubscription). |
active | past_due | Failed renewal, failedPaymentCount < 3 (platform-billing-recurring.service.ts:375). |
past_due | active | Subsequent successful renewal (platform-billing-recurring.service.ts:299). |
| `active | past_due` | cancelled |
active | cancelled | Owner clicks Cancel → cancelAtPeriodEnd=true; cron finalises when currentPeriodEnd ≤ now. |
active | cancelled | Admin force-cancel (immediate). |
cancelled | active | New checkout. upsertSubscription re-uses the row and resets all fields. |
On every cancelled transition, organizations.platform_tier flips to lite in the same DB transaction.
State machine — platform_billing_transactions.status
transaction_status enum (shared with per-org payments): pending | completed | failed | cancelled. (refund_pending and refunded exist in the enum but are not used here today.)
| From | To | Where |
|---|---|---|
| new | pending | createCheckoutSession inserts the row before redirecting (platform-billing.service.ts:253). |
pending | completed | handlePaymentCompleted (:294). Guard rejects any non-pending source state. |
pending | failed | handlePaymentFailed. Same guard. |
pending | cancelled | cancelSubscriptionInternal cancels in-flight pendings (:749) so a delayed webhook can’t re-activate a cancelled sub. |
pending | cancelled | Daily 02:30 sweep of pendings older than 7 days (platform-billing-recurring.service.ts:68). |
Checkout flow (detailed)
PlatformBillingController.createCheckout → PlatformBillingService.createCheckoutSession (platform-billing.service.ts:126):
- Role check — owner only. (
:136) - Free tier guard — refuses
tier='lite'(price=0 has no checkout). (:142) - Per-org advisory lock —
pg_advisory_xact_lock(hashtext('platform-billing:checkout:<orgId>'))(:155). Two parallelPOST /checkoutcalls serialise. - Reuse window — look up
pendingcharges < 10 minutes old. If one matches the target tier and carries apaymentPageUrlin metadata, return it (Cardcom URLs are valid ~30min). - Stale cancel — mark any
pendingrows for other tiers ascancelledso the owner can’t accidentally pay for the wrong tier from a stale tab. - Cardcom call —
CardcomProvider.createPaymentPagewithChargeAndCreateTokenoperation; readsPLATFORM_BILLING_*env directly viagetPlatformCredentials(). - Empty processId guard —
:247— Cardcom must return aLowProfileId; we refuse to persist a row with an emptyprovider_transaction_id(would corrupt every join). - Insert the pending row with
metadata = { tier, orgId, paymentPageUrl }.
Webhook flow
PlatformBillingWebhookController.handleCardcomWebhook (platform-billing-webhook.controller.ts:38):
- Reject if body lacks
LowProfileId. - Trust nothing in the body — call
adapter.getTransactionStatus(credentials, processId)to ask Cardcom for the truth. - Branch:
verification.status='completed'→handlePaymentCompletedwith{ ...body, verification: rawResponse }so both shapes are available for token extraction (platform-billing.service.ts:65).verification.status='failed'→handlePaymentFailed.- Otherwise (Cardcom hasn’t finalised) →
return { status: 'deferred' }; reconciler will revisit.
The handler is wrapped in a single DB transaction with SELECT … FOR UPDATE LIMIT 1 on the txn row (platform-billing.service.ts:296). State machine guards refuse any source state that isn’t pending (idempotency).
Three converging paths
| Path | When it fires | What it does |
|---|---|---|
| Webhook | Cardcom IPN POSTs /webhooks/platform-billing/cardcom | Verifies via getTransactionStatus; routes to completed/failed/deferred. |
| Verify-on-return | Buyer returns to …/checkout-return → web app POSTs /billing/verify-checkout (platform-billing.service.ts:501) | Same verification + same handler. Owner role + org ownership re-checked. |
| Reconciler | Cron */5 * * * * (platform-billing-reconciler.service.ts:55) | Picks pending rows 90s–60min old; calls the same verification; flips to terminal or fires a hang alert at 10min age. |
All three converge on handlePaymentCompleted / handlePaymentFailed. State-machine guards make the operation idempotent regardless of which path wins.
Renewal cron
PlatformBillingRecurringService.sweep — 0 2 * * * (platform-billing-recurring.service.ts:98).
Selector (:117):
WHERE status != 'cancelled'
AND current_period_end IS NOT NULL
AND current_period_end <= now + 1h -- CHARGE_LEAD_TIME_MS (negative)
ORDER BY current_period_end ASC
LIMIT 100Per sub:
| Branch | Outcome |
|---|---|
cancelAtPeriodEnd=true && currentPeriodEnd ≤ now | cancelSubscriptionInternal('scheduled-cancel-period-end') → sub cancelled, org → Lite. |
cancelAtPeriodEnd=true && period not yet over | skipped_not_due (the lead-time selector picks this up early). |
No providerToken && period over | expireToLite — sub cancelled with cancelReason='expired-no-token', org → Lite. |
No providerToken && period not yet over | skipped_no_token. |
| Has token | adapter.createCharge(token, amount * 100, …). On success → mark charged, period advances by 1 month. On failure → markFailed. |
On markFailed:
failed_payment_count += 1.- If new count ≥ 3 → sub
cancelled, org →lite,cancelReason='max-failed-payments'. - Otherwise → sub
past_due,lastFailedAt=now. - A
failedplatform_billing_transactionsrow is inserted withamount=0for telemetry.
Cancellation
| Endpoint | Behaviour |
|---|---|
POST /organizations/:orgId/billing/cancel | cancelSubscription (platform-billing.service.ts:608) — owner only. Sets cancelAtPeriodEnd=true; sub stays active until cron finalises. Reversible via /resume. Falls back to immediate cancel if no currentPeriodEnd. |
POST /organizations/:orgId/billing/resume | resumeSubscription — clears the flag. Refuses if status isn’t active or flag already false. |
cancelSubscriptionInternal(orgId, reason) | Immediate. Used by max-failed-payments, admin, and tier-downgrade-to-lite. Cancels in-flight pendings + flips org to Lite in the same transaction. |
PATCH /organizations/:orgId/tier { tier: 'lite' } | Owner only. Routes through cancelSubscription (period-end). |
PATCH /tier { tier: <paid_other> } from another paid tier | Unsupported — 400 PAID_DOWNGRADE_UNSUPPORTED (platform-tiers.controller.ts:88). |
PATCH /tier { tier: <paid> } from Lite | Unsupported — 400 PAYMENT_REQUIRED; owner must go through /checkout. |
Permissions
| Action | Required role |
|---|---|
POST /billing/checkout | owner |
POST /billing/cancel | owner |
POST /billing/resume | owner |
POST /billing/verify-checkout | owner |
GET /billing | any active member |
GET /billing/history | any active member |
PATCH /tier | owner |
GET /tier, GET /tier/usage | any active member |
Idempotency
| Mechanism | Where |
|---|---|
State-machine guard status !== 'pending' → skip | platform-billing.service.ts:313, :407 |
SELECT FOR UPDATE LIMIT 1 per processId in handlers | :296, :401 |
Unique partial index on platform_billing_transactions.provider_transaction_id WHERE provider_transaction_id IS NOT NULL | libs/db/src/lib/schema/platform-billing.ts:120 |
| Reuse-window for pending checkouts (10 min) | platform-billing.service.ts:159 |
| Org-level advisory lock during checkout creation | :155 |
| Cancel pending charges when sub is cancelled (rejects late webhook activation) | :749 |
Reconciler hang-alert dedupe via metadata.alertedAt | platform-billing-reconciler.service.ts:201 |
Edge cases
| Case | Behaviour |
|---|---|
| Cardcom IPN arrives 12 hours after the user paid | Webhook re-fetches getTransactionStatus. Truth wins. Sub activates as if webhook had been on time (period start = now, not original pay time — gap). |
| Buyer pays, webhook lost, user closes browser before return | Reconciler picks up at 90s+; flips sub to active within 5 min. |
| Buyer pays, webhook lost, user lands on return page | Verify-checkout fires synchronously; sub activates inside the same request. |
| Org has a pending checkout, owner clicks “Upgrade” again with same tier | Reuse-window returns the same URL. |
| Org has a pending checkout for Pro, owner switches to Elite | Pro pending → cancelled (“stale”); new Elite pending created. |
Cardcom returns success page but no LowProfileId | Service refuses to persist (:247); 400 to client. |
Same LowProfileId posted twice as webhook | Second hit: txn.status === 'completed' → early-return; no duplicate sub upsert. |
Org’s provider_token becomes invalid (e.g. card expired) | Charge fails → markFailed. After 3 cycles, sub cancelled, org → Lite. No automatic re-tokenisation flow exists. |
| Reconciler can’t reach Cardcom | Sentry.captureException; returns unknown. Row stays pending; next tick retries. |
| Webhook arrives for a cancelled sub | cancelSubscriptionInternal already cancelled the pending row to cancelled, so the guard refuses the activation. |
Side effects
| Action | Side effect |
|---|---|
| Checkout completed | organizations.platform_tier flipped (immediate feature access); Cardcom token stored; period set to now + 1mo. |
| Renewal succeeded | New recurring txn row; lastChargedAt=now; counters reset. |
| Renewal failed (counter < 3) | New failed txn row (amount=0); sub past_due. |
| Renewal failed (counter ≥ 3) | Sub cancelled; org → Lite — features instantly revoked on next request. |
| Cancel scheduled | Sub stays active; in-flight pendings untouched. |
| Cancel immediate | Pendings flipped to cancelled; org → Lite. |
There is no email on platform-billing events (FIT-tier; would be a nice-to-have). The owner sees status changes via the dashboard.