Skip to main content

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:

  1. 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.
  2. Pre-existing tenants must be untouched. Enforcement can only apply to tenants an operator has explicitly put under payment control.
  3. The metering hot path must not change. meter.go and the Kafka consumption pipeline are proven under at-least-once delivery; a balance mechanism must not introduce a counter that redelivery can drift.
  4. 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_id PK): plan_type (prepaid | postpaid), credit_cap_minutes (nullable), delinquent (bool), a payment_status mirror, 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_subscription row → unmanaged. The tenant is invisible to payment control — this is what protects every pre-existing tenant.
  • With a row: delinquentblocked, regardless of balance.
  • prepaid → blocked when balance ≤ 0.
  • postpaid → blocked only when a credit_cap_minutes is 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 cause field 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 plus suspend_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.activated reopens a payment-suspended tenant. Same follow-up as before: SASL/mTLS with per-service produce ACLs on platform-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 SUM expensive or a pricing-definition change becomes necessary.