Story 2719: Firm month-one checkout, eMandate registration, and the mandate lifecycle

Story-2711 (PR #739) created the tenant: a firm exists, its members sign in, and payment_status='pending_payment' withholds billable intake. Story-2714 (PR #740) built the money rail: billing.payment_order, webhook verification and dedupe, the online-purchase ledger grammar, and the D23 sweep. This Story is the firm's money half - the month-one order that starts a plan, and the mandate that will renew it. Out of scope and untouched: the renewal job itself (Story-2721), grant codes (Story-2715), the admin status surface (Story-2722), and every outbound Razorpay call (Story-2717, below).

Why this shape

Two decisions from Spike-2709 drive everything here.

The rail is Recurring Payments tokens, not Subscriptions. Our renewal amount is variable by construction - seats plus the prior period's overage (D14/D18) - and a product that bills a fixed plan amount on a schedule it owns cannot express an amount our ledger derived this morning. So we register a mandate once, hold the token, and charge per cycle.

pending is not active. e-NACH registration goes to NPCI and is not chargeable the same day. Every design choice below follows: the workspace opens on capture, never on mandate activation, and a renewal run against a pending mandate is blocked, never failed. Dunning a customer for a payment that has not failed yet is a worse bug than a late charge (D24, SB-55).

1. Where things live

ConcernFile
Mandate evidence table, review-flag vocabularyalembic/versions/c5d9a1e3f7b2_0198_firm_month1_mandate.py (new)
ORM mirrors + state vocabulariessrc/domains/payment/entity.py
Ceiling math, mandate state machine, renewal pre-checksrc/domains/payment/mandate.py (new)
Seat math, GSTIN capture, activation, duplicate detectionsrc/domains/payment/firm_checkout.py (new)
Capture branch by purpose + mandate webhook legssrc/domains/payment/service.py
D19 unprovisioned sweep + auto-refund SLAsrc/domains/payment/reconciliation.py
Purpose-aware velocity cap (defect fix, §6)src/domains/payment/orders.py
Routes + DTOssrc/app_api/routes/firm_checkout.py, src/domains/payment/dto.py (new)
Executable acceptancetests/features/firm_checkout_mandate.feature, tests/steps/test_firm_checkout_mandate.py (new)
Pure-logic guardstests/unit/test_firm_mandate_primitives.py (new)

2. The flow, end to end

1. POST /app/firm/checkout/month1        seats -> server prices it, priors voided, GSTIN captured
2. (web tier creates the Razorpay order, D11)
3. POST .../orders/{id}/provider-order   bind the provider order id (one-shot)
4. webhook payment.captured              -> payment_status active + ONE plan_provision/monthly event
5. POST /app/firm/checkout/mandate       ceiling derived from seats x rate card
6. POST .../mandate/{id}/provider-token  state -> PENDING (not active)
7. webhook token.confirmed               state -> ACTIVE; renewal now chargeable
   webhook token.cancelled               state -> CANCELLED, source='bank'

No step in this repo calls Razorpay. ADR-167 forbids a NAT gateway, so for the signup leg a browser is present and order creation egresses from the web tier (D11); every server-initiated outbound call - registration, refunds, renewal charges - belongs to the non-VPC razorpay-egress runtime that pramaan-infra owns (Spike-2709 §3.3, Story-2717).

3. The numbers, and where they come from

Nothing here is a constant in application code. The India firm card seeded as v2026-07-20 by migration 0196 carries ₹25,000/seat/month, 2,500 included pages per seat per month, and a three-seat floor.

QuantityDerivationAt 3 seats
Month-one chargeseats x seat_price₹75,000 (7,500,000 paise)
Pooled monthly allowanceseats x included_pages_per_seat_month7,500 pages
Mandate ceilingmin(round_up(2 x seats x seat_price, ₹50,000), ₹10,00,000)₹1,50,000 (15,000,000 paise)
The 2 x is not a round vendor number: D18 caps firm overage at ~1x the monthly fee, so seat fee (1x) plus maximum overage (1x) is the largest amount our own rate card permits us to collect in one cycle. Registering more would authorise something the contract forbids - and the bank shows that number to the customer at registration. A 21-seat firm's ceiling exceeds Razorpay's ₹10,00,000 registration cap, so it is refused and routed to sales rather than clamped: a clamped ceiling is a failed debit scheduled for next month.

4. The money invariants

InvariantHow it is enforcedScenario
The client never states an amount No request DTO in this Story has an amount field; the webhook compares captured paise to derived paise exactly -
Below the seat floor is refused, not raised derive_month1_amount_paise reads min_seats off the rate card, so card and checkout cannot disagree SB-14
One live order per attempt; priors voided Order-level key razorpay:firm:<firm>:firm-month1:attempt:<id> + void_prior_month1_orders SB-43
A second capture refunds, never credits already_provisioned_month1 asks the database (not our control flow) which order was first; the loser gets review_flag='duplicate_capture' and no ledger event SB-43
Exactly one allowance event per payment Ledger key firm_month1_key(provider_order_id) collides on UNIQUE (firm_id, idempotency_key), even for a delivery that walked past the dedupe table under a fresh event id SB-44
Provisioning happens in the firm's own RLS scope _set_firm_scope drops is_staff to false before the ledger write, so crediting another tenant is structurally impossible rather than merely incorrect SB-48 (inherited)
Money in, nothing delivered is never quiet review_flag='unprovisioned' at capture, then the sweep retries inside the SLA and queues an automatic refund past it SB-42

5. Two detectors for a dead mandate

D24 requires bank-side cancellation to be caught by webhook and renewal pre-check, and the redundancy is load-bearing rather than belt-and-braces. Razorpay retries a failing webhook for 24 hours and then disables the endpoint, needing a manual dashboard re-enable (Spike-2709 §2.5). After that, the webhook detects nothing at all - chargeable_mandate() is the detector that still works, because it reads state at charge time instead of waiting to be told.

MandateChargeability carries blocked separately from chargeable for the same reason: pending returns chargeable=False, blocked=True ("the bank has not confirmed yet; nothing has failed"), while cancelled returns blocked=False and names the source. A caller that ran dunning on both would email a customer mid-registration.

6. A defect this Story found in the merged rail

Story-2714's D17 velocity caps hold an account with no payment history to ₹50,000 across a rolling 24h. A three-seat firm's month one is ₹75,000 - above that cap - so the Firm plan was literally impossible to buy. This surfaced as a real red run on the acceptance suite before any exemption existed, not as a review opinion.

The fix is purpose-aware rather than a raised number: UNCAPPED_AMOUNT_PURPOSES exempts the firm purposes from the amount cap while every purpose keeps the order-count cap. The cap defends against an attacker choosing the number; a page pack's amount follows a client-supplied page count, but a firm plan's is seats x seat_price off a frozen card with the seat floor and mandate ceiling both enforced server-side. The page-pack protection is untouched, and test_firm_month_one_is_not_strangled_by_the_unproven_amount_cap pins the regression.

7. Also fixed: the sweep would have mis-credited firm money

The D23 sweep credited any state='captured' AND ledger_event_id IS NULL order through credit_purchased_pages. Once firm_month1 orders existed, that would have minted an online_purchase grant - purchased pages, twelve-month validity - for money that bought a seat subscription. The sweep's page-pack leg is now scoped WHERE purpose = 'page_pack', and firm orders get their own repair-or-refund leg.

8. Review gate

QuestionAnswer
New cloud capability?None. No new SSM read, queue, invoke target, or network path. This Story is schema, domain logic, and routes inside the existing app-api runtime; the webhook legs ride the endpoint Story-2714 already shipped.
Who grants it, where?N/A - nothing to grant. The outbound Razorpay capability (egress Lambda, its invoke grant, the Lambda VPC endpoint) is Story-2717's, and no code here depends on it existing.
What check goes red if it is absent?For this Story's own surface: tests/steps/test_firm_checkout_mandate.py (9 scenarios) and tests/unit/test_firm_mandate_primitives.py (12 cases), plus alembic check proving the ORM mirrors match the migrated DB.
Observed red?Yes, twice, on real defects. The acceptance suite failed 7/9 on the D17 velocity cap (§6) and failed again on an untyped IS NULL parameter in the void query; both were fixed and re-run green. The suite has been observed failing on broken behaviour, not only passing on correct behaviour.
What legacy does this delete?Nothing survives to delete. The GSTIN validator was de-duplicated rather than copied - validate_gstin moved to module scope in firm_subscription/dto.py and the admin DTO now calls the same function the checkout DTO does, so the staff door and the self-serve door cannot drift. No compat shim, no dual path, no flag.
Does the deploy smoke exercise the changed path?No deploy workflow changes here; this rides the app-api deploy. The end-to-end test-mode charge is Story-2718's smoke and is not claimed by this Story.

9. What this Story does NOT claim

10. Scenario coverage

ScenarioWhat it proves
SB-05Capture provisions a 7,500-page pool for 3 seats, charges seats x price, opens intake, and leaves the mandate honestly pending
SB-14Two seats refused with the floor named; no order row survives
SB-42Capture against an absent tenant is flagged, alerted, and queued for refund past the 24h SLA
SB-43Two captured orders, one plan provisioned, one flagged for automatic refund, pool counted once
SB-44Two deliveries under distinct event ids produce exactly one allowance event
SB-45GSTIN captured on the self-serve path and recorded for invoicing
SB-543 → 5 seats requires re-authorisation, and the formula-derived ceiling is stated
SB-55Pending shows as pending and blocks (not fails); confirmation makes renewal ready
SB-55bBank-reported cancellation records its source and refuses the next renewal