Top-up is an ordinary order
A top-up is an order withoperation = 'topup'. It gets a payment intent, a provider checkout,
webhook intake, reconciliation, and refund handling exactly like a subscription purchase does. What
differs is what settlement produces: credit in the customer’s wallet instead of an entitlement.
wallet_minor is always zero — and because there is nothing for the wallet to cover, a top-up is
always created pending with a provider to pay.
Alongside the order there is one wallet_topups row holding the requested amount, the credited
amount, and the ledger transaction that credited it. Its own CHECK ties the last two together: a
top-up is credited exactly when it has a ledger transaction, never one without the other.
Creating one
The store runs the whole creation in a serializable transaction:- Idempotency. An existing order under the caller’s key with a different operation, currency or amount is refused; the same one is returned.
- Maintenance. A top-up is a purchase, so it is refused with
503 maintenance_activewhile the installation is in a maintenance window. See Maintenance mode. - Window allowance. What the customer has actually been credited inside the rolling window is summed.
- Limits. The requested amount is validated against the configured bounds.
- The order and the
wallet_topupsrow are written, with a payment window of one hour unless the caller supplied one or named themanualprovider, which gets seventy-two hours — see the payment window.
Exactly-once credit
The credit is one ledger transaction keyed on the order:ledger_transactions.idempotency_key is unique and (reference_type, reference_id, type) is
unique, so a redelivered webhook, a reconciliation poll that arrives after the webhook, and a manual
approval all converge on the same single transaction. The wallet_topups.credited_at timestamp is
set in the same transaction, which is the third guard: crediting starts by locking the row
FOR UPDATE and returns without writing anything if that timestamp is already set.
Once the credit commits, an outbox event wallet.credited is written and an operator notice of kind
topup is queued, deduplicated on the order.
Overpayment and underpayment
A subscription order that receives the wrong amount stays pending, because the thing it buys has a fixed price. A top-up has no such constraint: the customer asked to add money and money arrived. Omniflow therefore credits exactly what the provider settled and marks the order paid:
The mismatch is recorded as a payment event and reaches the operator top-up topic, so a discrepancy
is visible without a customer having to report it. See Payment
classification.
Limits
Three operator-configured bounds apply, all in minor units of the settlement currency:- a minimum, which is never below one minor unit, so a zero-amount top-up cannot be created;
- a maximum for a single top-up, which a value of
0removes entirely; - a rolling-window ceiling on how much may be credited within a configured period, which a value of
0disables.
topup_below_minimum,
topup_above_maximum, topup_window_exceeded, topup_disabled, topup_invalid_amount — surfaced
as the type slug of an RFC 9457 problem and as a copy key the bot renders in the customer’s
language.
Presets are filtered against the same limits before they are rendered, so neither surface ever shows
an amount the backend would refuse.
Funding a wallet in the browser
/account/wallet shows the balance in each currency the customer holds — total, reserved, and
available — then the preset buttons, a free-entry field, and the payment methods that can settle the
currency. What a person types in the major unit is converted to minor units before it is sent.
Top up posts to POST /v1/account/wallet/top-up with {amountMinor, currency, provider} and a
mandatory Idempotency-Key header. The header is mandatory rather than optional because a top-up
mints real money into the wallet, so the caller’s key becomes the order’s key and a retried request
cannot mint it twice.
The response is 201 with {orderId, currency, amountMinor, state, payment}, and the page renders
whichever handoff the provider returned: a hosted checkout link, a Telegram invoice, “an operator
will confirm this” for a manual transfer, or nothing at all.
GET /v1/account/wallet returns the balances, a cursor-paginated page of ledger entries, and the
top-up policy itself — enabled, minimumMinor, maximumMinor, presets,
remainingWindowMinor and the available providers — so the screen never has to guess what the
server will accept.
If the order is created and the provider then refuses the payment intent, the API still returns
the order. The order exists and it is the customer’s; discarding it would leave a row nobody can
reach. The customer can start a payment on it again from its order page.
Funding a wallet in Telegram
Wallet → Top up offers the same presets, plus custom for typing an amount as an ordinary message, then a payment method, then the order status screen. Amounts typed in the major unit are parsed with the same currency exponents the web panel uses. History lists the last ten attempts with the state each reached — the order’s own state, orcredited once the ledger credit is written. An attempt still pending links straight to its order,
which is the same recovery screen a subscription purchase uses, including refresh and the
duplicate-payment explanation.
Saved carts
A cart is one saved purchase intent per customer: a plan version, an operation, a currency, the selected squads, any add-ons, and an applied promo code. It survives an insufficient balance, a lost session, and navigating away from checkout. A rejection is never destructive — the cart stays saved so the customer can top up, drop an add-on, or wait for the catalogue to settle.The saved price is never the charged price
A cart stores what the customer chose, not what it cost. Every time the cart is displayed or considered for purchase it is re-priced against the plan version that is current right now, including promotion eligibility, add-on prices, and the wallet balance. If the plan changed, the customer sees the new price before anything is charged; if the plan disappeared, the cart reportscart_plan_unavailable and stays saved so it can be rebuilt, rather than being charged at a stale
price. A promo code that stopped being valid is reported as a rejection and the cart is priced
without it.
Automatic purchase
With automatic purchase on, the cart is charged as soon as the wallet covers it. The decision is evaluated in a fixed order — automatic purchase off, then expired, then maintenance, then insufficient balance — so the reason a customer is shown is the first one that actually applies. Three things trigger an attempt:- A settled top-up, immediately after its transaction commits.
- The one-minute sweep, which retries up to 200 auto-purchase carts belonging to customers who hold wallet history in the cart’s currency. This is what makes a referral reward or an operator correction release a cart too.
- The customer tapping Buy now.
It cannot double-charge
The order is created under the cart’s own idempotency key, which is minted once when the cart is saved.orders (user_id, idempotency_key) is unique, so every path into the purchase — the
settlement hook, the sweep, and a customer tapping Buy now — resolves to the same single order
even if all three run at once. Marking the cart purchased is itself conditional on the cart still
being open, which closes the window where a second attempt could see it available.
Editing a cart mints a new key. That is deliberate: a changed plan is a different purchase and must
not resolve to the order the previous contents created. The partial unique index
carts (user_id) WHERE status = 'open' serialises the edit against an in-flight purchase, and the
CHECK status = 'purchased' ⟺ order_id IS NOT NULL means a cart can never claim to have bought
something it cannot name.
A cart’s add-ons are charged on the same order as the plan, so a cart is always exactly one order
and one payment however much it contains.
Refusals
Automatic purchase declines rather than fails, and always says why:
Only the first two need the customer to act. Each failure is recorded on the cart with an
incremented attempt count, and the cart stays saved in every case except an explicit clear.
Using a cart in Telegram
Saved carts are a Telegram feature; the web panel has no save control today.- Save appears on the payment screen when the balance is short, and saves with automatic purchase on.
- Cart shows the re-validated price, what is still missing, and the time left.
- Auto-purchase on/off toggles unattended charging without discarding the cart.
- Buy now charges it immediately.
- Clear discards it.
Shop carts never buy themselves
A saved digital-goods purchase is stored with automatic purchase off and is skipped by the sweep. A shop price is a provider quote that expires in minutes, so charging one unattended would charge a number the customer last saw days ago. The full reasoning is on Gifts and digital goods. A customer has one open cart, so saving a shop item replaces a saved plan and vice versa.What an operator configures
Top-up limits and the cart lifetime are environment variables, read at process start.
Startup refuses a configuration whose non-zero maximum is below the minimum, because that
combination can only ever reject every amount. The full variable reference is
Configuration.
The invariants behind both features
- A top-up credits exactly the amount the provider settled, exactly once, keyed on its order, and never spends wallet credit.
- A top-up is credited exactly when it carries a ledger transaction — a table CHECK, not a convention.
- A saved cart is re-priced against the current catalogue before any charge, and is charged under one idempotency key however many paths attempt it at once.
- One open cart per customer, and one open checkout per customer.