Skip to content

Payments: implementation status, gaps and roadmap ​

Measured 2026-08-17 against live Dev (dyhjofjjuazhyqcvlrkx) and the repo. Every ✅ here was produced by a command or a live probe. Nothing on this page is an inference from intent.

Verdict ​

PAYMENT MODULE: ⚠️ NOT READY — and the blocker has MOVED

The backend money loop is sound and deployed. Two of the four original blockers are closed. What now blocks sign-off is not in payments at all: the buyer has no way to reach it.place-public-order has zero client callers anywhere in apps/web, apps/mobile or packages/data — every order placed so far was placed by a fetch() script against the Edge Function.

⚠ Read that as the honest shape of the risk, not a technicality. A green money path proves the backend; it does not prove the journey. Reporting "payments works" while the buy button does not exist would be the completion-of-parts-bounding-the-whole error CLAUDE.md's third rule is written about.

Blocker ledger — original four, re-measured ​

#BlockerStatus
1The fixes were written and not deployed✅ CLOSED 2026-08-17. Deployed and proven on live money: order QRTY84-TKHZ recorded provider_fee_minor = 2560, a provider_order_id, and events_linked = 2. The tax snapshot was proven on a mixed-rate order (2106 @ 500bp + 9503 @ 1200bp). provider_method deployed.
2No reconciler — a dropped webhook was undetectable✅ CLOSED 2026-08-17, and deliberately detect-only: removing the write capability made 5 of 7 measured money-safety defects structurally unreachable rather than merely fixed. payment_reconciliation_runs / _exceptions applied; reconcile-payments shipped with 19 Deno tests; payments-watchdog.yml asserts candidates_examined so a sweep that examined nothing cannot report all-clear. 🟡 Never executed — it correctly returns 401 without PAYMENT_RECONCILER_SECRET, which is the owner's value.
3Razorpay events not subscribed — transfer.*, settlement.*, refund.created/failed, payment.dispute.created🔴 OPEN — owner action, dashboard checkbox, zero code. Still lost forever: an unsubscribed event is never delivered and never redeliverable. This is the only item on this page whose cost grows every day it stays open.
4Model A (subscription) has no collection path🔴 OPEN. ₹9,999 is taken out of band and the subscription row inserted by hand. Acceptable for 12 known vendors; not a payment module.

Blockers found after that ledger was written ​

#BlockerMeasured
5No buyer UI. place-public-order has zero client callers.Grepped apps/web/src, apps/mobile/src, packages/data/src: 0 hits; no PLACE_PUBLIC_ORDER in any *_EDGE_FN map. CatalogBlock.tsx renders a <ul> of name + price with no <a>, <button>, <img>, href or handler — nothing on the card can start an order.
6The Pay CTA cannot be gated yet — by one missing door.resolve_workspace_payment_readiness(uuid) exists and is already granted to anon, and its own header names the public card as an intended caller. But it is keyed on workspace_id, and no anon-callable function returns one: the complete anon-executable set is six functions and get_public_setu_card deliberately exposes no uuid at all ("NEVER resolve features or entitlements here"). One slug-keyed wrapper closes it.
7No anon-readable order status, so a buyer returning from Razorpay cannot be shown their own order.All four live functions that read orders are member-scoped and revoked from anon; anon holds zero privileges on orders/order_items/payments/payment_events and both orders policies are to authenticated. ⚠ get_my_order_book must never be granted to anon — it projects buyerName/buyerPhone/buyerNote for every order in the workspace, keyed only by workspace uuid. A new narrowly-projected RPC is required.
8pgTAP has never been executed.Three suites exist (orders_payments_test.sql, payment_collected_predicate_test.sql, payment_reconciliation_test.sql) and have never been run — they need supabase db start. Written-and-unrun is the QRS-013 shape.
9Merchant payment alerts have no UI.get_merchant_payment_alerts / acknowledge_merchant_payment_alerts are live and granted to authenticated; usePaymentAlerts.ts exists; no screen consumes it.
10🔴 THE RETURN LEG DOES NOT EXIST. No callback_url is sent to Razorpay, so the buyer is redirected nowhere after paying._shared/razorpay.ts sends amount, currency, reference_id, notify, accept_partial, options.order.transfers — and grepping the entire tree for callback_url / callback_method returns 0 hits. This invalidated the first version of this plan, which assumed a return page merely needed building.
11🟡 CORRECTED 2026-08-17 — a workspace IS payment-ready. This was recorded as a hard blocker and it is not one.The claim was "no workspace can mint a payment link today", inferred from the true fact that no repo artifact creates any of the three readiness conditions. A live anon probe after applying CR-51 returns {"reason":"ready","can_take_payments":true} for balaji-lahade — consistent all along with the 5 payments already recorded there, which is the evidence that should have overturned the inference before it was written down. ⚠ The real defect is smaller and different: enablement is MANUAL AND UNDOCUMENTED. No artifact creates the rows, no runbook was found, and two migrations disagree about who writes the availability grant (20260811140000:146 says the Razorpay webhook; 20260816100000:38 says a runbook "and the Route webhook when that lands"). So vendor #2 through #12 have no repeatable path. That is a runbook to write, not a system to build — and it is the honest version of this row.
12🔴 STOCK IS NEVER DECREMENTED, so a one-of-a-kind idol can be sold N times.place-public-order reads stock_quantity and never writes it; no trigger, RPC or migration decrements on insert. Two concurrent buyers both pass; the is_unique qty≤1 clamp is per-request, not per-item-lifetime. Ganapati idols are the named use case, which makes this a launch blocker rather than a nicety.
13🟡 UNPROVEN that a browser can call place-public-order at all.config.toml declares verify_jwt = true, so a call with no Authorization header never enters the function. SUPABASE_PUBLISHABLE_KEY is the modern sb_publishable_… format, which is not a JWT, and this repo's own live probe (04-test-evidence.md:66) records a non-JWT bearer returning UNAUTHORIZED_INVALID_JWT_FORMAT. payments-watchdog.yml passes a publishable key as Bearer + apikey and is presumed to work. Probe this before anything else — every downstream step is wasted if it fails.

Four things this page previously got wrong, recorded because they are the same defect class ​

ClaimReality
"Public card order CTA ✅"It does not exist. A ✅ on a page whose header says every tick was produced by a command.
The five reason codes in marketplace-paymentsNot one of the five documented strings but no_payout_account is returned by the function, and that one means something narrower. A client branching on them matches no branch. Written from design intent instead of the deployed body.
"Both call this one RPC" — the card and the order pathThe card called it from nowhere. The same sentence is written in the present tense in place-public-order/index.ts. A comment asserting a caller is not evidence of one.
"Consumer order lookup — cut, no channel delivers the reference"The reference is delivered (it is in the order-placement response). The missing piece was never the reference; it was callback_url and a route.

| State and city reference data | ✅ APPLIED TO DEV AND PROBED LIVE 2026-08-17, CR-26.0.1-52 — 36 states/UTs and 178 cities. Anon probe: get_states() → 36 rows (28 state + 8 union_territory) with gst_code correctly withheld from the projection; get_cities('maharashtra') → 24, karnataka → 10, delhi → 2, ladakh → 1; an unknown state returns 0 rows rather than the full list, so the dependent filter fails closed; and a direct GET /rest/v1/cities as anon is HTTP 401, so reference data is readable only through the RPC |

The sign-off plan ​

Ordered so that each step makes the next one testable. Effort is estimated; every ✅ is produced by a command or a live probe.

⚠ S0 COMES FIRST AND IS CHEAP, BECAUSE TWO ANSWERS CAN INVALIDATE EVERYTHING AFTER IT

#ProbeWhy it must be first
S0acurl place-public-order with Authorization: Bearer sb_publishable_… + apikeyIf the gateway rejects a non-JWT publishable key on a verify_jwt = true function, no browser can call this Edge Function and the whole client design changes (a server-side action proxying with a different credential). Blocker 13.
S0bThree selects: payout_accounts · feature_grants where feature_key='payments' and axis='availability' · workspace_subscriptionsDecides whether a Pay CTA is observable at all today, and reconciles the 5 live payments on balaji-lahade against blocker 11. If no workspace is ready, every "pay" path returns payment: null and only the cash path is testable.
S0cConfirm the repo's 50 migrations are all applied on DevEvery schema claim on this page is a repo claim. QRS-693 was a migration authored and never applied; nothing in this repo diffs the two.
StepWorkOwnerBlocks
S1aAdd callback_url + callback_method to _shared/razorpay.ts. Without it Razorpay redirects the buyer nowhere and no confirmation page is reachable. ⚠ Unknown to resolve while doing it: whether the payment_links API accepts callback_url alongside options.order.transfers, which query parameters it appends on return, and whether that signature is verifiable with the existing _shared/webhook.ts HMAC helper — that answer decides whether the return page may trust its own query string or must re-read the ledger.dev10
S1bDecrement stock atomically on order insert, and enforce is_unique per item lifetime rather than per request. A UNIQUE partial index or a for update decrement inside the order transaction — not an application-level check, which is what already failed.dev12
S1resolve_setu_card_payment_readiness(p_slug text) — an anon-granted, SECURITY DEFINER slug→readiness wrapper. Resolves setu_cards.workspace_id internally so the no-uuid-exposure invariant holds, and returns the existing {can_take_payments, reason}. ⚠ It must not widen the availability scope: payments availability may only ever be granted at workspace scope (precedence 60 over the platform deny at 10) — a plan-scope grant would light Pay Now for an entire plan at once.dev6
S2get_public_order_status(p_order_id uuid, p_reference text) — anon-granted, two independent secrets so a leaked uuid alone is insufficient (the uuid is handed to Razorpay as reference_id, so it is not solely ours). Projects reference · status · payment_status · total_minor · collect_on · shop_name and nothing else — no commission_*, no provider_fee_*, no other buyer's data. Uses payment_counts_as_collected(), never status='captured'. ⚠ orders has no index whose leading column is reference, so the probe must be uuid-keyed (PK) with reference as a filter, not the reverse.dev7
S3The buyer flow in apps/web. Hydration is already enabled (<Scripts /> in root.tsx), so a React order sheet needs no change to the rendering model — but the app has zero action exports, zero <form>, zero useState, so this is its first write path. CatalogBlock gains a tile CTA; a new order sheet collects name · phone · qty · collect-on; submit calls the EF; a new /:slug/order/:id return route reads S2. ⚠ The EF is verify_jwt = true, so the browser must send Authorization: Bearer <publishable> AND apikey — a request with no auth header is answered by the gateway with a different JSON shape than the function's, so the client handles two 401 schemas. idempotency_key is a mandatory body UUID (not a header — preflight does not allow a custom one), and the idempotency hash excludes pay, so a retry with pay flipped replays the original outcome.dev5
S4Consumer onboarding separation — /consumer in the EntryRoute union, provisionWorkspace made conditional, and a server-side account type (get_my_auth_context does not project one, so accountType is device-local and cannot survive a reinstall). Needed for the signed-in consumer experience; not needed for the anonymous buy journey.dev—
S5Alerts UI — wire usePaymentAlerts into the dashboard with an acknowledge action. acknowledge_merchant_payment_alerts uses greatest() on a watermark, so it is monotonic and idempotent by construction.dev9
S6Run pgTAP (supabase db start → npm run test:db), review, and fix failures rather than assume passes.dev8
S7Subscribe the missing Razorpay events. Dashboard only.owner3
S8Run the reconciler against the three GSTIN transactions — needs PAYMENT_RECONCILER_SECRET, or the owner runs the workflow_dispatch.owner2
S9Three-surface pass — web export on localhost, Android APK, push for Mac/iPhone — and the real journey end to end, including via a QR scan.dev + owner—

Three more things S3 must handle, each of which would otherwise ship a real defect ​

  • ⚠ THE CACHE WILL SERVE ONE BUYER'S ORDER TO ANOTHER VISITOR IF THIS IS GOT WRONG. /:slug is served Cache-Control: s-maxage=604800 — seven days — and the headers export returns loaderHeaders verbatim, with nothing handling an action response's headers. A confirmation rendered on a cached URL, or a POST response inheriting s-maxage, edge-caches order state publicly. Separately, payment readiness must not be baked into the cached card document: nothing purges the card when readiness changes (invalidateCardCache is called only by manage-item and manage-setu-card), so a suspended merchant would keep showing Pay for up to a week.
  • ⚠ THE PUBLIC CATALOGUE CANNOT DRIVE A CORRECT ORDER SHEET YET. Four separate gaps: is_unique is not in the public projection (added to get_my_catalogue only), so the sheet cannot cap a one-off idol at qty 1 and learns only via a 422; the filter is availability <> 'draft', not = 'available', so out_of_stock / discontinued / coming_soon rows are returned and then refused; variants are absent entirely and order_items.variant_id is written by nothing; and items are ordered by item->>'position', a TEXT comparison, so 1, 2, 10 sort as 1, 10, 2.
  • ⚠ PLAN FOR "PAID BUT NOT YET REFLECTED" AS A FIRST-CLASS STATE. Nothing establishes webhook latency, and there is no polling or revalidation design. If the return page assumes the webhook has already landed, it will routinely tell a buyer who just paid that they have not.

Sign-off is claimable when S0-S9 are green and Model A is either built or explicitly accepted as out-of-band for the launch cohort. Model A being out of band is a decision, not a defect — but it must be recorded as one, because "payments is signed off" would otherwise imply a capability that does not exist.

Two things that are NOT on this plan, deliberately ​

  • Nothing that depends on a Razorpay fee. Per the owner's standing decision (2026-08-17), no branch, threshold, projection or reconciliation rule may assume a card or UPI rate — not even zero-MDR UPI. provider_fee_minor / provider_fee_tax_minor are recorded for traceability and read by nothing (verified: every reference is a write, a type, a test or a comment).
  • Catalogue images on the public card. PublicCatalogItem already carries images[] and a thumb rendition exists with no caller — but MEDIA_BASE_URL is unprovisioned, so setuCardMediaUrl returns null and every image would render as nothing. Adding the markup before the bucket exists would look like a broken card rather than a missing one.

What is proven, and how ​

CapabilityEvidence
Anonymous public order6 live orders placed through place-public-order on balaji-lahade
Server-side pricingevery total matches the catalogue; no client figure is read
Route split5 payments, amount = commission + vendor true on all, floor-to-vendor rounding correct
Real payment capture₹800 and ₹2,100 test orders paid by the owner and written captured / paid
Webhook signatureverified; a tampered signature returns 401
Replay protectionduplicate events return acknowledged('duplicate') before processing
Derived payment statusorders.payment_status re-derived from the ledger on every write
Atomic completioncomplete_order_with_settlement locks, refuses terminal and refuses razorpay
Payment readinessprobed through all five states in order
Prepaid collectionadvanceIntentFor returns complete, never settle, at zero balance — three layers deep
Migration parityrepo 43 files vs Dev 43 registered, both diff directions empty
Tests204 Deno EF tests, 904 mobile jest tests, all green
Gatescheck:sql, check:parity, check:naming, check:release all green

Defects measured on live data today ​

These are what the undeployed fixes address. Each was found in the database, not by reading code.

FindingMeasurement
Provider fees captured on nothingprovider_fee_minor is NULL on every captured payment, while the payloads carry 2076 / 5452 / 11942 paise
Event ledger unjoinablepayment_events.payment_id linked on 0 of 9 rows
payment.captured dead-letters2 of 3 with no matching payment row, and marked processed_at at the same time
provider_order_id never writtenNULL on all 6 payments
Tax facts never written0 of 9 order lines carry an HSN
Seven migrations mis-stampedrepo and Dev disagreed on version for 7 of 43 (now corrected)

⚠ The dead-lettered payment.captured is noise, not data loss, and the distinction matters. Its payload has no payment_link_id and no reference_id, so when it arrives before payment_link.paid (measured: 230ms earlier) no key can match. payment_link.paid carries the same fee moments later and correlates cleanly. Closing it needs the reconciler to replay unmatched events; correlating it at arrival is impossible with the keys the payload actually has.

Component status ​

ComponentStatus
orders · order_items · payments · payment_events✅ built, documented, constrained
payout_accounts · workspace_subscriptions · platform_plans✅ built
workspace_tax_identity_history + trigger✅ built
get_my_order_book · complete_order_with_settlement✅ built, probed live
resolve_workspace_payment_readiness · resolve_workspace_plan✅ built, probed live
payment_counts_as_collected✅ built
place-public-order · manage-order · razorpay-webhook✅ deployed 2026-08-17, proven on live money (QRTY84-TKHZ: provider_fee_minor 2560, a provider_order_id, events_linked 2)
_shared/commission.ts✅ deployed
Merchant Collections / OrderDetail screens✅ real data, real mutations
Public card order CTA🔴 DOES NOT EXIST. ⚠ This row read ✅ until 2026-08-17, on a page whose own header says every tick was produced by a command. CatalogBlock.tsx renders a list of name + price with no anchor, button, image, href or handler, and place-public-order has zero client callers. Nothing on the card can start an order
Razorpay callback_url🔴 ABSENT — the return leg does not exist. _shared/razorpay.ts sends amount, currency, reference_id, notify, accept_partial, options.order.transfers and no callback_url / callback_method (grepped the whole tree: 0 hits). Razorpay therefore redirects the buyer nowhere. A confirmation page is not partly built, it is unreachable until this is added
Stock decrement on order🔴 NEVER HAPPENS, and Ganapati idols are the named use case. place-public-order reads stock_quantity to validate and never writes it; no trigger, RPC or migration decrements it on insert — every writer is manage-item (a merchant editing). Two concurrent buyers both pass the check and both orders commit, and the same is_unique idol can be ordered by N buyers, because the qty≤1 clamp is per-request, not per-item-lifetime
payment_reconciliation_* + reconcile-payments✅ built 2026-08-17, detect-only, 19 Deno tests · 🟡 never executed (correctly 401s without PAYMENT_RECONCILER_SECRET)
payments-watchdog.yml✅ built — asserts candidates_examined, so a sweep that examined nothing cannot report all-clear
In-app payment alerts🟠 backend live, no UI. get_merchant_payment_alerts / acknowledge_merchant_payment_alerts granted to authenticated; usePaymentAlerts.ts exists; no screen consumes it
Anon order-status read🟡 RPC APPLIED TO DEV AND PROBED LIVE 2026-08-17 (a wrong uuid+reference pair returns null, never an error, so it is not an oracle) — get_public_order_status(uuid, text), CR-26.0.1-51. ⚠ This row previously read "cut — no channel delivers the reference to an anonymous buyer"; the channel is callback_url plus a return route, both still missing
Slug-keyed payment readiness✅ APPLIED TO DEV AND PROBED LIVE 2026-08-17 — resolve_setu_card_payment_readiness(text), CR-26.0.1-51. Anon probe: balaji-lahade → {"reason":"ready","can_take_payments":true}; an unknown slug → unknown_workspace. The door that makes an anon-gated Pay CTA possible at all
payout_transfers✅ built 2026-08-17 (CR-57), and this row read "not built" until 2026-08-19 — the transfer ledger shipped with the money-movement migration and the webhook upserts into it
platform_commission_rates🔴 not built (wave 2, deliberately)
statutory_documents / invoicing🔴 not built (September, with two recorded fixes)
Model A collection🔴 not built
pgTAP for the new RPCs🟠 three suites written, NEVER EXECUTED — needs supabase db start. Written-and-unrun is the QRS-013 shape
Merchant payment enablement (the three readiness conditions)🟡 The ROWS EXIST for balaji-lahade (probed: ready), but NOTHING IN THE REPO CREATES THEM. No artifact inserts a feature_grants availability row, a payout_accounts row, or a workspace_subscriptions row, and resolve_workspace_plan falls back to free while payments is entitled only on business/pro/enterprise. ⚠ Two migrations also disagree about who writes the availability grant (20260811140000:146 says the Razorpay webhook does; 20260816100000:38 says a runbook does "and the Route webhook when that lands"), and no enablement runbook was found. Treat it as a manual, undocumented, out-of-band step until the webhook source is read

Known gaps and edge cases ​

GapConsequenceTracker
No reconcilera dropped webhook is invisibletasks 7 to 9
Unsubscribed provider eventstransfer, settlement, refund-created and dispute data lost foreverowner action
provider_transfer_id never written"did the vendor actually get paid" is unanswerable from our dataneeds transfer.processed
Counter sales snapshot no tax factshalf of Ganapati sales carry no HSNQRS-709
orders.tax_minor hardcoded 0no order can carry tax until the subtotal/total coupling is resolvedQRS-707
Commission GST, TCS, TDSnot modelledblocked on a CA ruling
audit_log has 0 rowsno payment path writes itopen
Cloudflare purge unverifiableCLOUDFLARE_* unset on Dev, so only the failure branch is exercisedQRS-306
RAZORPAY_KEY_SECRET == RAZORPAY_WEBHOOK_SECRETidentical digests; one compromise is twoowner action
Test-mode webhook points at the prod projectafter cutover, test payments write into production money tablesowner action
No notification channel worksa merchant learns of a paid order by opening the appstructural, see finance page
No pgTAP on the new RPCsthe SQL layer is proven by live probe onlyopen

Roadmap ​

Before 18 August — subscribe the Razorpay events (owner, 10 min), deploy the two Edge Functions, build the reconciler tables plus the sweep EF, add the watchdog and the in-app alert hook, write the pgTAP, and re-run the ₹1 live probe end to end.

Wave 2 (19 to 25 August) — platform_commission_rates with an insert-only trigger and a deterministic tiebreak; payout_transfers with a signed direction column so reversals are summable and without the settlement_needs_transfer CHECK, which makes a reversed-after-settled transfer unwritable and turns a 23514 into a 500; transfer.processed and settlement.processed handling; pg_cron once the control-plane action is taken; ZeptoMail REST for merchant email; counter-sale tax snapshot (QRS-709).

September — statutory_documents with both recorded defects fixed (WHERE NOT cancelled on the unique index, and a real subject_id for commission_period); commission invoicing under Rule 47's 30-day window; TCS/TDS once the CA has ruled; the inclusive-tax decomposition and the subtotal-versus-total coupling.

Envisioned, deliberately not built — these are recorded for architectural continuity, not scheduled. Consumer-held subscriptions. Split payments across multiple merchants in one basket. Escrow or hold-and-release for made-to-order goods (the Ganapati advance is the obvious candidate). Automated refund execution, which is currently a recorded decision and a manual bank transfer. Self-serve Route onboarding, replacing the owner creating each linked account by hand. Per-merchant negotiated commission, for which the seam now exists. Dispute and chargeback handling. Multi-currency, which every CHECK currently forbids on purpose.