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).
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).
| Concern | File |
|---|---|
| Collection-cycle table: statement freeze, dunning clock, D27 escape | alembic/versions/d7b3f1a9c4e2_0200_billing_collection_cycle.py (new) |
| ORM mirror + kind/state/alert vocabularies | src/domains/payment/entity.py |
| D22 statement derivation, the two watermarks, INR rendering | src/domains/payment/statement.py (new) |
| D18 ceiling arithmetic, escalation rungs, overage alerts | src/domains/payment/overage.py (new) |
| The rail: freeze → state → authorise → provision + rebuild | src/domains/payment/renewal.py (new) |
| Dunning ladder (grace/lock/unlock) + D27 deadline escape | src/domains/payment/dunning.py (new) |
| The one door out for transactional email (SES) | src/shared/email/ (new) |
| Capture branch by purpose + failed-debit → dunning wiring | src/domains/payment/service.py |
| D18 preflight block folded into the D28 admission gate | src/shared/billing/intake_admission.py |
| Scheduled daily worker + R4 non-vacuous canary | src/workers/billing_renewal/handler.py (new) |
| Config knobs (grace days, ceiling %, escape cap) | src/shared/settings.py |
| Two rail gauges | src/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 proofs | tests/integration/test_billing_renewal_rail.py (new) |
| DB-free arithmetic + structural-law pins | tests/unit/test_billing_renewal_rules.py (new) |
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
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.
collection_cycle_statement_before_charge - a cycle may not name a
payment_order until statement_sent_at is set. That is D15/SB-22 as
a constraint, not a code path someone can reorder. Proven by trying to write the forbidden
row directly in test_a_cycle_may_never_name_an_order_without_a_statement.collection_cycle_total_is_itemisation - the total equals seats plus overage
exactly. A statement whose total disagreed with its own lines is impossible.
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.
The canonical D10 string razorpay:mandate:<token>:period:<YYYY-MM>:<leg>
is the single source of idempotency:
| Store | What the string guarantees |
|---|---|
payment_order | UNIQUE (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 ledger | the 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.
charge fails -> email -> 14 days of grace (past_due, full use) -> billing-locked (paused)
attempts still counts every failure.clear_billing_lock only lifts the status when no failed/locked
cycle remains.commercial_adjustment ledger
event with a reason and an author - it appears in billing history and reconciles like any
grant. Uniqueness is structural: one locked cycle, one escape_granted_at.
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.
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.
| Scenario | Proves |
|---|---|
| SB-10 | Month two renews on the mandate: charge, pool reset, projection == ledger |
| SB-22 | Itemised statement (seats + overage) reaches the Firm Admin before the debit |
| SB-46 | Work between statement and debit does not move the debit; it rolls to next cycle |
| SB-09 | Real allocator burns onboarding → monthly → purchased → overage, across a renewal |
| SB-44 | A retried charge hands the provider one unchanged key; one allowance event |
| SB-37 | Overage at the ceiling blocks intake before processing; Firm Admin alerted, no amount to Members |
| SB-50 | An overage spike alerts (pages + rupees) once; a same-day re-run does not repeat it |
| SB-23 | A billing-locked Member still reads every matter; sees no amounts; 14-day grace honoured |
| SB-51 | D27 escape: capped, audited, once per delinquency; appears in billing history |
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.
| # | Question | Answer |
|---|---|---|
| 1 | New cloud capability? | ses:SendEmail + a network path to SES for the statement/dunning mail |
| 2 | Who 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 |
| 3 | Named check if absent? | gauge pramaan.billing.statement_undelivered + PramaanEmailSendFailedError; the rail declines to charge |
| 4 | Observed red? | Yes - test_a_statement_that_cannot_be_sent_stops_the_debit (planted failing SES client) |
| 5 | Legacy deleted? | Nothing to delete - this is net-new rail. No compat path left behind. |
| 6 | Smoke 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 |