Where store money lands in the platform ledger

The exact transaction rows a store sale writes — types, reference ids and idempotency keys for the buyer debit, the revenue leg, the shipping and tax pass-through and every reversal — and how to reconcile them.

7 min readUpdated 6 August 2026ledger, reconciliation, refunds, transactions, accounting

Orders and fulfilment explains what a sale moves in plain terms. This page names the rows, because when you are reconciling store takings against the platform's transaction table — or investigating a half-completed reversal — plain terms are not enough.

Every leg below is written inside the checkout transaction. Either all of them exist for an order, or none of them does.

The rows one sale writes

An order id is a UUID, and it is the anchor for everything. Below, <id> is that order id.

Leg Wallet credited or debited transaction.type referenceId idempotencyKey
Buyer debit The buyer's wallet, debited ECOMMERCE_PURCHASE <id> ecom_order_<id>
Store revenue Super Admin wallet, credited PLATFORM_FEE <id>_fee platform_fee_TRADE_<id>
Shipping + tax Super Admin wallet, credited ORDER_PASSTHROUGH <id>_passthrough ecom_passthrough_<id>
Revenue reversal Super Admin wallet, debited REFUND <id>_fee_reversal ecommerce_refund_platform_fee_<id>
Pass-through reversal Super Admin wallet, debited REFUND <id>_passthrough_reversal ecom_passthrough_reversal_<id>
Buyer refund The buyer's wallet, credited REFUND <id>_refund ecommerce_refund_buyer_<id>

Every one of these rows is written with status = 'COMPLETED'. There is no pending state in the store's ledger; by the time a row exists the balance has already moved.

The buyer debit

Written by walletService.debit with operationType: ECOMMERCE_PURCHASE, which lands in the ledger as a transaction.type of the same name. Its amount is the full charge — subtotal, less discount, plus shipping, plus tax — and its description spells the arithmetic out so a receipt can be reconstructed from the row alone:

Purchase of Blue Widget x2 (80.00 - 10.00 discount + 5.00 shipping + 7.50 tax) = 82.50 USD

metadata on the same row carries orderId, productId, productName, quantity, subtotal, discountAmount, shippingCost and taxAmount as structured fields, which is usually easier to query than parsing the sentence.

The revenue leg

Only the discounted subtotal is booked as revenue, and only when it is greater than zero. It credits the Super Admin wallet with description Ecommerce order revenue: <product> x<qty>.

This is the one leg that also writes a row to admin_profit, linked to the transaction by transactionId, with type = 'TRADE'. That is the value the platform's profit reporting reads.

They are two different columns on two different tables and they do not match by design. admin_profit.type is TRADE; the transaction.type behind it is PLATFORM_FEE. Searching the transaction table for TRADE will find nothing.

The pass-through leg

Shipping and tax left the buyer's wallet, so they have to arrive somewhere. They credit the Super Admin wallet separately, with description Shipping and tax collected on order <id>, and deliberately do not write an admin_profit row.

That is the point of the separation: you still owe the carrier and you still owe the tax authority. Counting the pass-through as profit would overstate your margin on every physical order. If you are reconciling what the platform holds against what you have earned, ORDER_PASSTHROUGH is the difference.

The leg is skipped entirely when shipping and tax are both zero — a digital-only sale in a store with tax switched off writes no pass-through row.

Neither the revenue leg nor the pass-through leg is allowed to roll back a completed purchase. If the Super Admin user is not configured, the money is logged as [CRITICAL] Dropped … no Super Admin configured with the order id and amount, and the sale still completes. Search your backend log for [CRITICAL] before you conclude that a missing credit is a reporting bug.

Reversal

Cancelling or rejecting an order runs one shared routine for all three admin doors — the per-order status route, the per-order update route and the bulk status route — so they cannot disagree.

  1. The payment is located first. If there is none, the operation fails before the status changes.

  2. Revenue is reversed — but only if it was actually credited, checked by looking for the <id>_fee row.

  3. Shipping and tax are reversed — again only if the <id>_passthrough row exists. Skipping this would mint money, because the buyer is about to be refunded the full amount.

  4. The buyer is credited the amount recorded on the purchase transaction — not a recomputation from current settings.

  5. Stock is restored for every physical item on the order.

Each leg carries its own idempotency key, so a reversal that half-completed and was retried does not pay anyone twice; a leg that has already run is skipped and logged rather than repeated.

No payment was found for order <id>, so it cannot be refunded.

This message means the status change did not happen. It is checked before the write, precisely so that an order can never be left CANCELLED with the buyer's money still gone. The order is still in whatever state you found it.

You will see it when the order genuinely has no purchase transaction — a row created by hand, a row imported from elsewhere, or an order whose transaction was deleted. Confirm with the query below before doing anything else.

How the payment is found, and why an old order behaves differently

The lookup tries the exact idempotency key ecom_order_<id> first. If that finds nothing, it falls back to any transaction whose referenceId is the bare order id.

That fallback exists for orders placed before the idempotency key was introduced, and it is looser than the primary path: it will match any transaction carrying that reference. In practice that is the purchase debit, because referenceId is unique — but it means an old order's reversal is resolved by a weaker rule than a new one's. If a legacy order reverses oddly, check which of the two paths matched it.

Deleting an order is blocked while its money is still with you

Both delete doors — single and bulk — run a guard before removing anything. It blocks any order that is PENDING or COMPLETED, has a purchase transaction, and has no matching <id>_refund row. Blocked orders are named:

Order <id> has been paid for and not refunded. Cancel or reject it first — that refunds the buyer and restores the stock — then delete it.

On a bulk delete the message counts the blocked orders and lists the first three. Nothing is deleted when even one is blocked.

Delete removes the row and nothing else — it does not refund, does not restore stock and does not reverse revenue. Cancel first, then delete. Deletes are soft and restorable in any case.

Reconciling by hand

Three things bite when you query the database directly.

The transaction table carries a unique index on referenceId (transactionReferenceIdKey). Inserting a reconciling row that reuses an order id — or any reference already in use — violates it and fails the whole statement. This is exactly why every leg above has its own suffixed reference rather than sharing the order id: an earlier build wrote refunds with referenceId = order.id, which the purchase debit already held, so every cancel rolled back and the order stayed PENDING.

Money columns on the transaction and admin_profit tables are DECIMAL(36,18), and the MySQL driver returns them as strings. In JavaScript "1" + 1 is "11". Coerce before any arithmetic. The order table's own money columns (subtotal, discount, shippingCost, tax, total) are DOUBLE and come back as numbers, so the two tables behave differently in the same script.

Order money is in the order's own currency. Each order row carries the currency and walletType copied from the product at checkout. Summing total across a multi-currency catalogue produces a number with no meaning. Group by currency first — the same rule the table analytics apply.

The legs of one order

SET @id = 'PASTE-THE-ORDER-UUID-HERE';

SELECT type, status, amount, currency, referenceId, idempotencyKey, description
FROM   transaction
WHERE  referenceId IN (
         @id,
         CONCAT(@id, '_fee'),
         CONCAT(@id, '_passthrough'),
         CONCAT(@id, '_fee_reversal'),
         CONCAT(@id, '_passthrough_reversal'),
         CONCAT(@id, '_refund')
       )
    OR idempotencyKey = CONCAT('ecom_order_', @id)
ORDER BY createdAt;

A settled, un-refunded sale shows three rows. A cancelled one shows six (or four, if there was no shipping or tax to collect).

The same set the delete guard blocks. Useful as a standing check that no cancellation half-completed:

SELECT o.id, o.status, o.total, o.currency, o.createdAt
FROM   ecommerce_order o
JOIN   transaction p  ON p.idempotencyKey = CONCAT('ecom_order_', o.id)
LEFT   JOIN transaction r ON r.referenceId = CONCAT(o.id, '_refund')
WHERE  o.status IN ('CANCELLED', 'REJECTED')
  AND  r.id IS NULL;

Any row this returns is an order marked cancelled whose buyer was never credited — which the guarded path cannot produce, so a hit means something wrote that status outside the admin doors.

Store revenue for a period

SELECT currency, SUM(amount) AS revenue
FROM   admin_profit
WHERE  type = 'TRADE'
  AND  description LIKE 'Ecommerce order revenue:%'
  AND  createdAt >= '2026-08-01'
GROUP  BY currency;

The description filter matters: TRADE is a shared profit category and other parts of the platform write to it. And SUM(amount) here is a string sum in the driver — group by currency and convert deliberately rather than adding across denominations.

What reconciliation cannot express

There is no partial refund, no per-item refund and no restocking fee anywhere in this addon. A reversal returns the exact amount recorded on the purchase transaction. If a customer keeps part of an order, the only path is to cancel the whole thing and re-sell what they are keeping — and your reconciliation will show one full refund and one new sale, not an adjustment.

A cart of three products also produces three independent orders, each with its own id and its own set of ledger rows, and shipping is charged once across the whole checkout rather than once per order. There is no parent record joining them, so "what did this customer's basket cost" is a question the ledger can only answer by grouping the buyer's transactions by time.