Skip to content

Payments data model ​

Every table money touches, what each meaningful column is for, and which record is written at which stage. Measured against live Dev on 2026-08-17.

Relationships ​

⚠ Three FK behaviours are deliberate and are not interchangeable.payments.order_id is RESTRICT: an order with money against it cannot be deleted. order_items.order_id is CASCADE: a line has no meaning without its order. orders.buyer_user_id is SET NULL and nullable: an anonymous buyer is first-class, and deleting a consumer account must not delete the merchant's sales record.

orders — 21 columns ​

The merchant's sales ledger. Two independent status axes, which is the single most important thing to understand about this table: an order can be ready and unpaid, or completed and partly_paid. Collapsing them into one status would make "the idol is carved but not paid for" unrepresentable, and that is the normal Ganapati case.

ColumnPurpose
iduuid, and this is what Razorpay carries as reference_id on the payment link
workspace_idthe merchant. Every RLS policy scopes on it
referencewhat the buyer quotes at the counter. Random, never a sequence — a per-workspace sequence lets a competitor measure a rival's volume by ordering twice and subtracting. Crockford base32 omitting I/L/O/U so it is unambiguous read aloud. Unique per workspace
buyer_user_idnullable. Null = anonymous, set = a registered consumer. Not an addition: an append-only table cannot gain an identity column cheaply
buyer_name · buyer_phone · buyer_email · buyer_notebuyer_phone is NOT NULL: an order with no contact route is not an order, because a stall that cannot reach the buyer cannot say the idol is ready
sourcepublic_card | manual — did it arrive from the card or the counter
statuspending → confirmed → ready → completed, or cancelled. orders_cancelled_has_reason forces a reason
payment_statusunpaid | partly_paid | paid | refunded | failed. ⚠ A cache, derived from the ledger, never set from an event
subtotal_minor · tax_minor · total_minorinteger paise. orders_total_is_subtotal_plus_tax CHECKs the identity. ⚠ tax_minor is currently always 0 — see below
tax_treatmentsupplier_not_registered | inclusive | exclusive | exempt | not_computed
collect_onthe agreed collection date, nullable
cancelled_reasonfree text, so a row written by support may hold a sentence rather than one of our codes

⚠ tax_minor is hardcoded 0, and the blocker is not the CHECK constraint.place-public-order mints the Razorpay link on subtotalMinor and inserts payments.amount_minor = subtotalMinor, while derivePaymentStatus compares captured against orders.total_minor. They coincide only because tax is zero. The moment it is non-zero, every fully-paid order derives as partly_paid and the Route transfer is computed on the wrong base. That coupling is the real blocker on inclusive tax, and it is why the inputs were snapshotted before the decomposition was attempted.

order_items — 16 columns ​

ColumnPurpose
order_idCASCADE
item_id · variant_idSET NULL, and nullable by design: a stall sells things not in the catalogue
name_snapshotthe name at sale time. A join to catalog_items would restate a past sale under today's name
unit · quantitynumeric(12,3) — 0.5 kg of sweets is an ordinary sale
unit_price_minor · line_total_minorboth computed server-side, never accepted from the client
positionassigned server-side so ordering cannot be steered by a caller
hsn_sacsnapshotted GST classification. NULL means the merchant had not classified the item, which is distinguishable from zero-rated
tax_rate_bpsnapshotted rate in basis points. ⚠ Existed since 2026-08-08 and was written by nothing until 2026-08-17
price_includes_taxwhether unit_price_minor was tax-inclusive at sale time

⚠ The three tax fields are the only genuinely lost-forever facts in the whole payment surface. Every provider figure is recoverable from payment_events.payload; no payload anywhere contains our tax fields. They live only in catalog_items, which is merchant-mutable, so the classification at the instant of sale dies silently the first time a vendor edits the item.

payments — 21 columns ​

The money ledger. One row per attempt, not one per order: a retried link and a counter top-up are both ordinary.

ColumnPurpose
order_idRESTRICT
workspace_iddenormalised from the order deliberately: every policy filters by workspace, and an order never changes workspace, so this is a fixed copy of an immutable fact
providerrazorpay | cash | upi_manual. The two offline values exist because advances are taken in cash and tracked in a notebook, and a payments table that cannot record cash records none of it
provider_payment_idRazorpay's pay_…. UNIQUE with provider — the replay key. Nullable because the row is created before the provider is called
provider_order_idRazorpay's order_…
provider_link_idour plink_…, written at checkout. The only key present on the row before any payment happens
amount_minor> 0
commission_rate_bpsnapshotted at settlement, so a rate change cannot rewrite history
commission_minor · vendor_minorpayments_split_adds_up: amount = commission + vendor, exactly
refunded_minorcumulative, <= amount_minor
statuscreated | authorized | captured | failed | partly_refunded | refunded
failure_reason · captured_atpayments_captured_is_complete forces captured_at and a pay_… on a captured Razorpay row
provider_fee_minorwhat Razorpay charged us, inclusive of its GST. Borne by Digious, never the merchant, and not returned on a refund
provider_fee_tax_minorthe GST within the fee. ⚠ Not an expense — a reclaimable input tax credit, an asset
provider_transfer_idRoute's trf_…. Answers "did the vendor actually get paid"

⚠ payments_offline_has_no_commission forces commission_rate_bp = 0 and commission_minor = 0 for cash and UPI. We take no cut of money that never passed through us.

payment_events — 9 columns ​

Append-only, and the reason most defects here are recoverable rather than fatal.

ColumnPurpose
provider + provider_event_idUNIQUE. A redelivery becomes a constraint violation rather than a second payment
event_typepayment_link.paid | payment.captured | order.paid | payment.failed | refund.processed, plus anything else recorded and ignored
payloadthe verbatim body, written BEFORE processing. If processing throws, the row survives intact and is replayable
payment_idFK SET NULL, so the ledger can be joined to the money it describes
processed_atset only on success, so a dead letter stays visible as unprocessed
process_errorset on a permanent business failure. Indexed WHERE process_error IS NOT NULL
received_atarrival time, which is how the event-ordering race was measured

payout_accounts — 13 columns ​

ColumnPurpose
workspace_idCASCADE
provider_account_idRazorpay's acc_…. UNIQUE with provider
account_statuscreated | suspended — Razorpay's view of the account
activation_statusrequested | needs_clarification | under_review | activated | suspended — Razorpay's view of whether it can receive money
account_last4 · ifscformat-CHECKed. ⚠ The full bank account number is deliberately NOT stored

⚠ Two status fields, and only one of them gates a transfer. activation_status = 'activated' is the product gate. An account can exist (account_status = 'created') and still be unable to receive money, and conflating the two would mint payment links that Route cannot settle.

RLS is enabled with no policy, deliberately, matching payment_events: this is platform operations data, the only writer is the service role, and an absent policy looks like a gap to the next reader who will otherwise "fix" it.

workspace_subscriptions and platform_plans ​

Covered on Subscription payments. workspace_subscriptions is one row per workspace, plan_key FK to platform_plans(key), status in ('active','ended').

workspace_tax_identity_history — 7 columns ​

The merchant's GSTIN and state code over time, written by the workspaces_record_tax_identity trigger using is distinct from so a no-op update does not create a row. Time-keyed rather than a mutable column because an invoice issued in August must reflect the GSTIN that was in force in August, not today's.

What is written at each stage ​

Stageordersorder_itemspaymentspayment_events
Public order placedINSERT pending / unpaidINSERT lines + tax snapshot——
Payment link minted——INSERT created, split snapshotted, provider_link_id—
Any webhook arrives———INSERT verbatim, before processing
order.paid correlatesUPDATE payment_status (derived)—UPDATE captured, pay_id, captured_atUPDATE processed_at, payment_id
payment_link.paid (stale)——UPDATE fee, tax, provider_order_idUPDATE processed_at, payment_id
payment.captured first———UPDATE process_error (dead letter, noise)
Refund processedUPDATE payment_status—UPDATE refunded_minor (monotonic) + statusUPDATE processed_at, payment_id
Counter paymentUPDATE payment_status—INSERT captured, zero commission—
Order completedUPDATE status = completed—INSERT settlement row if a balance was due—
Order cancelledUPDATE cancelled, reason—UPDATE refunded_minor if a refund was agreed—

Razorpay identifiers we store ​

Razorpay idColumnWritten when
plink_… payment linkpayments.provider_link_idat checkout, by place-public-order
pay_… paymentpayments.provider_payment_idon first correlating webhook
order_… orderpayments.provider_order_idon a webhook carrying it
acc_… linked accountpayout_accounts.provider_account_idwhen the owner onboards a vendor
trf_… Route transferpayments.provider_transfer_id⚠ nothing writes it yet — needs transfer.processed
event idpayment_events.provider_event_idon every webhook, as the replay key
rfnd_… refund⚠ not stored — only the cumulative amount is—