Skip to content

Payments architecture ​

Read before changing

  1. This page (the map) · 2. Marketplace payments · 3. Data model ·
  2. Reconciliation and finance · 5. ADR-0002 (billing model, Accepted). The machine contract is this page's context: frontmatter; .claude/rules/domains/payments.md is 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 deploy check:db-health and check:ef-drift; payments-watchdog.yml is manual dispatch. Your plan must answer: which correlation key(s) does the change touch? which payment_counts_as_collected() call sites? does anything cross the disclosure boundary (provider_fee_*)? Related domains: edge-functions · migrations · data-seam. Machine reads (the manifest's reads, 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 · MarketplaceModel A · Subscription
Who pays whoma consumer pays a merchant, through usa merchant pays Digious
Razorpay productPayment Links + Routenone yet (see status)
Money splitsyes, per payment, into commission + vendorno
Who bears the gateway feeDigious (fee_bearer = platform)Digious
Commission5% (500 bp), snapshotted per paymentnot applicable
Our recordorders + order_items + payments + payment_eventsworkspace_subscriptions
Entry pointplace-public-order (anonymous-capable)manual insert
Statusbuilt and proven end to end, with undeployed fixesschema 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 key

The four questions and the column that answers each:

QuestionLook 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.

  1. Money is integer paise. No floats, anywhere, in any layer. manage-order's requireMinor refuses 12.5 rather than rounding it.
  2. The split always adds up. payments_split_adds_up CHECKs amount_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.
  3. Offline money earns no commission. payments_offline_has_no_commission forces commission_rate_bp = 0 for cash and upi_manual.
  4. A refund cannot exceed its payment. payments_refund_within_amount.
  5. The client supplies intent, the server supplies money. Item ids and quantities in; every price read from catalog_items. orders_total_is_subtotal_plus_tax can be satisfied by a consistent set of wrong numbers, so the constraint is not the defence. priceLines is.
  6. A commission rate is snapshotted, never joined. payments.commission_rate_bp records the rate at the moment money moved, so changing the platform rate cannot rewrite history.
  7. Every webhook is recorded before it is processed. payment_events holds 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 ​

PageWhat it answers
Marketplace payments (Route)consumer → QRSETU → Razorpay → Route → merchant, with the webhook mechanics
Subscription paymentshow a merchant pays Digious for the Business plan
Data modelevery table, every column that matters, and what is written at each stage
Reconciliation, settlement and financecommission, Razorpay fees, GST, audit trail, and the reconciler
Implementation status, gaps and roadmapwhat 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. Use payment_counts_as_collected() — captured orpartly_refunded or refunded. 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). failed sits below captured deliberately: 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_minor are 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 see provider_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 when reference_id is a UUID). It exists because payment.captured carries neither payment_link_id nor reference_id — only id and order_id — so on first arrival it is uncorrelatable and dead-letters on every single transaction. That is noise, not data loss; order.paid and payment_link.paid carry 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 returns acknowledged('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_examined exists so a sweep that examined nothing cannot report all-clear (payments-watchdog.yml asserts it).
  • NULL and 0 reach different code. complete_order_with_settlement treats NULL as 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.

  1. place-public-order has ZERO client callers. CLOSED. Measured 2026-08-17 as 0 hits across apps/web/src, apps/mobile/src and packages/data/src, which is why the journey step "consumer places order" had no UI and every test order was placed by a raw fetch() script. Re-measured 2026-08-28: six call sites — OrderPanel.tsx, CatalogBlock.tsx, the routes/place-order.tsx route, setu-card.tsx, and publicOrder/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.
  2. Consumer onboarding is not separated from merchant onboarding. CLOSED as QRS-730.entryRoute.ts had 0 occurrences of consumer, so an individual who finished signing in landed on the merchant /dashboard, keyed on a workspace they can never have, while src/app/consumer/ held nine routes with zero inbound navigation — reachable only by typing the URL. Now: EntryRoute carries { pathname: '/consumer' }, resolveEntryRoute has a consumer branch, provisionWorkspace is conditional (its test asserts not.toHaveBeenCalled() for the individual path), and accountType is derived server-side via primaryContextOf(ctx) in auth/service.supabase.ts rather than living only in the device store — so it now survives a reinstall.