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

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

FromToDriver
newactiveFirst successful checkout in handlePaymentCompleted (platform-billing.service.ts:344upsertSubscription).
activepast_dueFailed renewal, failedPaymentCount < 3 (platform-billing-recurring.service.ts:375).
past_dueactiveSubsequent successful renewal (platform-billing-recurring.service.ts:299).
`activepast_due`cancelled
activecancelledOwner clicks Cancel → cancelAtPeriodEnd=true; cron finalises when currentPeriodEnd ≤ now.
activecancelledAdmin force-cancel (immediate).
cancelledactiveNew 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.)

FromToWhere
newpendingcreateCheckoutSession inserts the row before redirecting (platform-billing.service.ts:253).
pendingcompletedhandlePaymentCompleted (:294). Guard rejects any non-pending source state.
pendingfailedhandlePaymentFailed. Same guard.
pendingcancelledcancelSubscriptionInternal cancels in-flight pendings (:749) so a delayed webhook can’t re-activate a cancelled sub.
pendingcancelledDaily 02:30 sweep of pendings older than 7 days (platform-billing-recurring.service.ts:68).

Checkout flow (detailed)

PlatformBillingController.createCheckoutPlatformBillingService.createCheckoutSession (platform-billing.service.ts:126):

  1. Role check — owner only. (:136)
  2. Free tier guard — refuses tier='lite' (price=0 has no checkout). (:142)
  3. Per-org advisory lockpg_advisory_xact_lock(hashtext('platform-billing:checkout:<orgId>')) (:155). Two parallel POST /checkout calls serialise.
  4. Reuse window — look up pending charges < 10 minutes old. If one matches the target tier and carries a paymentPageUrl in metadata, return it (Cardcom URLs are valid ~30min).
  5. Stale cancel — mark any pending rows for other tiers as cancelled so the owner can’t accidentally pay for the wrong tier from a stale tab.
  6. Cardcom callCardcomProvider.createPaymentPage with ChargeAndCreateToken operation; reads PLATFORM_BILLING_* env directly via getPlatformCredentials().
  7. Empty processId guard:247 — Cardcom must return a LowProfileId; we refuse to persist a row with an empty provider_transaction_id (would corrupt every join).
  8. Insert the pending row with metadata = { tier, orgId, paymentPageUrl }.

Webhook flow

PlatformBillingWebhookController.handleCardcomWebhook (platform-billing-webhook.controller.ts:38):

  1. Reject if body lacks LowProfileId.
  2. Trust nothing in the body — call adapter.getTransactionStatus(credentials, processId) to ask Cardcom for the truth.
  3. Branch:
    • verification.status='completed'handlePaymentCompleted with { ...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

PathWhen it firesWhat it does
WebhookCardcom IPN POSTs /webhooks/platform-billing/cardcomVerifies via getTransactionStatus; routes to completed/failed/deferred.
Verify-on-returnBuyer 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.
ReconcilerCron */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.sweep0 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 100

Per sub:

BranchOutcome
cancelAtPeriodEnd=true && currentPeriodEnd ≤ nowcancelSubscriptionInternal('scheduled-cancel-period-end') → sub cancelled, org → Lite.
cancelAtPeriodEnd=true && period not yet overskipped_not_due (the lead-time selector picks this up early).
No providerToken && period overexpireToLite — sub cancelled with cancelReason='expired-no-token', org → Lite.
No providerToken && period not yet overskipped_no_token.
Has tokenadapter.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 failed platform_billing_transactions row is inserted with amount=0 for telemetry.

Cancellation

EndpointBehaviour
POST /organizations/:orgId/billing/cancelcancelSubscription (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/resumeresumeSubscription — 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 tierUnsupported — 400 PAID_DOWNGRADE_UNSUPPORTED (platform-tiers.controller.ts:88).
PATCH /tier { tier: <paid> } from LiteUnsupported — 400 PAYMENT_REQUIRED; owner must go through /checkout.

Permissions

ActionRequired role
POST /billing/checkoutowner
POST /billing/cancelowner
POST /billing/resumeowner
POST /billing/verify-checkoutowner
GET /billingany active member
GET /billing/historyany active member
PATCH /tierowner
GET /tier, GET /tier/usageany active member

Idempotency

MechanismWhere
State-machine guard status !== 'pending' → skipplatform-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 NULLlibs/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.alertedAtplatform-billing-reconciler.service.ts:201

Edge cases

CaseBehaviour
Cardcom IPN arrives 12 hours after the user paidWebhook 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 returnReconciler picks up at 90s+; flips sub to active within 5 min.
Buyer pays, webhook lost, user lands on return pageVerify-checkout fires synchronously; sub activates inside the same request.
Org has a pending checkout, owner clicks “Upgrade” again with same tierReuse-window returns the same URL.
Org has a pending checkout for Pro, owner switches to ElitePro pending → cancelled (“stale”); new Elite pending created.
Cardcom returns success page but no LowProfileIdService refuses to persist (:247); 400 to client.
Same LowProfileId posted twice as webhookSecond 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 CardcomSentry.captureException; returns unknown. Row stays pending; next tick retries.
Webhook arrives for a cancelled subcancelSubscriptionInternal already cancelled the pending row to cancelled, so the guard refuses the activation.

Side effects

ActionSide effect
Checkout completedorganizations.platform_tier flipped (immediate feature access); Cardcom token stored; period set to now + 1mo.
Renewal succeededNew 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 scheduledSub stays active; in-flight pendings untouched.
Cancel immediatePendings 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.