orders row. A subscription, a plan change, a wallet top-up, a
mid-period add-on, a gift, a digital-goods purchase and an unattended renewal all use the same
record, the same payment intents, the same webhook intake, the same reconciliation and the same
refund path. The alternative — a second money path for top-ups, a third for the shop — would be a
second and a third place to get idempotency wrong, and the migration comments say so where the
topup and goods operations were added.
What differs between operations is only what settlement produces: an entitlement, wallet credit, a
delivery row, or a claimable gift. Everything before that point is shared.
One record, nine operations
orders.operation names what is being bought. The set is a table CHECK, so an unknown operation
cannot be written at all.
renewal was split out of extension on purpose. Support answers “why was I charged?” from this
column, and the honest answer differs between a change a customer asked for and a charge that
happened while they were asleep. See Recurring payments.
The amounts on an order
The load-bearing constraint is
wallet_minor + external_minor = subtotal_minor - discount_minor.
It is a table CHECK rather than an application rule, so no script, backfill or future code path can
write an order whose parts do not add up. All amounts are integer minor units with an uppercase
three-letter currency; there is no floating point anywhere on the wire or in the schema.
The state machine
An order has eight states. The three questions the states keep apart are: has money been taken (paid), has the thing been
delivered (fulfilled), and has money been given back (partially_refunded, refunded).
Fulfilment is a separate state from payment because provisioning into Remnawave is a retryable,
asynchronous operation that can fail after the money has already arrived — see
Fulfillment.
draft exists in the schema and in the domain, and every creator in the codebase writes pending
or paid directly, so a customer will not normally see it.
The domain declares a map of allowed transitions, but nothing in production calls it. What
actually enforces the lifecycle is a set of guards spread across the statements that write each
transition, listed below. Do not read the diagram as a single gate that every write passes
through.
Cancelling is idempotent: the statement accepts
cancelled as a source state and the mutation row
is upserted on (order_id, action, idempotency_key), so a double-tap closes one order once.
A closed order can still be paid. A provider’s checkout outlives the order that opened it: a
CryptoBot invoice or a YooKassa page left open in another tab accepts money after the customer
pressed Cancel, and a manual transfer can be approved after the window. Money that arrives is
money the customer sent, so a payment that matches external_minor settles the order — the
entitlement is created and provisioning queued exactly as for a normal sale — but it is classified
apart: late on an expired order, paid_after_cancellation on a cancelled one. The second also
queues an operator notice, because the customer had been told nothing would be charged.
A late settlement re-checks what the order was checked against when it was opened, because the
world may have moved on while it sat closed. Three outcomes do not settle, each classified so an
operator can act on it:
stale_quote— a shop order whose gateway quote lapsed. Its window is the quote’s validity, and delivering at a rate that no longer holds can cost more than the customer paid.limit_exceeded— its promotion or trial was claimed by another order while it sat closed. See Catalog and promotions.subscription_unavailable— the subscription it opened was released while it sat closed, and the concurrency ceiling will not open another. See Subscriptions.
cancelled with a status_changed payment event.
The payment window
expires_at is set when the order is created and follows the payment provider:
A surface that knows the provider when it opens the order names it, and the window is right from
the start. A surface that lets the customer pick the provider afterwards opens the order with the
one-hour default, and creating the payment intent then extends the window to what the chosen
provider warrants. The extension only ever lengthens a window, only while the order is
pending,
and never touches a goods order, so a closed order is never reopened and a stale quote is never
paid — a goods payment that arrives after the quote lapsed is classified stale_quote and not
settled. Choosing a card provider after a manual one leaves the longer window in place.
What the customer sees: the payment phase
Neither customer surface shows the raw order state. The order state, the payment-intent status and the latest fulfillment-operation status are folded server-side into one phase, because only the server sees all three at once.
The order is consulted first and the intent only afterwards, which is what stops a late or
duplicated provider webhook from moving a paid order backwards on somebody’s screen.
- Web.
/account/orders/{id}renders a three-step progress bar — paid, provisioning, ready — driven only by the phase, so a reload or a second tab shows the same progress. - Telegram. The order screen switches on the same phase and offers a different keyboard per phase: the provider link and Cancel while pending, Connect once completed.
GET /v1/account/orders and GET /v1/account/orders/{orderID} return phase alongside state.
Ownership is part of the SQL (WHERE o.id = $3 AND o.user_id = $1), so an order identifier lifted
from a URL or from callback data cannot address somebody else’s order, and “not yours” is
indistinguishable from “does not exist”.
The customer order list deliberately excludes top-ups: it requires an
order_lines row, and a
top-up never has one. A top-up’s own order page is still reachable by its identifier, which is
what the wallet screen links to.Quoting a checkout
One pricing function serves both the preview and the order that follows it. The preview runs it inside a transaction that is always rolled back, so looking at a price can never redeem a promotion or reserve wallet credit; confirmation runs the same function inside the serializable transaction that writes the order, so the figures shown and the figures persisted cannot disagree. The steps, in order:- Resolve the plan version for the requested currency. A version that is archived, retired, or
carries no price in that currency yields
plan_unavailable. - Check the operation against the plan’s upgrade and downgrade policies.
- Read the available wallet balance — the total minus what the customer’s own
draftandpendingorders have already claimed. - Resolve the promotion, if a code was given, taking
FOR UPDATEon the promotion row so two customers racing for the last use of a limited code serialise instead of both succeeding. - Resolve the squad selection against the plan version’s selection policy.
- Price any add-ons at full catalogue price, because an add-on bought with the plan covers the whole first period.
- Split what is due into a wallet part and an external part.
POST /v1/account/checkout/promo answers 200
with a promoRejection field rather than an error, and the quote is recomputed without the code, so
the customer can try another one without losing their basket. The stable reasons are
promo_unknown, promo_ineligible, promo_exhausted, promo_invalid, and — for the shop —
promo_below_cost. See Catalogue and promotions.
On the bearer-token order route, promo attempts are rate-limited to ten per minute per customer
through Valkey. If the limiter itself is unreachable the request answers 503, because failing
open on a promotion limiter is how a code gets brute-forced.
Wallet credit is applied before external payment
paid and settles inside the
creating transaction. Such an order is offered no payment provider at all: asking to start a payment
on it answers 409 payment_not_required.
A checkout carries an apply wallet toggle on both surfaces (PATCH /v1/account/checkout with
{"applyWallet": false} in the browser, the wallet toggle button in the bot) for the customer who
would rather keep their balance for later.
At settlement the balance is re-read. If it has fallen below the order’s wallet_minor since
confirmation, the payment is classified wallet_unavailable and the order does not settle. See
When a payment does not settle for what that leaves behind.
Wallet mechanics in full are on Wallet and ledger.
The checkout session
A customer has at most one open checkout, shared by the browser and the chat — a partial unique index on the customer enforces it, so opening a checkout in the browser supersedes one left open in Telegram rather than running beside it. The session carries the plan version, the operation, the currency, the chosen provider, the promo code and its rejection, the wallet toggle, the subscription target, the selected squads, and one idempotency key minted when the checkout opens and reused for every confirmation attempt. That is what makes a second tap, a double-submitted form, or a retry after a dropped connection resolve to the order that already exists.1
Open the checkout
From
/account/store, choose a plan and press Buy. This is POST /v1/account/checkout with
{planVersionId, operation, subscriptionId?, newSubscription?}.2
Adjust it
/account/checkout shows the breakdown. Every edit is PATCH /v1/account/checkout carrying any
of provider, currency, applyWallet, subscriptionId, newSubscription, squadIds — each
a JSON pointer, so “omitted” and “cleared” are different requests. Add-ons toggle at
POST /v1/account/checkout/addons/{addonVersionID}; promo codes at
POST/DELETE /v1/account/checkout/promo.3
Confirm
Confirm posts to
POST /v1/account/checkout/confirm with an Idempotency-Key header and
answers 201 with the order. Nothing is charged here.4
Pay
Pay posts to
POST /v1/account/orders/{orderID}/payment with {"provider": "..."} and an
Idempotency-Key header, and returns the handoff.- Every edit statement requires the checkout to have no order yet, so an edit arriving after
confirmation is reported as
409 checkout_settledrather than as a missing checkout. - A lifecycle change with more than one subscription and no named target is refused with
422 subscription_target_required. The panel must ask rather than let the server guess. - A currency the chosen adapter cannot settle is refused at selection time.
- A checkout expires an hour after it opens; reading an expired one deletes it, so the customer restarts from a fresh quote rather than a stale price.
- In Telegram, required-channel membership is re-checked at the moment of confirmation, because a customer can leave a channel between browsing and paying.
Payment intents
Apayment_intents row is one attempt to settle one order through one provider. It stores the
provider, a status (pending, requires_action, processing, succeeded, failed, cancelled,
expired), the amount and currency, the provider’s own reference, the checkout URL, the caller’s
idempotency key, a snapshot of the adapter’s declared capabilities, and non-secret
receipt_metadata — which is where regional fiscalization fields live, so an adapter can satisfy
them without leaking provider fields into the order model.
Creating one:
- The idempotency key is validated (8 to 128 characters).
- An unregistered provider is refused with “payment provider is not enabled”.
- A PostgreSQL advisory lock keyed on
provider + ":" + keyserialises concurrent attempts. - The order must be
pendingand owe an external amount, otherwise “order does not require an external payment”. - An existing intent under the same key with a different order, amount or currency is refused with “idempotency key was already used with different payment parameters”; the same parameters return the intent unchanged.
- If the adapter can recover an invoice by merchant reference (CryptoBot can), Omniflow adopts the invoice it already created rather than creating a second one.
- Otherwise the adapter is asked to create the payment, and its status, reference and checkout URL are stored.
Scoping the customer key to the order and the provider is deliberate: the order’s own key would
not distinguish a customer who abandoned one method and came back with another, and reusing it would
bind the second attempt to the first adapter’s intent.
The response carries a handoff kind, and each one renders a different sentence because each means
something different to the person reading it:
The provider contract
Every adapter implements one interface: name, capabilities, create, poll, refund, verify webhook. Four optional interfaces refine it — charge a saved method, recover an invoice by merchant reference, restrict the settlement currencies, and probe the credentials without moving money. Capabilities are{refunds, partialRefund, recurring, webhooks, polling} and are snapshotted onto
every intent the adapter creates, so a capability that changes later cannot rewrite the history of
what an intent was.
manual and telegram_stars are always registered. cryptobot is registered when
APP_CRYPTOBOT_TOKEN is set; yookassa when its shop ID and secret are both set. An empty
credential leaves its adapter unregistered rather than registering a broken one.
A method is offered to a customer only when the installation configured it and the plan carries
a price in a currency that method settles. The display order is a fixed map — Telegram Stars,
YooKassa, CryptoBot, then manual.
Per-provider setup, credentials and webhook URLs are on Payment
providers.
Webhook intake and idempotency
One public, unauthenticated route takes provider callbacks:POST /v1/payments/webhooks/{provider}. Authenticity is the adapter’s signature check, not a
credential on the route, and the body is capped at 64 KiB (a larger one answers 413) — real
notifications are a few kilobytes, and the route is open to anyone.
- The named adapter must be registered and declare webhook support. This is what closes the
public route for
telegram_starsandmanual— Telegram does not sign Bot API updates for a public HTTP route, so accepting a “Stars webhook” there would be accepting unauthenticated money. - The body is hashed.
- The adapter verifies the signature. A failure still writes a row, with
signature_valid = false, an event ID derived from the body digest, and an empty body — the digest identifies repeated garbage, and storing whatever a stranger posts would hand them the database’s disk — incrementsomniflow_provider_webhooks_total{outcome="signature_invalid"}, and answers400. - The event is inserted into
provider_webhook_eventson(provider, provider_event_id). If the stored row already readsprocessedorignored, the request returnsduplicateand stops. This is the deduplication guard. - For YooKassa only, the referenced payment is re-fetched over the authenticated API and the
polled status and amount replace the notification’s — YooKassa does not sign its callbacks, so
authenticity is established by asking the provider directly. The re-fetch is made only for a
payment intent this installation opened: a notification naming an unknown payment completes as
failedwithpayment_not_foundwithout calling YooKassa, so a stranger posting well-formed bodies cannot make the API spend provider requests. A failed re-fetch completes the row asfailedwithreconciliation_failedand applies nothing. - A verified event that is not a success is completed as
ignored, not as a failure. - The intent is found by provider reference, falling back to the order carried in the merchant
reference. No match completes the row as
failedwithpayment_not_found. - The payment is applied through the shared settlement path, and the row is completed as
processedcarrying the classification.
content-type
and user-agent only. Retaining an authorization or signature header would put a provider
credential in the database; retaining the body with a digest is what makes a dispute answerable and
a replay safe. Rows are deleted 30 days after intake by the retention sweep.
Operators can list recent events at GET /v1/panel/system/webhooks (system.read).
Payment classification
Every settled provider payment is classified against the order, and every classification is written topayment_events whether or not the order settles. “Something is wrong with this payment” is not
one condition, and the vocabulary lets a support operator read what happened instead of inferring it
from two status values.
A replay is not a duplicate. One charge is routinely reported more than once: an automatic
renewal’s saved-method charge settles synchronously and the provider then sends
payment.succeeded
for the same payment, and a webhook can arrive after the reconciler has already polled the success.
When the intent is already succeeded and its order already settled, and the amount matches the
intent, the report is recorded as replay and changes nothing. A duplicate is reserved for money
reaching a settled order through a different intent, which is a second charge an operator has to
refund.
payment_events.type is a closed set, and the mapping renames paid to status_changed,
currency_mismatch to amount_mismatch, replay to reconciled, and paid_after_cancellation to late; every event’s
details carries the classification and the order’s state before the payment, so the timeline still
distinguishes a late payment from one that landed on a cancelled order. The row is unique on
(payment_intent_id, provider_event_id, type) with nulls not distinct, so the same provider event
cannot append the same event twice.
Top-ups are the exception. A top-up owes nothing beyond the money itself, so an over- or
underpayment on operation = 'topup' is forced to paid and the wallet is credited with exactly
what arrived. The mismatch is still recorded as a payment event and still reaches the operator
top-up notice.
When a payment does not settle
A non-settling classification still commits. The payment event is durable and the order keeps its state, so nothing is silently dropped. Every one of them —duplicate, underpayment, overpayment, currency_mismatch, and
wallet_unavailable — also queues an operator notice in the same transaction, under the
incident kind, carrying the order, the provider, the classification, and the amount class. It is
deduplicated per payment intent and classification, so a replayed webhook adds nothing. The same
notice is queued for paid_after_cancellation, which does settle. See
Operator notifications.
Settlement: what a paid order produces
Settlement runs inside the transaction that recorded the payment, and branches on the operation.
The subscription path, in order:
- The double-entry revenue movement.
- The entitlement schedule is computed from the operation and the plan’s policies: a purchase
starts now; an extension or renewal ends at
max(now, current end) + duration; an upgrade either replaces or stacks; a downgrade takes effect immediately or at the current end. - The entitlement is created with the squad set the customer confirmed, falling back to the plan version’s own set.
- Add-on lines are applied, idempotent on the order and add-on version.
- A fulfillment operation is created with the key
order:{orderID}:fulfilland the desired Remnawave state. - An
outbox_eventsrow with topicorder.paidis written. - An operator notice is queued, deduplicated on the order.
- Referral rewards are granted, in this same transaction, so a retried webhook can never credit twice.
The
outbox_events row is written but never marked published — no process in the repository
sets published_at today. Treat the table as an internal record of what settled, not as an event
feed you can subscribe to.- A mid-period add-on whose subscription disappeared between purchase and settlement does not
roll the money back. The order stays paid, an audit event
addon.no_active_entitlementis written with categoryfinancialand outcomefailure, and an operator notice is queued. Refunding automatically would be deciding on the customer’s behalf what they wanted. - A saved cart released by a settled top-up is charged after the commit, because a cart failure must never roll back a payment that was received. See Wallet top-up and saved carts.
Manual payments
Themanual adapter has no external system: it returns an intent in requires_action whose
provider reference is the order ID, and settlement happens when an operator approves it. It exists
for bank transfers, cash, and any rail Omniflow does not integrate, and the approval is audited so
the money is attributable to a named operator with a reason.
manual handoff and is told an operator will confirm it. The
order stays payable for seventy-two hours from the moment the manual intent is created — see
the payment window — and an approval after even that window still settles it,
classified late.
Refunds
Payment history is never rewritten. A refund is a newrefunds row plus a new balanced ledger
transaction, so the record of what was charged survives intact.
The working path, step by step:
- Refuse without a valid idempotency key and a non-empty reason.
- Take the advisory lock
refund:{paymentIntentID}, so refund mutations on one intent serialise. - Refuse unless the intent is
succeeded, the currency matches, and the amount is positive and no greater than the intent. - Refuse unless the adapter is registered, declares refund support, and the intent has a provider reference. CryptoBot therefore cannot be refunded at all.
- Sum the pending, processing and succeeded refunds already on the intent and refuse if this one would take the total past the intent amount — before the provider is called, because a provider that accepts an over-refund is a provider you then have to argue with.
- Write the
refundsrow first —pending, with no provider reference, unique on intent and idempotency key — so the refund is on record, and its amount reserved, before any request leaves. - Call the adapter under the refund’s idempotency key and write its answer onto the row: the status and the provider’s reference. Only if the provider reported success are the ledger movement and the new order state recorded.
- A definite refusal on the first request — the provider answered
4xxother than408,409or429, or the adapter cannot refund — marks the rowfailed, which frees the amount it reserved. - No answer — a timeout, an outage, a
5xx, or a crash — leaves itpendingwith no provider reference. Repeating the refund request with the same idempotency key asks the provider again, and so does the background reconciler, every minute, for any such row between one minute and a day old. The provider recognises the key, so asking again cannot refund twice. - A revival that still cannot get an answer queues one
incidentnotice with statusrefund_outcome_unknown, deduplicated per refund, so an operator checks the outcome at the provider. After a day — how long a provider holds an idempotency key — the reconciler stops asking.
refunded when the refunded total reaches
paid_minor, and partially_refunded otherwise, and an operator notice is queued.
What a refund takes back
Money given back must not leave what it bought in place, so the same transaction that records the refund reverses it:- A full refund of a subscription order (
purchase,extension,renewal,upgrade,downgradereachingrefunded) takes the period it bought off the entitlement it created. Every schedule adds the plan’s duration to a base, so the end moves back by exactly that duration: a refunded extension falls back to the end the customer had before it, and a refunded purchase ends now. Time already used is never clawed back, and a paused entitlement ends at its pause instant. Areconcileoperation keyedrefund:{refundID}:revokepushes the shorter end to Remnawave, which then expires the user at that end plus the plan’s grace period. An entitlement that has not started yet — anat_expirydowngrade — or that was never provisioned is retired assupersededinstead, and itscreateis cancelled when it runs. An entitlement a later change already superseded is left alone, because the refunded period now sits under that change; the refund notice says so. - A refund of a wallet top-up debits the refunded amount from the wallet (ledger type
debit, keyrefund-debit:{refundID}), because that credit is the money that went back. When the available balance no longer covers it — the customer spent it, or an unpaid order has it reserved — nothing is debited, since the ledger never goes negative, and anincidentnotice with statustopup_refund_wallet_shortis queued for an operator to resolve. - A partial refund of a subscription order changes nothing but the money: it is usually a goodwill gesture, and whether access should change is the operator’s decision. An order paid partly from the wallet can only ever be partially refunded through the provider, so it is in the same position.
access is revoked, retired or kept, and
wallet is debited or short.
Provider-specific limits:
- Telegram Stars refunds the whole charge or nothing, and needs the paying Telegram account, which is recorded only when the payment was started from a surface that knows it. A Stars payment started in the web panel carries no Telegram identifier and cannot be refunded through the adapter.
- CryptoBot has no refund API.
- YooKassa supports full and partial refunds.
- Manual returns a pending refund record; the money moves however the operator sent it.
GET /v1/account/orders/{orderID} includes up to ten refunds
against the order’s intents. Cancelling is the only order mutation a customer has, and it is refused
past pending with 409 order_not_cancellable.
Reconciliation and stuck payments
A webhook can be lost, delayed, or blocked by a firewall, so Omniflow also asks providers what happened. Because reconciliation applies its answer through the same idempotent settlement path, it can run beside a webhook without double-crediting.
The reconciler picks intents that are
pending or processing, belong to cryptobot or
yookassa, carry a provider reference, and have not been touched for a minute. Reconciling refuses
unless the adapter declares polling; if the remote status is a success the payment is recorded with
the event ID reconcile:{providerReference}, and the intent’s status, reference and checkout URL
are updated either way.
The customer Refresh button only acts while the phase is pending or awaiting_action, and it
swallows an unsupported-adapter error. For Telegram Stars, which cannot be polled at all, Refresh
therefore does nothing — the payment settles from the bot’s own update stream or not at all.
/admin/finance → Stuck lists intents in flight for more than two hours, with their age.
What an operator sees
/admin/finance has three tabs, because they are the same question at three stages: money
expected, money stuck, money that did not arrive.
- Orders. Filter by state and operation, cursor-paginated, with a CSV export. Columns: created
at, customer, operation, state badge, paid, refunded. A row opens the order.
GET /v1/panel/finance/orders— permissionfinance.read. - Stuck. The reconciliation queue described above.
- Charges. Failed automatic renewal attempts with attempt number, funding source, outcome and failure code. It is a review surface only: nothing re-triggers a charge from here, because the customer has already been told to renew and charging them again from a panel click would be an attempt they did not ask for.
paidMinor − refundedMinor and requiring a reason — subject to the two warnings above. The raw
provider payload is deliberately absent: it is retained for replay and dispute and reachable through
the webhook diagnostics under its own permission.
Panel mutations carry their justification in the X-Operator-Reason header rather than in each
body, and reads require finance.read while reconcile and refund require finance.write. The
permissions are declared where the routes are mounted, not inside the handlers, so an added early
return cannot skip them.
renewal is missing from the finance browser’s operation filter, so renewal orders cannot be
filtered by operation in /admin/finance even though the column carries the value. They still
appear unfiltered and in the CSV export.orders.csv with a stable column order — a later
version appends a column and never inserts one:
Metrics
Two counters cover money, and both label only bounded values — an identifier never becomes a label:outcome is either signature_invalid or the settlement classification, which makes a run of
rejected signatures and a run of duplicate deliveries two different lines on the same graph. See
Observability.