ADR 0026 — Payment Control: Prepaid/Postpaid Subscriptions and Hour-Balance Enforcement
Status: Accepted — §3/§4 amended by ADR 0028 (hour exhaustion is now a soft 402 gate, not a suspension; only delinquency suspends) Context date: 2026-07-27
Context
Platform operators can meter tenants (the billing service's Kafka room-events pipeline already aggregates usage_daily.participant_minutes per tenant) and can suspend them (ADR 0025), but nothing connects the two: a tenant consumes unbounded platform time regardless of what they have paid for. Operators need to sell accessible hours — a tenant purchases a block of hours, and when it runs out, access stops.
Scope boundaries for this iteration:
- The currency is hours, stored as minutes. Money, pricing, invoices, and Stripe are explicitly out of scope — but the schema must leave room for a future invoice engine to reconcile against.
- Pre-existing tenants must be untouched. Enforcement can only apply to tenants an operator has explicitly put under payment control.
- The metering hot path must not change.
meter.goand the Kafka consumption pipeline are proven under at-least-once delivery; a balance mechanism must not introduce a counter that redelivery can drift. - No second access-control path. ADR 0025 built exactly one suspension machinery (auth gates credentials, room projection gates APIs and sweeps live rooms, notification emails admins); payment enforcement must drive it, not duplicate it.
Decision
1. billing_db owns subscription state and an append-only hour ledger
Two new tables land in the billing service's migrations, because consumed minutes already live in billing_db — putting grants there too makes the balance a single-DB query:
tenant_subscription— one row per managed tenant (tenant_idPK):plan_type(prepaid|postpaid),credit_cap_minutes(nullable),delinquent(bool), apayment_statusmirror, timestamps.hour_grants— an append-only ledger:id,tenant_id,minutes(signed),reason,granted_by,idempotency_key(unique per tenant when present),created_at.
The balance is derived at read time: balance = Σ hour_grants.minutes − Σ usage_daily.participant_minutes. Nothing is ever decremented — Kafka at-least-once redelivery cannot drift a counter that does not exist, and meter.go is deliberately untouched. The append-only ledger gives an audit trail, idempotent top-ups (retried requests with the same key insert once), reversibility (a clawback is a negative row), and the reconciliation substrate a future invoice engine needs.
2. Access rule: unmanaged tenants are never enforced
- No
tenant_subscriptionrow → unmanaged. The tenant is invisible to payment control — this is what protects every pre-existing tenant. - With a row:
delinquent→ blocked, regardless of balance. prepaid→ blocked when balance ≤ 0.postpaid→ blocked only when acredit_cap_minutesis set and balance ≤ −cap (postpaid tenants overdraft into negative balance by design; no cap means never blocked by balance).
3. A periodic reconciler drives ADR 0025's suspension machinery — one access-control path
A reconciler goroutine in the admin gateway (default 60s tick, Postgres advisory lock so exactly one replica runs it) computes blocked/allowed for every managed tenant from billing_db, then drives the existing setTenantStatus transaction unchanged: auth_db status update + outbox row → platform-events → room projection gates APIs and sweeps live rooms; auth gates login/refresh/token-mint; notification emails admins. Payment enforcement adds zero new enforcement surface — it is a new decider in front of the proven actuator.
Accepted staleness: reconciler interval + ADR 0025 propagation ≈ tens of seconds to ~2 minutes of overrun past a zero balance. Acceptable because the currency is hours. Enforcement inherits ADR 0025's fail-open posture: if the reconciler is down, over-limit tenants keep running until it recovers.
4. suspend_cause separates payment suspensions from operator bans
A new auth_db column tenants.suspend_cause (NULL | 'operator' | 'payment') makes the two writers of suspension safe around each other:
- The reconciler suspends with cause
'payment'and auto-reactivates only payment-suspended tenants once a top-up brings the balance back over the threshold. - Manual suspend sets
'operator'; manual activate clears the cause. The reconciler never un-suspends an operator ban — an operator's decision always outranks the balance. - The outbox envelope gains a
causefield so downstream consumers (and tenant-facing messaging) can distinguish "pay up" from "banned".
5. Admin API and ops-dashboard panel
The admin gateway exposes (operator-only writes; the tenant dashboard reads the same subscription endpoint tenant-scoped):
GET /admin/v1/tenants/{id}/subscription— plan, flags, and the derived balance.PUT /admin/v1/tenants/{id}/subscription— create/update plan type, credit cap, delinquent flag.POST /admin/v1/tenants/{id}/hours— top-up or clawback (signed minutes) with an idempotency key.GET /admin/v1/tenants/{id}/hours— the grant ledger.
The ops-dashboard tenant detail page gains a payment-control panel over these endpoints.
Alternatives Considered
- auth_db owns the subscription and grant tables. Rejected: consumed minutes live in billing_db, so every balance read becomes a cross-DB join — the one query the whole feature revolves around would violate database-per-service on its hot path. Auth keeps only the enforcement bit it already owns (
status, now plussuspend_cause). - Meter-side balance decrement. Rejected: a mutable counter beside the usage rows is a second source of truth that drifts under Kafka at-least-once redelivery — exactly the failure mode the derived-at-read-time balance is immune to.
- Synchronous balance check at room join. Rejected: a second access-control path (ADR 0025 built the first one precisely to be the only one), and the room service does not own balance data — it would need a sync call to billing on the hottest path in the system.
- A single mutable balance row per tenant. Rejected: no audit trail, no idempotent top-ups, no reversibility, and the classic read-modify-write drift under concurrency. The append-only ledger costs one
SUM.
Consequences
- Positive: Operators can now sell access. Prepaid tenants stop at zero, postpaid tenants get a bounded overdraft, delinquent tenants stop immediately — all through the one suspension machinery that already gates credentials, room APIs, and live media.
- Positive: Every hour movement is a ledger row: auditable, idempotent, reversible, and reconcilable by the future invoice engine. The schema leaves room for money without containing any.
- Positive: Unmanaged tenants are structurally exempt (no row, no enforcement) — rollout cannot break a pre-existing tenant.
- Positive: The metering hot path is untouched; consumed is derived, never decremented, so delivery semantics cannot corrupt balances.
- Negative / accepted: The reconciler is a second writer of the suspension machinery, racing with manual operator actions. Mitigated by
suspend_cause(the reconciler only ever reverses its own suspensions), the existing status-transition guards, and the advisory-lock singleton — but the race is real and the mitigation is convention plus guards, not a serialized queue. - Negative / accepted: The admin gateway now writes billing_db directly (subscription upserts, grant inserts) — a step beyond ADR 0025's gateway pattern, which only read other services' data or wrote auth_db. A billing-service write API is the documented future path; until then, billing's schema has a second client.
- Negative / accepted: The derived balance is O(usage rows) per reconciler tick and lifetime-retroactive — "hours" is defined as
participant_minutes, so changing that definition later re-prices all history. A period/rollup column on the grant and usage side is the escape hatch when either the cost or the retroactivity bites. - Negative / accepted: Enforcement lags the balance by up to ~2 minutes (reconciler tick + ADR 0025 propagation) and fails open during a reconciler outage. Acceptable at hour granularity; the overrun is bounded and visible in the ledger.
- Negative / accepted (security): ADR 0025's forgeable-Kafka-boundary risk now also defeats payment suspension — an in-network producer forging
tenant.activatedreopens a payment-suspended tenant. Same follow-up as before: SASL/mTLS with per-service produce ACLs onplatform-events, NetworkPolicy-restricted brokers until then. - Follow-up: Move gateway billing_db writes behind a billing-service API once billing grows a write surface.
- Follow-up: Add a rollup/period boundary to the balance computation before usage history makes the per-tick
SUMexpensive or a pricing-definition change becomes necessary.