Appearance
Payments architecture
Read before changing
- This page (the map) · 2. Marketplace payments · 3. Data model ·
- Reconciliation and finance · 5. ADR-0002 (billing model, Accepted). The machine contract is this page's
context:frontmatter;.claude/rules/domains/payments.mdis GENERATED from it and loads when you touch the money path — for this domain an edit is refused until these pages were opened. Gates:npm run test:db·check:sql·check:rpc; after any deploycheck:db-healthandcheck:ef-drift;payments-watchdog.ymlis manual dispatch. Your plan must answer: which correlation key(s) does the change touch? whichpayment_counts_as_collected()call sites? does anything cross the disclosure boundary (provider_fee_*)? Related domains: edge-functions · migrations · data-seam. Machine reads (the manifest'sreads, in order):documentation/portal/payments/index.md·documentation/portal/payments/marketplace-payments.md·documentation/portal/payments/data-model.md·documentation/portal/architecture/adr/0002-subscription-billing-system-of-record.md.
The single source of truth for how money moves through QRSETU. Everything on these pages was measured against live Dev (dyhjofjjuazhyqcvlrkx) and the repo on 2026-08-17, not recalled and not inferred from intent. Where something is designed but not built, it says so in the same sentence.
READ THE STATUS PAGE BEFORE TRUSTING ANY FLOW HERE
Several components on these diagrams are written but not deployed, and two are not built at all. Implementation status and gaps is the authoritative list, and it is deliberately separate from the flow pages so an architecture diagram is never mistaken for a statement about what is running.
Two payment models, and they are deliberately not one implementation
QRSETU moves money in two structurally different ways. They share the payments table's vocabulary but almost nothing else, and the owner's instruction on 2026-08-16 was explicit: do not force them into one implementation for the sake of reuse.
| Model B · Marketplace | Model A · Subscription | |
|---|---|---|
| Who pays whom | a consumer pays a merchant, through us | a merchant pays Digious |
| Razorpay product | Payment Links + Route | none yet (see status) |
| Money splits | yes, per payment, into commission + vendor | no |
| Who bears the gateway fee | Digious (fee_bearer = platform) | Digious |
| Commission | 5% (500 bp), snapshotted per payment | not applicable |
| Our record | orders + order_items + payments + payment_events | workspace_subscriptions |
| Entry point | place-public-order (anonymous-capable) | manual insert |
| Status | built and proven end to end, with undeployed fixes | schema only, no collection path |
→ Marketplace payments (Route) · Subscription payments
Tracing one transaction, which is the question this section exists to answer
Start from whatever identifier you have and follow the chain. Every hop is a real column.
buyer quotes "QR6D7M-6YHH" -> orders.reference (unique per workspace)
|
orders.id (uuid) -------------------> order_items.order_id (the lines, with tax snapshot)
| payments.order_id (the money, one row per attempt)
|
payments.provider_link_id "plink_…" -> the Razorpay Payment Link we minted
payments.provider_payment_id "pay_…" -> Razorpay's payment, and the UNIQUE replay key
payments.provider_order_id "order_…" -> Razorpay's own order behind the link
|
payment_events.payment_id -----------> every webhook we received about it, payload verbatim
payment_events.provider_event_id -> Razorpay's event id, the idempotency keyThe four questions and the column that answers each:
| Question | Look at |
|---|---|
| Did the buyer pay? | orders.payment_status, derived from the ledger, never set from an event |
| How much did we actually collect? | sum(payments.amount_minor) where payment_counts_as_collected(status) |
| What is ours and what is the vendor's? | payments.commission_minor / payments.vendor_minor (they always sum to amount_minor) |
| What did Razorpay charge us? | payments.provider_fee_minor (inclusive of provider_fee_tax_minor) |
⚠ orders.payment_status is a cache, not the truth. The ledger in payments is the truth, and payment_status is re-derived from it by derivePaymentStatus on every write. If the two ever disagree, the ledger wins and the disagreement is a reconciliation finding.
The invariants that hold everywhere
These are enforced by CHECK constraints, not by convention, so they cannot drift.
- Money is integer paise. No floats, anywhere, in any layer.
manage-order'srequireMinorrefuses12.5rather than rounding it. - The split always adds up.
payments_split_adds_upCHECKsamount_minor = commission_minor + vendor_minor. The commission is rounded down and the vendor takes the remainder, so a sub-paise fraction always goes to the merchant. - Offline money earns no commission.
payments_offline_has_no_commissionforcescommission_rate_bp = 0forcashandupi_manual. - A refund cannot exceed its payment.
payments_refund_within_amount. - The client supplies intent, the server supplies money. Item ids and quantities in; every price read from
catalog_items.orders_total_is_subtotal_plus_taxcan be satisfied by a consistent set of wrong numbers, so the constraint is not the defence.priceLinesis. - A commission rate is snapshotted, never joined.
payments.commission_rate_bprecords the rate at the moment money moved, so changing the platform rate cannot rewrite history. - Every webhook is recorded before it is processed.
payment_eventsholds the verbatim payload, so a processing bug is replayable rather than a permanent loss. This single property is why most missing financial facts are recoverable and therefore deferrable.
Section map
| Page | What it answers |
|---|---|
| Marketplace payments (Route) | consumer → QRSETU → Razorpay → Route → merchant, with the webhook mechanics |
| Subscription payments | how a merchant pays Digious for the Business plan |
| Data model | every table, every column that matters, and what is written at each stage |
| Reconciliation, settlement and finance | commission, Razorpay fees, GST, audit trail, and the reconciler |
| Implementation status, gaps and roadmap | what is running, what is not, and what is deliberately deferred |
Invariants of the money path (operating-manual text)
Provenance — moved from CLAUDE.md on 2026-09-23 (QRS-1288)
This is the verbatim text of CLAUDE.md § "Orders, payments & the money path" as of commit 00c1eca, relocated here under the context-architecture programme. Sentences of the form "this said X until [date]" are corrections recorded at the time they were made; the live rule is the corrected one. Retired vocabulary inside those corrections names what was retired and is not a live claim.
Orders, payments & the money path [live on Dev; SSOT is documentation/portal/payments/]
⚠ START AT documentation/portal/payments/index.md. Six pages — marketplace-payments.md · subscription-payments.md · data-model.md · reconciliation-and-finance.md · status-and-roadmap.md — written because tracing a transaction across orders / order_items / payments / payment_events / the reconciliation tables was not possible from the code alone. Do not re-derive the flow here; it is the single source of truth for which table and which column to look at.
TWO payment models, and they must never be conflated. Model A — the platform subscription (a merchant pays QRSETU; ADR-0002, web-first, no in-app purchase UI or CTA on native) has no collection path built at all today. Model B — marketplace / Razorpay Route (a buyer pays a merchant, QRSETU takes a commission split) is the one that is live.
The invariants that are cheap to get wrong and expensive to get wrong:
- Never test
status = 'captured'directly. Usepayment_counts_as_collected()— captured orpartly_refundedorrefunded. A raw'captured'filter discards the positive leg of a refunded payment, so money that was collected disappears from the merchant's book. This is QRS-706: one rule that had acquired four different spellings in-repo. - Payment status advances MONOTONICALLY by rank —
created(0) < failed(1) < authorized(2) < captured(3) < partly_refunded(4) < refunded(5).failedsits belowcaptureddeliberately: a late-arriving failure event must not un-capture a paid order. - ⚠ NEVER HARDCODE OR DEPEND ON A RAZORPAY FEE [owner decision, 2026-08-17]. Digious is on Razorpay's default rate plan and will renegotiate at volume, so no branch, threshold, projection or reconciliation rule may assume a card/UPI rate — not even zero-MDR UPI, which is what I asserted as near-fact and was corrected on.
provider_fee_minor/provider_fee_tax_minorare recorded for traceability, read by nothing (verified: every reference is a write, a type, a test or a comment). Record actual provider-reported figures; never compute from them. - THE DISCLOSURE BOUNDARY IS A HARD LINE. The merchant sees their own contract —
amountMinor,commissionMinor,commissionRateBp,netToMerchantMinor. They must never seeprovider_fee_minor/provider_fee_tax_minor, because the gap between what Razorpay charges us and what we charge them is the platform margin. A test pins this; do not project your way around it. A buyer sees less again. - The webhook correlates through a FALLBACK CHAIN, in this order:
provider_payment_id→provider_link_id→provider_order_id→order_id(only whenreference_idis a UUID). It exists becausepayment.capturedcarries neitherpayment_link_idnorreference_id— onlyidandorder_id— so on first arrival it is uncorrelatable and dead-letters on every single transaction. That is noise, not data loss;order.paidandpayment_link.paidcarry the same fee data ~230 ms later. - A 5xx from the webhook is INERT, so a dead event needs a REPLAY, not a RETRY. Razorpay's redelivery hits
payment_events' unique(provider, provider_event_id)and returnsacknowledged('duplicate')before any processing. - The reconciler is DETECT-ONLY, on purpose. Removing its write capability made 5 of 7 measured money-safety defects structurally unreachable rather than merely fixed. It records exceptions; a human resolves them.
candidates_examinedexists so a sweep that examined nothing cannot report all-clear (payments-watchdog.ymlasserts it). NULLand0reach different code.complete_order_with_settlementtreatsNULLas no settlement and RAISES on<= 0. Do not normalise one into the other.- Platform tax identity is time-keyed (
platform_tax_identity), and Digious' GSTIN is not the merchant's — they are different suppliers in different transactions on the same order.
⚠ BOTH OF THE TWO BLOCKERS RECORDED HERE ARE NOW CLOSED, AND THIS BLOCK ANNOUNCED THEM AS "CURRENTLY-TRUE" UNTIL 2026-08-28. Keep the original measurement as the record of why they mattered; do not cite either as the present state.
CLOSED. Measured 2026-08-17 as 0 hits acrossplace-public-orderhas ZERO client callers.apps/web/src,apps/mobile/srcandpackages/data/src, which is why the journey step "consumer places order" had no UI and every test order was placed by a rawfetch()script. Re-measured 2026-08-28: six call sites —OrderPanel.tsx,CatalogBlock.tsx, theroutes/place-order.tsxroute,setu-card.tsx, andpublicOrder/service{,.supabase}.ts. The public card now carries a real order affordance. The lesson survives the fix and is the reason this entry is kept rather than deleted: a green money path proves the BACKEND, never the JOURNEY — only a client caller does that.Consumer onboarding is not separated from merchant onboarding.CLOSED as QRS-730.entryRoute.tshad 0 occurrences ofconsumer, so anindividualwho finished signing in landed on the merchant/dashboard, keyed on a workspace they can never have, whilesrc/app/consumer/held nine routes with zero inbound navigation — reachable only by typing the URL. Now:EntryRoutecarries{ pathname: '/consumer' },resolveEntryRoutehas a consumer branch,provisionWorkspaceis conditional (its test assertsnot.toHaveBeenCalled()for the individual path), andaccountTypeis derived server-side viaprimaryContextOf(ctx)inauth/service.supabase.tsrather than living only in the device store — so it now survives a reinstall.