Story 2721: The monthly renewal rail

Story-2719 (PR #745) built the firm's money half - the month-one order that starts a plan and the eMandate that will renew it. This Story is what happens every month after. It freezes a pre-debit statement, charges the mandate, resets the pooled monthly allowance, bills the prior period's overage, caps overage credit at a ceiling, and runs a humane dunning ladder for a firm that cannot pay. Out of scope and untouched: the outbound Razorpay charge call itself (Story-2717's non-VPC egress runtime - this repo only produces the order it will charge), Firm-Admin billing authority (Story-2723, PR #752), and grant codes (Story-2715).

Why this shape

Freeze, then tell, then charge - the order is the product promise. D15 says the Firm Admin gets an itemised statement before every debit, and that the debit equals the statement. Those are one mechanism: a collection freezes a window of usage (D22), and the charge reads the frozen row rather than recomputing. A firm that keeps working between the statement and the debit cannot move the debit - its later usage lands in the next window (SB-22, SB-46).

Authorise, do not call. ADR-167 forbids a NAT gateway, so this repo never talks to Razorpay. The renewal job produces a payment_order carrying the D20 provider idempotency key razorpay:mandate:<token>:period:<YYYY-MM>:<leg>; the egress runtime charges with exactly that key. A retry that never heard the provider's answer reuses one key and the firm is debited once (SB-44).

Reading never locks; Members never see amounts. The dunning lock pauses NEW billable work only - the thing that costs us money to serve. Every read path is untouched, and the lock's customer message names no rupees. Both properties are structural, not polite: the intake gate has exactly one caller (the upload path), and the amount lives only on the statement and the Firm-Admin surface (D15, SB-23).

1. Where things live

ConcernFile
Collection-cycle table: statement freeze, dunning clock, D27 escapealembic/versions/d7b3f1a9c4e2_0200_billing_collection_cycle.py (new)
ORM mirror + kind/state/alert vocabulariessrc/domains/payment/entity.py
D22 statement derivation, the two watermarks, INR renderingsrc/domains/payment/statement.py (new)
D18 ceiling arithmetic, escalation rungs, overage alertssrc/domains/payment/overage.py (new)
The rail: freeze → state → authorise → provision + rebuildsrc/domains/payment/renewal.py (new)
Dunning ladder (grace/lock/unlock) + D27 deadline escapesrc/domains/payment/dunning.py (new)
The one door out for transactional email (SES)src/shared/email/ (new)
Capture branch by purpose + failed-debit → dunning wiringsrc/domains/payment/service.py
D18 preflight block folded into the D28 admission gatesrc/shared/billing/intake_admission.py
Scheduled daily worker + R4 non-vacuous canarysrc/workers/billing_renewal/handler.py (new)
Config knobs (grace days, ceiling %, escape cap)src/shared/settings.py
Two rail gaugessrc/shared/observability/metrics.py
Executable acceptance (9 scenarios)tests/features/firm_renewal.feature, tests/steps/test_firm_renewal.py (new)
Red-first capability + two-path proofstests/integration/test_billing_renewal_rail.py (new)
DB-free arithmetic + structural-law pinstests/unit/test_billing_renewal_rules.py (new)

2. The flow, end to end

DAILY WORKER (billing_renewal, EventBridge-triggered)
  1. escalate overage    per firm with usage: alert at 50%, collect at 80%, block at 100%
  2. collect renewals    per firm due this period:
       a. freeze_cycle        derive statement at cutoff, write collection_cycle (idempotent)
       b. chargeable_mandate  D20/D24 pre-charge check: blocked != failed
       c. send_cycle_statement  SES; a send that fails STOPS the charge (R2/R5)
       d. authorise_charge    payment_order with the D20 key -> egress runtime charges it
  3. dunning             lock every firm whose 14-day grace expired

WEBHOOK (existing service.py, extended)
  payment.captured  purpose in (firm_renewal, firm_overage):
       provision_captured_collection -> mint monthly pool (renewal only),
       rebuild firm_monthly_usage from the ledger, mark cycle paid, clear_billing_lock
  payment.failed    purpose in (firm_renewal, firm_overage):
       record_charge_failure -> start grace clock, firm -> past_due, email Firm Admin

3. The one new table, and why

billing.collection_cycle - one row per (firm, kind, period) - carries the three facts nothing else in the schema can hold. It is never a balance: the page ledger stays the only balance store, exactly as payment_order and payment_mandate do.

Two money laws are enforced by the database, not by application discipline:

4. The two watermarks (a credit hole caught in review)

A collection writes payment_order_id onto its cycle when it authorises a debit - which advances the billing watermark, so the next statement does not bill the same pages twice. But the D18 ceiling asks a different question: how much credit is outstanding? If the ceiling read that same billing watermark, invoicing a firm at its ceiling would instantly hand it a fresh line of credit, and a firm that never pays could loop forever.

So billing_watermark(settled_only=True) reads the last state = 'paid' cutoff for the ceiling, and the last charged cutoff for billing. An authorised debit is not money until the bank confirms it. Pinned by test_an_authorised_but_uncollected_debit_does_not_release_the_ceiling.

5. Exactly-once, three stores, one string (D14/D20/SB-44)

The canonical D10 string razorpay:mandate:<token>:period:<YYYY-MM>:<leg> is the single source of idempotency:

StoreWhat the string guarantees
payment_orderUNIQUE (firm_id, business_idempotency_key) - a retry reuses one order, never opens a second
Razorpay (via egress)the provider idempotency key - the bank debits once even if we never heard the first answer
page ledgerthe grant's UUID derives from it - exactly one plan_provision/monthly event per period

The monthly pool is dated to period_anchor() - the first instant of the period - not to capture time, because bucket_remaining scopes the monthly bucket by to_char(occurred_at, 'YYYY-MM'). A charge confirmed a day early or a fortnight late still lands the pool in the month it belongs to.

After the pool is minted, the projection is rebuilt from the ledger, never incremented (CL-13). test_sb10_month_two_renews_on_the_mandate asserts the rebuilt projection equals the stored one field for field.

6. The dunning ladder (D15, OQ-2)

charge fails -> email -> 14 days of grace (past_due, full use) -> billing-locked (paused)

7. The capability contract (AGENTS.md R2), proven red

The rail depends on a cloud capability this repo does not grant: ses:SendEmail plus a network path to SES (an in-VPC sender needs the interface endpoint, since ADR-167 forbids a NAT gateway). The named check that goes red when it is absent is the gauge pramaan.billing.statement_undelivered and the typed PramaanEmailSendFailedError.

test_a_statement_that_cannot_be_sent_stops_the_debit is the planted negative: a deliberately failing SES client. It has been observed red by construction, and it proves the rail refuses to charge rather than debiting a customer who was never told - no cycle, no order, transaction rolled back. Its green twin (test_the_same_run_charges_once_ses_is_reachable) proves it goes green for the right reason.

8. The overage ceiling asked twice, pinned together (ADR-130 DR-1)

The renewal worker asks the ceiling through the full statement derivation (six round trips). The upload admission path cannot afford that - presign carries a hard per-request query budget - so it folds both admission questions (D28 status and D18 ceiling) into one query in assert_firm_may_intake. Two derivations of one number is exactly how a firm gets blocked for an amount its statement never shows, so test_the_fast_ceiling_preflight_agrees_with_the_full_derivation pins them together at below / just-under / exactly-at / past the ceiling. The ceiling fails open for a firm this rail cannot price (no plan, no rate card): a missing rate card must not become a platform-wide upload outage.

9. Acceptance scenarios (pytest-bdd, test clock)

ScenarioProves
SB-10Month two renews on the mandate: charge, pool reset, projection == ledger
SB-22Itemised statement (seats + overage) reaches the Firm Admin before the debit
SB-46Work between statement and debit does not move the debit; it rolls to next cycle
SB-09Real allocator burns onboarding → monthly → purchased → overage, across a renewal
SB-44A retried charge hands the provider one unchanged key; one allowance event
SB-37Overage at the ceiling blocks intake before processing; Firm Admin alerted, no amount to Members
SB-50An overage spike alerts (pages + rupees) once; a same-day re-run does not repeat it
SB-23A billing-locked Member still reads every matter; sees no amounts; 14-day grace honoured
SB-51D27 escape: capped, audited, once per delinquency; appears in billing history

10. Migration & heads

0200_billing_collection_cycle was re-parented from 0199 onto d1f4b7c9a3e5 (Story-2723's 0199, PR #752) after rebase - both had landed a 0199 off the same 0198 mandate migration, so the chain now has a single head. Expand-only; downgrade drops the table and its policies. The new FK collection_cycle → payment_order cascaded into two sibling BDD suites' teardown order (the "NOT-NULL-flip blast radius" pattern): both now clear collection_cycle before payment_order.

11. Review gate (six answers)

#QuestionAnswer
1New cloud capability?ses:SendEmail + a network path to SES for the statement/dunning mail
2Who grants it, where?pramaan-infra (the shared Lambda exec role already carries pramaan-ses-send-email + the SES VPC endpoint); the daily worker's role is a linked infra twin
3Named check if absent?gauge pramaan.billing.statement_undelivered + PramaanEmailSendFailedError; the rail declines to charge
4Observed red?Yes - test_a_statement_that_cannot_be_sent_stops_the_debit (planted failing SES client)
5Legacy deleted?Nothing to delete - this is net-new rail. No compat path left behind.
6Smoke exercises the change?Yes - the worker's _CANARY_EVENT runs the real due-scan + overage + undelivered queries against the DB, not an empty-record no-op
Deferred, filed, inert. The daily worker's EventBridge schedule and the worker's own IAM role (SES + DB) are pramaan-infra Terraform, tracked with Story-2717's egress work - the function ships here; the trigger and grant are the infra twin. Until that lands the worker is deployable but unscheduled: no live compat path, nothing running half-wired.