Skip to content

Reconciliation, settlement and finance ​

Commission, gateway fees, GST, the audit trail, and the reconciler. Figures here are measured from live Razorpay payloads on Dev, not from a rate card.

Gateway charges: recorded, never depended on ​

THE GOVERNING RULE — OWNER DECISION, 2026-08-17

Razorpay's charges are provider-controlled commercial terms, and nothing in this platform may depend on them.

  • Do not hardcode a Razorpay fee anywhere.
  • Do not make payment logic depend on a fixed fee.
  • Do not assume a specific card or UPI rate, now or in future.
  • Keep the architecture flexible across Razorpay's current and future rate plans.
  • DO capture the actual provider-reported figures wherever Razorpay supplies them, so Finance and Admin have transaction-level historical traceability and reconciliation.

Digious is on Razorpay's default rate plan, and Razorpay has indicated the commercial terms are renegotiable as transaction volume grows. Any rate written into code or into a business rule is therefore wrong on the day that negotiation lands.

The distinction that matters: using a charge for BUSINESS LOGIC versus recording it for FINANCIAL RECONCILIATION. The first is forbidden; the second is required.

Verified 2026-08-17, and it must stay true: nothing reads provider_fee_minor or provider_fee_tax_minor to make a decision. Every reference in the tree is a write, a type declaration, a test of the write, or a comment. Commission is floor(amount_minor × rate_bp / 10000) and is entirely independent of whatever the gateway charged. A future change that makes a money decision conditional on a provider fee is a defect against this rule, not an optimisation.

What is captured, and what it is for ​

ColumnHoldsUsed for
payments.provider_fee_minorthe provider's fee, inclusive of its GSTFinance reporting, margin analysis, reconciliation
payments.provider_fee_tax_minorthe GST within that feeinput tax credit tracking
payments.provider_methodthe instrument (card, upi, netbanking, …)attributing a margin change to a mix change rather than a pricing change

Three properties of the charge that are structural rather than commercial, so they hold across any rate plan:

  1. The fee is charged on the GROSS, not on our commission. It is a percentage of the whole transaction, not of our cut, so the platform's net is always commission − fee and never simply the commission.
  2. fee_bearer = platform, so Digious pays it and the merchant does not. That is why payments_split_adds_up still holds exactly: amount = commission + vendor, with the fee charged against Digious's balance and never entering the split.
  3. ⚠ The tax component is NOT an expense. It is a reclaimable input tax credit, an asset, so netting it against revenue is a specific arithmetic error. ⚠ The credit is only claimable if Digious's GSTIN is on the Razorpay account, otherwise the fee invoice never reaches GSTR-2B and s.16(2)(aa) fails. Owner action, still unconfirmed.

⚠ A fully refunded order is a negative-margin event, whatever the rate plan: the gateway fee is not returned on a refund, so the platform pays the fee and earns no commission. Nothing in the schema could express that before provider_fee_minor existed.

DO NOT MODEL ON THE DEV FIGURES

Observed test-mode values are a snapshot of one rate plan under test conditions and have already proved internally inconsistent (one transaction reported a zero GST component where others reported 18%). They are useful only as a record that the capture path works. Compute the blended rate from payments once live traffic exists — that is precisely why the fee is stored per row rather than taken from a rate card.

⚠ An earlier version of this page asserted that UPI is zero-MDR and would cost nothing in live mode. That was an inference from the regulation, it is wrong for a payment aggregator (Razorpay levies its own processing charge on UPI as it does on cards), and it was corrected by the owner from a direct conversation with Razorpay. It is named rather than quietly deleted because the failure mode is the lesson: a confident rate claim in documentation is read as fact by everyone who comes after.

Settlement ​

The dashed steps happen entirely inside Razorpay. ⚠ We currently have no record of them at all.payments.provider_transfer_id exists and nothing writes it, because transfer.processed and settlement.processed are not subscribed in the Razorpay dashboard. An unsubscribed event is never delivered and is never redeliverable, so this is one of the few genuinely lost-forever gaps. Subscribing is a dashboard checkbox and costs nothing, because payment_events records any event verbatim before checking whether we handle it.

Reconciliation ​

PARTLY BUILT

The schema is live on Dev as of migration 20260817100000: payment_reconciliation_runs, payment_reconciliation_exceptions, record_payment_reconciliation_exception() and get_payment_reconciliation_health(). The reconcile-payments Edge Function and the watchdog are the remaining halves, and nothing is deployed to Prod. So until the reconciler actually runs, reconciliation is still a SQL query someone remembers to run: the tables existing changes nothing on its own, which is the same "an index with no reader is not observability" trap recorded further down this page.

Why two tables and not one ​

runs answers "is the reconciler alive, and did it actually do work" — the heartbeat, read by an out-of-band watchdog. exceptions answers "what is wrong right now" — a work queue for a human. A design with only exceptions cannot distinguish "nothing is wrong" from "nothing has run since Thursday", and those are the two states a money system must never confuse.

⚠ candidates_examined is the anti-green-no-op field. A reconciler that examines zero rows and reports "all clear" is indistinguishable from a working one. That count is the only thing separating them, and the watchdog asserts on it. This is QRS-013 applied to the one component whose entire job is to notice that something did not happen.

⚠ A run that COMPLETED is not a run that WORKED. provider_errors > 0 with candidates_examined > 0 means Razorpay was unreachable, which must alarm exactly as loudly as a mismatch, because a gateway outage and a silent worker look identical from outside.

Exception kinds ​

KindDetects
missing_captureprovider says paid, we say created. The dropped webhook
missing_failureprovider says failed, we still show an open attempt
amount_mismatchthe two systems disagree on how much moved
refund_mismatchcumulative refunded differs
dead_letter_eventpayment_events.process_error set and nothing replayed it
orphan_provider_paymentRazorpay has a payment we have no row for
stuck_pendingan attempt that never resolved either way

⚠ The reconciler may fill a NULL; it may never overwrite a value that is present and different. Filling a NULL from an authoritative provider response records a fact for the first time. Changing 450000 to 460000 because Razorpay says so destroys the evidence that the two systems ever disagreed, and that evidence is the only thing distinguishing a dispute from a bug weeks later.

⚠ Closing an exception is never a money movement. auto means a later sweep observed agreement. manual_corrected records that a human authorised an adjustment; the adjustment itself is its own append elsewhere, never an edit to the exception row.

Scheduling: not pg_cron ​

pg_cron is superuser = true, trusted = false and postgres is not superuser on Dev (measured), so it needs a Supabase control-plane action rather than a migration. Do not make a launch-critical component depend on an extension whose installability is unproven. The reconciler is an HTTP endpoint behind a shared secret, so the scheduler is a swappable detail — which is precisely what makes this decision cheap to reverse. A date-bounded GitHub Actions cron covered the festival window (it ended 2026-09-17, and since 2026-09-23 the watchdog runs only on manual dispatch, as no workflow runs on a schedule any more); pg_cron follows in wave 2.

⚠ Two traps found in the proposed designs and recorded so they are not reintroduced: the cron would have sent header X-Internal-Secret while _shared/internalAuth.ts reads x-worker-secret, 401ing silently on every run because pg_net is fire-and-forget and nothing reads net._http_response; and a GitHub Actions watchdog holding a full Postgres connection string is a strictly larger blast radius than the service-role key the same design refused to store.

The out-of-band watchdog ​

.github/workflows/payments-watchdog.yml. It triggers a sweep over HTTPS, then reads get_payment_reconciliation_health() and prints the whole object before asserting anything, so a red run is diagnosable from the log alone. It exists because the reconciler cannot be its own watchdog: a component that runs inside the database it is judging cannot report that the database is down.

#Fails whenThe failure it catches
A1newest_completed_at is null, or older than 180 minutesNothing has swept. Also catches a run abandoned mid-sweep, because an abandoned lease never sets finished_at
A2last_run_examined = 0, or last_run_errors > 0The green no-op, and its twin: a run that COMPLETED without being able to reach Razorpay
A3open_critical > 0A real discrepancy is open. Warnings deliberately do not alarm: they are a work queue, and paging on them trains people to ignore the alarm
A4dead_letter_events > 0, or stuck_unprocessed > 0The webhook is dropping events. These are the two indexes that existed for days with no reader

⚠ The health read goes through the Edge Function, NOT through PostgREST, and the grant is why.get_payment_reconciliation_health() has EXECUTE revoked from anon and from authenticated, so an anon-key /rpc/ call cannot reach it. That grant must not be relaxed to suit the watchdog — reconciliation posture is platform-operations data, and granting anon would publish the state of the money loop to the public internet. So reconcile-payments answers POST { health_only: true } with the function's output and nothing else: no run row, no lease, no write. The watchdog authenticates with the worker secret, exactly as the function's own comment specifies. The anon key it also sends is only there to satisfy the gateway's verify_jwt = true, which _shared/internalAuth.ts is explicit is not real authentication.

⚠ The sweep body is { trigger_source }, and there is no action field. Recorded because the first draft of the watchdog guessed an { action: 'sweep' | 'health' } dispatch the function never had. The sweep call would have appeared to work while the health call 400d on a missing trigger_source, so the failure would have looked like a broken endpoint rather than a wrong client. Read the function's source, not its shape by analogy with manage-order.

⚠ No Postgres connection string and no service-role key are stored in GitHub Actions. A connection string reaches every table directly, bypasses PostgREST's grants entirely and cannot be scoped down; the worker secret reaches one endpoint that does one thing.

SINCE 2026-09-23 THE WATCHDOG RUNS ONLY WHEN STARTED MANUALLY

What changed. Its schedule is removed (kept commented out in the workflow), as part of the owner's decision that no workflow runs on a timer (GitHub Actions usage forensics). It was already disabled in the Actions UI on 2026-09-04, and its festival window closed on 2026-09-17.

How to run it now. gh workflow run payments-watchdog -f target=prod (or dev, with skip_sweep=true to read health only).

What that costs. There is currently no automated detection of a dropped capture. Re-opening a schedule for a future launch window is an owner decision. The cadence reasoning below still applies when that happens.

It is date-bound to the festival window and fails, rather than skips, outside it. A cron pointed at a money endpoint that nobody watches spends the Actions budget forever while its green checks are read by no one, so they become evidence of health that nothing produced. A red X is the only thing that forces the deliberate choice between extending the window and deleting the file. Hourly during 09:00-22:00 IST costs ~434 billed minutes over a 31-day window, about 22% of the Free-tier monthly allowance, which is why it does not run overnight when no orders are placed.

⚠ The workflow's assertion logic is mutation-tested in both directions (17 cases) and has NEVER RUN against a live endpoint. The tests prove that every assertion can fail, that a non-numeric or absent count is refused rather than coerced to zero, and that a response whose shape disagrees with the function's keys is rejected rather than passing green. They prove nothing about the endpoint. Two things must be true before the schedule opens: reconcile-payments must be deployed with its RECONCILER_WORKER_SHARED_SECRET set (⚠ RECONCILER_, not RECONCILE_ — one letter, and the wrong one is a silent 401 on every run), and the SUPABASE_PROD_PUBLISHABLE_KEY repository variable must exist. Rehearse it with workflow_dispatch against Dev first, because a watchdog cannot have its first run in production.

supabase/config.toml declares [functions.reconcile-payments] verify_jwt = true, which is the gateway layer the watchdog's anon key satisfies. ⚠ Note that nothing enforced that entry existing: check:fn-config exits with usage text unless passed --project <ref>, so it runs nowhere, which is how manage-reminder reached Dev deployed verify_jwt = false by accident rather than by decision. The entry being correct here is diligence, not a gate.

Tax and statutory records ​

What is captured ​

FactWhereStatus
HSN/SAC at sale timeorder_items.hsn_sac✅ column + write path
Tax rate at sale timeorder_items.tax_rate_bp✅ column + write path
Inclusive vs exclusive at sale timeorder_items.price_includes_tax✅ column + write path
Order-level treatmentorders.tax_treatment✅ column, ⚠ not yet written
Merchant GSTIN and state, over timeworkspace_tax_identity_history✅ table + trigger
Gateway fee and its GSTpayments.provider_fee_minor / _tax_minor✅ columns, ⚠ write path undeployed
Commission GST—🔴 blocked on a CA ruling
TCS under s.52—🔴 blocked on a CA ruling
TDS—🔴 blocked on a CA ruling
Statutory invoice documents—🔴 not built

⚠ The three blocked columns are blocked deliberately. Inventing a tax position in a schema is worse than leaving the column out: a tcs_minor column that is always zero asserts that no TCS was collectible, which is a claim nobody has verified.

The e-commerce operator question, which is the one to ask the CA ​

Under s.52 CGST, an electronic commerce operator collecting consideration on behalf of suppliers must collect TCS and register under s.24(ix) regardless of turnover. QRSETU collecting a buyer's money and splitting it to a vendor looks like exactly that.

⚠ But Notification 34/2023 may exempt this cohort entirely: goods supplied through an ECO, by a supplier under the registration threshold, intra-state only, holding a PAN and an enrolment number. The Ganapati cohort is twelve Pune vendors selling to Pune buyers, which fits. This is a ruling to obtain, not a conclusion to adopt — it changes whether TCS must be collected from day one, and getting it wrong in either direction is expensive.

Invoice numbering ​

Rule 46(b) requires a consecutive number, unique per financial year, max 16 characters.

⚠ orders.reference fails on both limbs and must not be reused as an invoice number. It is deliberately random (a per-workspace sequence would leak a vendor's order volume to a competitor placing two orders and subtracting) and it is up to 32 characters. Any statutory document needs its own numbering series.

The statutory_documents design was cut from this wave on the merits, with two structural defects recorded for whoever builds it: the one-per-subject unique index had no WHERE NOT cancelled, so a cancelled invoice could never be reissued; and subject_kind = 'commission_period' had no subject_id that exists, so the one stream justifying the deferral could not be issued. Rule 47 gives 30 days on the commission invoice, so an 18 August commission remains invoiceable until 17 September, which is what makes the deferral safe rather than merely convenient.

Audit and event tracking ​

ConcernMechanismStatus
Every provider event, verbatimpayment_events.payload, written before processing✅
Which events touched which paymentpayment_events.payment_id✅ column, ⚠ write path undeployed (currently 0 of 9 linked)
Replay protectionunique (provider, provider_event_id)✅
Dead letterspayment_events_failed_idx WHERE process_error IS NOT NULL✅ index, 🔴 nothing reads it
Unfinished eventspayment_events_unprocessed_idx WHERE processed_at IS NULL✅ index, 🔴 nothing reads it
Structured logsconsole.log → Supabase/Logflare✅
Errors and crashesSentry via @qrsetu/observability⚠ no-op until a DSN is set
Merchant-facing auditaudit_log✅ table, 0 rows — no payment path writes it

⚠ An index with no reader is not observability. Both dead-letter indexes exist and are correct, and nothing queries either one, so a dead letter is discoverable only by someone who already suspects it. That is the half the reconciler closes.

Notifications and receipts ​

THE HONEST ANSWER: NO CHANNEL WORKS TODAY

ChannelWhy not
APNs pushstructurally impossible. A free Personal Team cannot sign the Push capability (QRS-234); it needs the $99 programme and a fresh signed build
FCM / Android pushno Firebase project, no google-services.json, no EAS project id, no token table, no send path. withoutPushEntitlement.js actively strips the entitlement
EmailZeptoMail REST is genuinely viable and is not the broken Supabase-Auth SMTP path (QRS-285 is a 535 against smtp.hostinger.com), but it needs a Send Mail token and workspaces.contact_email is nullable. Festival vendors do not read email at a stall
SMS / WhatsAppTRAI DLT template registration and Meta Business Verification. Weeks
public.outboxthe table exists with a cache.purge topic and nothing drains it. Enqueuing into an undrained queue is worse than a direct call because it looks done

The launch answer is in-app on next foreground: a polling alert hook that surfaces a new paid order when the merchant opens the app. It is honest about what it is, and it does not depend on a capability that cannot be signed.

Receipts: the buyer's receipt today is the Razorpay confirmation plus the order reference. There is no QRSETU-issued receipt document, and the design's own position is that a Setu Card order is not an invoice. A merchant-issued statutory invoice is the statutory_documents work above.