Story 2539: page commercial contract and ledger projection

Why this exists

The public promise is pages, not tokens. To bill that promise PRAMAAN needs immutable commercial receipts: which contract priced a page, which allowance pool absorbed it, what the exact overage cost, and how a cheque paid at a desk becomes usable pages — all reconstructable to the paisa months later. Counters can say how much; only an append-only ledger proves why.

1. Rate cards are data, not code (billing.rate_card)

One immutable row per (region, account type, version). Money is integer micros in the card's currency. A price change appends version 2; every historical receipt keeps its frozen rate_card_version forever — UPDATE and DELETE are blocked by trigger.

India IndividualIndia FirmUS IndividualUS Firm
SubscriptionNone₹25,000/seat/moNone$250/seat/mo
AgreementNone3 seats · 12 monthsNone3 seats · 12 months
Included pages/monthNone2,500/seat, pooledNone1,000/seat, pooled
INTAKE / CREATE page₹15₹10 overage$0.40$0.25 overage
Onboarding pagesNone10,000 once, firm-wideNone4,000 once, firm-wide

2. The commercial ledger fields (billing.page_billing_ledger)

The Story-2485 content-free ledger is evolved into the customer money book. Every row freezes: billing_account_id (= firm), event_kind (usage | credit | allowance_grant | reversal), verb (intake | create, null for grants), allowance_bucket, included_pages_applied / overage_pages, unit_price_micros, currency, amount_micros, rate_card_version, the full-package source_completion_event_id, and initiated_by_user_id. Idempotency keys, firm RLS, the append-only trigger, and the correction-guard trigger all remain.

Still structurally content-free: no filename, text, quote, prompt, model, token count, party name, or privilege label can enter the table — DB checks reject malformed rows even if service code is bypassed.

3. Atomic allocation: onboarding → monthly → offline purchase → overage

full INTAKE/CREATE package succeeds (pages = N)
  → take per-firm advisory transaction lock        (two concurrent completions serialize)
  → already billed under this idempotency root?    → return the existing events, write nothing
  → consume pools in order, purely from events:
       onboarding pool   (firm-wide, once)
       monthly pool      (per billing period)
       offline purchases (cheque-paid pages, validity-checked)
  → whatever remains is overage, priced at the frozen per-page rate
  → one single-bucket usage segment per pool touched + one overage segment,
    all sharing the completion receipt

Because the lock serializes pool math per firm, two jobs crossing the boundary together can never spend the same included page twice: the suite proves two concurrent 80-page charges against a 100-page pool always land as exactly 100 included + 60 overage. Individual accounts skip pools entirely — every page at the flat rate.

4. Staff grants and linked reversals (PRAMAAN Admin)

staff selects firm
  → POST /admin/firms/{firm_id}/page-allowance-grants
      pages + amount + payment method + cheque/receipt reference
      + received date + validity + reason + acting staff identity
  → immutable allowance_grant receipt appended (audit event emitted)
  → projection rebuilt in the same transaction
  → firm's available pool increases

Nothing ever runs UPDATE remaining_pages. A mistaken grant is corrected by POST …/{event_id}/reversal, which appends a linked mirror event (reverses_event_id) — and refuses if the granted pages were already consumed. A zero-value goodwill grant requires a reason. Writes are platform-admin only; both actions emit typed audit events carrying the staff actor.

5. Projection, rebuilt from truth (billing.firm_monthly_usage)

The monthly row (pages used by verb, included applied, overage, overage amount, and per-pool pages remaining) is a pure recomputation over ledger events. Drop the row, rebuild, and the totals match exactly — proved by the CL-13 test. The product reads the projection for speed; the ledger stays the only truth.

Scope boundary. No producer emits usage yet — Story-2508 wires the full-package completion signal. Legacy AI-unit/credit columns on the projection remain until Story-2546's cutover. Rate-card resolution (firm → region/account type) is the producer's job.