Appearance
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
| # | Blocker | Status |
|---|---|---|
| 1 | The 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. |
| 2 | No 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. |
| 3 | Razorpay 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. |
| 4 | Model 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
| # | Blocker | Measured |
|---|---|---|
| 5 | No 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. |
| 6 | The 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. |
| 7 | No 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. |
| 8 | pgTAP 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. |
| 9 | Merchant 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
| Claim | Reality |
|---|---|
| "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-payments | Not 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 path | The 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
| # | Probe | Why it must be first |
|---|---|---|
| S0a | curl place-public-order with Authorization: Bearer sb_publishable_… + apikey | If 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. |
| S0b | Three selects: payout_accounts · feature_grants where feature_key='payments' and axis='availability' · workspace_subscriptions | Decides 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. |
| S0c | Confirm the repo's 50 migrations are all applied on Dev | Every 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. |
| Step | Work | Owner | Blocks |
|---|---|---|---|
| S1a | Add 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. | dev | 10 |
| S1b | Decrement 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. | dev | 12 |
| S1 | resolve_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. | dev | 6 |
| S2 | get_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. | dev | 7 |
| S3 | The 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. | dev | 5 |
| S4 | Consumer 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 | — |
| S5 | Alerts 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. | dev | 9 |
| S6 | Run pgTAP (supabase db start → npm run test:db), review, and fix failures rather than assume passes. | dev | 8 |
| S7 | Subscribe the missing Razorpay events. Dashboard only. | owner | 3 |
| S8 | Run the reconciler against the three GSTIN transactions — needs PAYMENT_RECONCILER_SECRET, or the owner runs the workflow_dispatch. | owner | 2 |
| S9 | Three-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.
/:slugis servedCache-Control: s-maxage=604800— seven days — and theheadersexport returnsloaderHeadersverbatim, with nothing handling an action response's headers. A confirmation rendered on a cached URL, or a POST response inheritings-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 (invalidateCardCacheis called only bymanage-itemandmanage-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_uniqueis not in the public projection (added toget_my_catalogueonly), so the sheet cannot cap a one-off idol at qty 1 and learns only via a 422; the filter isavailability <> 'draft', not= 'available', soout_of_stock/discontinued/coming_soonrows are returned and then refused; variants are absent entirely andorder_items.variant_idis written by nothing; and items are ordered byitem->>'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_minorare 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.
PublicCatalogItemalready carriesimages[]and athumbrendition exists with no caller — butMEDIA_BASE_URLis unprovisioned, sosetuCardMediaUrlreturnsnulland 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
| Capability | Evidence |
|---|---|
| Anonymous public order | 6 live orders placed through place-public-order on balaji-lahade |
| Server-side pricing | every total matches the catalogue; no client figure is read |
| Route split | 5 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 signature | verified; a tampered signature returns 401 |
| Replay protection | duplicate events return acknowledged('duplicate') before processing |
| Derived payment status | orders.payment_status re-derived from the ledger on every write |
| Atomic completion | complete_order_with_settlement locks, refuses terminal and refuses razorpay |
| Payment readiness | probed through all five states in order |
| Prepaid collection | advanceIntentFor returns complete, never settle, at zero balance — three layers deep |
| Migration parity | repo 43 files vs Dev 43 registered, both diff directions empty |
| Tests | 204 Deno EF tests, 904 mobile jest tests, all green |
| Gates | check: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.
| Finding | Measurement |
|---|---|
| Provider fees captured on nothing | provider_fee_minor is NULL on every captured payment, while the payloads carry 2076 / 5452 / 11942 paise |
| Event ledger unjoinable | payment_events.payment_id linked on 0 of 9 rows |
payment.captured dead-letters | 2 of 3 with no matching payment row, and marked processed_at at the same time |
provider_order_id never written | NULL on all 6 payments |
| Tax facts never written | 0 of 9 order lines carry an HSN |
| Seven migrations mis-stamped | repo 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
| Component | Status |
|---|---|
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
| Gap | Consequence | Tracker |
|---|---|---|
| No reconciler | a dropped webhook is invisible | tasks 7 to 9 |
| Unsubscribed provider events | transfer, settlement, refund-created and dispute data lost forever | owner action |
provider_transfer_id never written | "did the vendor actually get paid" is unanswerable from our data | needs transfer.processed |
| Counter sales snapshot no tax facts | half of Ganapati sales carry no HSN | QRS-709 |
orders.tax_minor hardcoded 0 | no order can carry tax until the subtotal/total coupling is resolved | QRS-707 |
| Commission GST, TCS, TDS | not modelled | blocked on a CA ruling |
audit_log has 0 rows | no payment path writes it | open |
| Cloudflare purge unverifiable | CLOUDFLARE_* unset on Dev, so only the failure branch is exercised | QRS-306 |
RAZORPAY_KEY_SECRET == RAZORPAY_WEBHOOK_SECRET | identical digests; one compromise is two | owner action |
| Test-mode webhook points at the prod project | after cutover, test payments write into production money tables | owner action |
| No notification channel works | a merchant learns of a paid order by opening the app | structural, see finance page |
| No pgTAP on the new RPCs | the SQL layer is proven by live probe only | open |
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.