Skip to main content
Omniflow does not store a wallet balance. It stores movements, and a balance is what you get when you add them up. Every movement is a 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_key is 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’s customer_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.
Earlier documentation described balances as the sum of non-expired entries. That is not how it works, and the difference matters when you are auditing: an expired credit is removed by a compensating negative entry written by a background loop, so a balance converges on the expected figure once that loop has run rather than the instant the expiry timestamp passes.
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 carry expires_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.
An entry whose clamped amount comes out at zero still gets its 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/wallet shows 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 is GET /v1/account/wallet.
  • Telegram. Wallet shows the balance and the last ten movements with signed amounts, and Top up starts a funding order.
Operator reasons are shown to customers verbatim. A balance that changed without explanation is worse than one whose explanation was written for staff, so write adjustment reasons expecting a customer to read them. Funding the wallet is Wallet top-up and saved carts; spending it is described under wallet-first payment in Orders, payments and refunds.

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.
What is required, and why:
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.
On success the transaction and both entries are written, and an audit event 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.

Retention and privacy

Financial history is never removed by a retention sweep, and anonymising a customer keeps it: an order that funded a ledger entry cannot be deleted without unbalancing the ledger. What anonymisation removes is contact and identity data, not the movements that explain a balance. See Customer privacy and Financial invariants.