ledger_transactions row with at least two ledger_entries
that sum to zero per currency, and no code path anywhere updates or deletes either table.
That shape is chosen for one reason: a balance has to be explainable. A stored number that somebody
adjusted is a number with no history; a sum of movements can always answer “why is it this, and
what changed it”. Corrections are new compensating pairs, never edits, so the answer survives the
correction.
The three accounts
The table CHECK makes those rules structural: a
customer_wallet entry without a customer, or a
clearing entry with one, cannot be written. Before any transaction is inserted, the domain also
refuses fewer than two entries, a zero amount, a malformed currency, and any currency whose entries
do not sum to zero.
Money is an int64 count of minor units plus an uppercase three-letter ISO code. Floating point
cannot represent 0.10 exactly, and a drift of one minor unit in a ledger that must sum to zero is
an unbalanced ledger, so nothing in the schema, the domain or the API ever carries a decimal
amount. Requests and responses use amountMinor with currency; the currency exponent comes from
a fixed table rather than a hard-coded 100, so a zero-decimal currency such as XTR, JPY or KRW
is never scaled by the wrong factor.
Transaction types
ledger_transactions.type is a closed set: credit, debit, payment, refund,
referral_reward, correction, expiration. Each row also carries a reference_type and
reference_id naming what caused it, an idempotency_key, and — for anything a person decided — a
reason and an actor_id.
Two uniqueness guards make a transaction exactly-once from two directions:
idempotency_keyis unique across the whole table;(reference_type, reference_id, type)is unique, so the same cause cannot produce a second transaction of the same kind even under a different key.
Every movement Omniflow writes
Two of these are worth reading closely.
A gift claim pairs the recipient’s credit against
platform_clearing, not against revenue. The
sender’s money was recognised when their own order settled, and recognising it a second time when
the recipient claims would double-count it.
A top-up credits customer_wallet against provider_clearing, because the money genuinely came
from a provider and has not been earned yet. An order settling then moves it from the customer to
platform_clearing as revenue. That is why a customer paying entirely from their wallet still
produces a balanced movement with no provider leg.
Balances
A balance is the plain sum of a customer’scustomer_wallet entries in one currency. Nothing is
filtered out of that sum — not expired credit, not old entries — which is exactly what makes it
deterministic and what makes the customer’s history reconcile against it line by line.
The customer-facing screens show three figures per currency, because one number would answer none
of the questions honestly:
Reserved is subtracted with the same arithmetic the order path uses — literally the same: one
query (
GetWalletBalances, exposed as commercepg.Store.WalletBalances and
AvailableWalletBalance) is what order creation, the shop, a top-up refund and the customer web
panel all read. Without the reservation, two checkouts opened side by side would each believe the
same money was theirs. A customer shown only the total
and then given a smaller amount at checkout has been told the wrong number twice.
Expiring credit
A ledger entry may carryexpires_at. Only referral rewards set it, and only when the referral
program configures reward_expiry_days — see Referrals and
loyalty.
The worker’s commerce_maintenance pass runs once a minute, takes up to 500 positive
customer entries whose expiry has passed and which have no expiration transaction yet, and writes
one compensating pair for each.
Two decisions in that pass are deliberate:
- Expiry is a compensating entry, not a filter on the balance query. The balance stays a plain sum, and the expiry itself appears in the customer’s history where they can see what happened to their credit.
- The amount is clamped to the entry amount, the customer’s current balance, and what they received after that entry. A customer who already spent the credit is not driven negative by its expiry.
expiration transaction, with no
entries under it, which is what stops the pass from re-examining it forever.
The pass runs only on the worker. An installation running without a worker never expires wallet
credit, and expired referral rewards stay spendable.
What a customer sees
- Web.
/account/walletshows the three figures per currency, the top-up controls, and a cursor-paginated history of the customer’s own entries with the transaction type and, where a correction carried one, the operator’s reason. The API isGET /v1/account/wallet. - Telegram. Wallet shows the balance and the last ten movements with signed amounts, and Top up starts a funding order.
What an operator sees
/admin/customers/{customerId} → Wallet reads
GET /v1/panel/customers/{customerID}/wallet, and the permission is finance.read, not
customers.read. The split is intentional: a support operator can see that a customer has a
balance without being able to read every movement that produced it.
The panel’s wallet tab is read-only. There is no update or delete path for a ledger row anywhere in
the panel, and no panel route that adjusts a balance.
Operator adjustments
Goodwill credit after a support case, a correction, a manual referral payout — all of these are one API call, and only that call. There is no/v1/panel route and no screen in /admin for adjusting
a wallet; the bearer-token API is the whole surface.
1
A reason, an actor, and a non-zero amount
All three are refused if missing. Requiring them is what makes a balance that changed without
an explanation impossible, and the reason is shown to the customer.
2
A type, with the right sign
credit, debit, referral_reward, correction or expiration. credit and
referral_reward must be positive; debit and expiration must be negative; correction may
be either. A credit written with a negative amount is a typo, not an intention.3
An idempotency key
Reusing a key with a different type, reference, actor, customer, currency or amount is refused
with “idempotency key was already used with different wallet parameters”. Reusing it with the
same parameters returns without writing a second transaction, so a retried request is safe.
4
A balance that stays non-negative
A negative adjustment re-reads the balance inside the serializable transaction and refuses with
“wallet adjustment would create a negative balance” rather than writing a debit the customer
cannot cover.
wallet.adjusted is
appended with category financial, outcome success, the customer as its target, and the amount
and currency in its metadata. The adjustment then appears in the customer’s own wallet history with
its type and reason.