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).
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).
| Concern | File |
|---|---|
| Mandate evidence table, review-flag vocabulary | alembic/versions/c5d9a1e3f7b2_0198_firm_month1_mandate.py (new) |
| ORM mirrors + state vocabularies | src/domains/payment/entity.py |
| Ceiling math, mandate state machine, renewal pre-check | src/domains/payment/mandate.py (new) |
| Seat math, GSTIN capture, activation, duplicate detection | src/domains/payment/firm_checkout.py (new) |
| Capture branch by purpose + mandate webhook legs | src/domains/payment/service.py |
| D19 unprovisioned sweep + auto-refund SLA | src/domains/payment/reconciliation.py |
| Purpose-aware velocity cap (defect fix, §6) | src/domains/payment/orders.py |
| Routes + DTOs | src/app_api/routes/firm_checkout.py, src/domains/payment/dto.py (new) |
| Executable acceptance | tests/features/firm_checkout_mandate.feature, tests/steps/test_firm_checkout_mandate.py (new) |
| Pure-logic guards | tests/unit/test_firm_mandate_primitives.py (new) |
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).
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.
| Quantity | Derivation | At 3 seats |
|---|---|---|
| Month-one charge | seats x seat_price | ₹75,000 (7,500,000 paise) |
| Pooled monthly allowance | seats x included_pages_per_seat_month | 7,500 pages |
| Mandate ceiling | min(round_up(2 x seats x seat_price, ₹50,000), ₹10,00,000) | ₹1,50,000 (15,000,000 paise) |
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.
| Invariant | How it is enforced | Scenario |
|---|---|---|
| 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 |
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.
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.
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.
| Question | Answer |
|---|---|
| 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. |
verify_webhook → apply_verified_webhook path. Test-mode
end-to-end is Story-2717/2718.
duplicate_capture, unprovisioned) are flagged, logged
at error, and counted; issuing the refund is an outbound call, and its confirmation returns
through the existing inbound refund webhook.
main already
(it fails dropping business.firm_invites from migration 0195, before this branch
exists). Migration 0198's own downgrade and re-upgrade were verified in isolation and
alembic check reports no drift.
| Scenario | What it proves |
|---|---|
| SB-05 | Capture provisions a 7,500-page pool for 3 seats, charges seats x price, opens intake, and leaves the mandate honestly pending |
| SB-14 | Two seats refused with the floor named; no order row survives |
| SB-42 | Capture against an absent tenant is flagged, alerted, and queued for refund past the 24h SLA |
| SB-43 | Two captured orders, one plan provisioned, one flagged for automatic refund, pool counted once |
| SB-44 | Two deliveries under distinct event ids produce exactly one allowance event |
| SB-45 | GSTIN captured on the self-serve path and recorded for invoicing |
| SB-54 | 3 → 5 seats requires re-authorisation, and the formula-derived ceiling is stated |
| SB-55 | Pending shows as pending and blocks (not fails); confirmation makes renewal ready |
| SB-55b | Bank-reported cancellation records its source and refuses the next renewal |