A shop order is an ordinary order. The wallet, promo codes, saved carts,
payment providers, refunds, receipts, and history all work the same way they
do for a plan. What differs is a cost floor on discounts and the fact that a
saved shop purchase never buys itself — both explained under
Promotions and saved purchases.
Gifts
A gift is bought by one customer and redeemed by another. It may be a subscription, an add-on, or wallet credit.Whose history it appears in
The order belongs to the sender and is never copied into the recipient’s history.orders.user_id is the sender; the entitlement or ledger entry the
claim produces carries the recipient. That is what keeps a gift out of the
recipient’s payment history while still giving them what was bought.
Claim codes
A code is sixteen Crockford base-32 characters — noI, L, O, or U, so
nothing in it is visually ambiguous and no accidental word appears in it. Eighty
bits, combined with the attempt ceiling and the endpoint’s rate limit, makes
guessing one impractical.
Only the SHA-256 of the code is stored. A database read never yields a
redeemable code. The last four characters are kept separately as a hint, which
is enough for a sender to tell two of their own gifts apart and for support to
confirm which gift a conversation is about, and not enough to redeem one.
Input is normalised before lookup: separators are stripped and the three
Crockford substitutions (I/L to 1, O to 0) are applied, because those
are the characters a person copying by hand reliably confuses. Anything else is
rejected rather than guessed at.
Redemption rules
Redemption is exactly once by construction: the claim statement only matches a gift that is deliverable and unexpired, so a second claim of the same code updates no row. The refusal reasons are checked in a deliberate order:- Attempt ceiling. After ten failed presentations a code stops answering anything at all, rather than continuing to leak which failure it is. The count is durable state on the row, so it survives a restart and cannot be reset by waiting out a rate-limit window.
- Status. A gift whose payment has not settled answers “not yet” rather than a refusal. A claimed, revoked, refunded, or expired gift is refused.
- Expiry, even if the sweeper has not run yet.
- Self-claim. A sender cannot redeem their own gift: routing money through the gift path would otherwise reach a promotion or reward they are not eligible for.
- Named recipient. A gift addressed to a Telegram identifier is claimable only by that identifier.
Revocation and refunds
An operator may revoke a gift that is pending, deliverable, or expired. A claimed gift is deliberately not revocable: the recipient already holds what it bought, and taking that back is a refund decision made against their entitlement or ledger, with its own permission and its own record. Only a gift that was never redeemed is refundable to its sender. Refunding a claimed gift would mean the sender’s money back and the recipient’s entitlement intact, which is exactly the abuse this rule prevents. Revoking takes a row lock first, so a revoke and a claim arriving at the same moment serialise: whichever commits first wins and the other sees a state its guarded update refuses.Digital goods
A digital good is not VPN access. Nothing in this area creates, modifies, reads, or depends on a Remnawave entitlement, and a delivery failure here can never disturb a subscription.The gateway, and why Omniflow does not talk to Fragment
Fragment has no API. Buying there means driving an authenticated browser session through a stateful multi-step flow and settling the result with an on-chain TON transfer from a wallet derived from a mnemonic. Omniflow deliberately does not hold that wallet. A self-hosted product should not ship a configuration field that can spend real money, and a stateful scraping session is not something a background worker can share safely. Instead the operator runs a gateway that owns the Fragment session, the TON wallet, and the settlement, and Omniflow calls it over HTTP Basic as the fixedomniflow user.
The adapter interface stays small — supports, quote, deliver, poll, balance —
so a second gateway or a different goods provider is five methods, not a
rewrite of the shop.
What the gateway does and does not offer
Delivery is addressed by username. The
user_id the gateway also takes is
bookkeeping for its own customer records, so Omniflow sends the buyer’s
Telegram identifier — it does not know the numeric identifier behind an
arbitrary handle, and the handle is what actually receives the goods.
Pricing
What the customer pays is derived from what the provider charges: a markup in basis points, then a rounding rule, both operator-configured per product.
Every mode rounds up, and the markup itself rounds up rather than truncating. A
shop that loses a minor unit on every sale is a shop whose configured margin is
not the margin it earns.
Stars are quoted from the gateway’s published per-star value, rounded to the
minor unit per star and then multiplied — the same arithmetic the gateway
charges against.
Premium has no published cost. The operator sets the sale price directly with
fixed_amount_minor, and Omniflow records that the cost is unknown rather than
zero, so the finance view reports the margin as unknown instead of as a hundred
per cent.
Both sides of a quote are stored on the order: what the provider asked and what
the customer was shown. Their difference is the realised margin, which is why
the finance view can report it without recomputing a markup that may since have
changed. Because a gateway rate can move, a quote carries an expiry;
quote_ttl_seconds sets it, and a stale quote produces a new one rather than a
charge at the old number.
Recipients
The recipient is a Telegram username and nothing else. No display name, no numeric identifier, no profile data: delivery needs the username and support needs to be able to answer “where did it go”, and neither needs more. Input is normalised from the forms people actually paste — a bare handle, an@ prefix, a t.me/ link, a link with a query string — and validated against
Telegram’s own rule of five to thirty-two characters beginning with a letter.
The gateway exposes no pre-payment check, so a handle that is well-formed but
unreachable is caught at delivery and refunded.
Delivery and failure classification
One delivery row per order, enforced by the primary key. That constraint stops a replayed job, a duplicated webhook, or a retried worker from submitting a second time. It cannot undo a purchase that already happened, which is exactly why the ambiguous class below exists. A failure is classified, and the class decides what happens next:
Every class resolves in exactly one way, which is asserted by a unit test.
Retrying an ambiguous purchase could deliver and charge twice; refunding one
could give money back for goods the recipient received. Both answers can be
wrong in a way that costs somebody money, so the delivery stops and waits.
A server error from the gateway is ambiguous rather than retryable, because the
gateway settles on-chain and writes its own records afterwards — a 5xx can be
raised from code that runs after the goods were paid for. A lost connection is
ambiguous for the same reason.
Backoff doubles from a minute to a ceiling of an hour, and six attempts — a
little over two hours — is where a retryable failure becomes a refund. The
refund is an ordinary wallet credit, so it appears in the customer’s wallet
history exactly like any other.
Paying with wallet balance
A shop purchase is an ordinary order, so wallet-first payment applies to it unchanged: the balance covers what it can and the remainder becomes the external amount a provider settles. A customer with enough balance pays for Premium or Stars without a payment provider being involved at all.Spend controls
The provider record carries a low-balance threshold and a rolling spend ceiling measured in provider cost rather than customer price — the ceiling protects the operator’s funding source, and that is what the provider actually draws down. A zero ceiling means no ceiling. The gateway publishes no balance, so the low-balance alert has nothing to fire on for it today; an exhausted TON wallet instead surfaces as deliveries the gateway accepts and silently abandons. Exposing a balance endpoint is the single change that would fix that. Credentials — the gateway URL and its shared token — are sealed withAPP_DATA_ENCRYPTION_KEY, never returned by the API after they are written, and
excluded from diagnostics.
Promotions and saved purchases
A shop price is not a plan price, and three decisions follow from that.A promotion applies to plans or to goods, never both
An unscoped plan promotion means “every plan”. If digital goods had simply joined that set, every promotion an operator had ever written would have started discounting shop orders the moment they upgraded — which nobody agreed to, and which does not mean what it looks like: a plan price is a number the operator chose, and a shop price is a provider quote plus a markup. “Fifty percent off” written for plans is a different instruction against a product whose margin might be eight percent. So a promotion names its catalogue. Existing promotions default to plans and keep meaning exactly what they meant. A shop promotion is scoped to specific products or, left unscoped, applies to every visible one.A discount cannot take an order below what the provider charges
The provider still charges its cost whatever the customer pays, so a discount past the markup is the installation paying to make a sale. That is a legitimate thing to choose and an illegitimate thing to arrive at by accident. Markup is where an operator sets the price; a promotion is a discount off the price they set. A code that would breach cost is refused with that reason, not quietly capped — a percentage that silently becomes a different percentage is the mismatch that generates a support ticket. Two exceptions follow from the same reasoning:- A product sold at a fixed price the operator configured has no provider quote behind it. That number is theirs, exactly like a plan price, and it can be discounted to zero.
- A product whose provider will not say what it charges has no floor to check against, so it cannot be discounted at all. Discounting a number nobody can bound is how an installation sells under cost without knowing.
A saved shop purchase never buys itself
A saved plan charges unattended when a wallet top-up covers it. That is safe because the price it re-quotes against is the operator’s own and changes rarely. A shop price is a provider quote that expires in minutes. Charging one unattended would mean charging a number the customer last saw days ago, against a rate that has moved since. So a shop purchase is saved and the customer confirms it when they come back. What is shared is the property that matters: the price is re-quoted before any charge. A price that fell is applied without comment — nobody objects to paying less. A price that rose is refused, and the customer is shown both numbers. A customer has one open cart. Saving a shop item therefore replaces a saved plan rather than sitting beside it: keeping both would mean the auto-purchase sweep charging for something the customer believed they had replaced.Buying in the bot
Gifts
A sender chooses a subscription or an amount of wallet credit, optionally writes a note, and receives a claim code. The code exists in exactly that one message: only its SHA-256 is stored, so a database read — a backup, a dump, a compromised replica — never yields a redeemable code, and a sender who loses it cannot have it recovered. The stored hint is the last four characters, which is enough to tell two of your own gifts apart and not enough for anybody to redeem one. A gift becomes claimable when the sender’s order is paid, and the code is shown before then with a note saying so — a recipient trying early would otherwise be refused with no explanation. Every refusal renders the same message. A recipient learning the difference between “no such code”, “already claimed”, and “expired” would also be an attacker learning which codes exist. A gifted subscription extends what the recipient already has rather than replacing it, and is provisioned through the same fulfillment pipeline a purchase uses, in the claim’s own transaction. The gift cannot be marked claimed without the recipient actually getting something.The shop
The catalogue separates Premium from Stars, because one is answered with a duration and the other with a quantity. The price is quoted when the product is opened, not at confirmation, so the number on the screen is the number that will be charged and its expiry is stated. A quote that has expired by the time the customer confirms is re-taken rather than honoured. A product with a fixed price never touches the provider at all: the operator has already decided what the customer pays and absorbs the variance. The recipient review step is a separate screen. Digital goods cannot be recalled once a gateway has sent them, so a mistyped username is unrecoverable, and the handle is restated in full immediately above the button that spends the money. The recipient’s Telegram handle is never stored on the customer record. A handle can be changed or dropped at any time, and keeping a copy would mean holding a stale identifier for somebody who has moved on. It is stored on the shop order, where delivery and support genuinely need it.Delivery, polling, and the ambiguous outcome
goods_deliveries is the source of truth about what is owed, not the job queue.
A scheduler translates “this row is due” into “run the worker”, so a lost job, a
purged queue, or a restarted process costs a delay rather than a delivery.
A row that already carries a provider reference has been submitted and has not
finished. Asking again would be asking for a second purchase, so the worker
polls instead — that is what delayed delivery means in practice.
An adapter that cannot be polled leaves the outcome genuinely unknown, and
unknown is neither a failure to retry nor a success to record. The gateway that
fronts Fragment is exactly that case: it honours no idempotency key and exposes
no way to poll a submission. Such a delivery is classified ambiguous and parks
in the panel’s review queue until an operator confirms with the provider what
happened. Retrying could deliver twice; refunding could give money back for
goods the recipient received.