> ## Documentation Index
> Fetch the complete documentation index at: https://omniflow-vpn.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Gifts and digital goods

> Buying a subscription for somebody else, and selling Telegram Premium and Stars alongside VPN access.

> [!NOTE]
>
> 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](#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 — no `I`, `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:

1. **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.
2. **Status.** A gift whose payment has not settled answers "not yet" rather
   than a refusal. A claimed, revoked, refunded, or expired gift is refused.
3. **Expiry**, even if the sweeper has not run yet.
4. **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.
5. **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 fixed
`omniflow` 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

| Capability | Status |
| - | - |
| Deliver Stars by username | `POST /admin/stars/send`, minimum 50 |
| Deliver Premium by username | `POST /admin/premium/send`, 3, 6, or 12 months |
| Star cost | `GET /admin/stars/value`, a per-star price in the gateway's currency |
| Premium cost | Not published — the operator sets the sale price in the panel |
| Idempotency key | **Not honoured** |
| Poll a submission | Not offered |
| Wallet balance | Not offered |
| Validate a recipient before payment | Not offered |

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.

> [!WARNING]
>
> **Two gateway limitations affect what Omniflow can promise.**
>
> There is no idempotency key, so a request whose answer never arrives is
> genuinely ambiguous: the goods may already have been sent and the operator's
> funds already spent.
>
> And a success is not proof of delivery. The gateway answers 200 for a purchase
> its own processor abandoned — insufficient TON, an expired Fragment session,
> and a rejected purchase are reported to an operator chat and then returned
> from normally. Until the gateway distinguishes those, an accepted submission
> means "the gateway took it".
>
> Both are fixed on the gateway side: honour an `Idempotency-Key` header, and
> raise on a failed purchase instead of returning. Until then, read "delivered"
> in the panel as "submitted and accepted".

### 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.

| Rounding | Effect |
| - | - |
| `none`, `up_minor` | The exact amount |
| `up_unit` | Up to a whole major unit |
| `up_ten_units` | Up to a multiple of ten major units |
| `up_hundred_units` | Up to a multiple of a hundred major units |

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:

| Class | Outcome |
| - | - |
| `retryable` | Retried on the backoff schedule |
| `provider_balance` | Retried, and raises an alert — the operator can top up |
| `provider_unavailable` | Retried; an outage or a rejected credential, not a rejection of the order |
| `recipient_invalid` | Refunded — nothing the worker does will make the username resolve |
| `permanent` | Refunded |
| `ambiguous` | **Neither.** Parked for an operator |

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 with
`APP_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.

The floor is enforced in the table as well as in the application, so a script or
a backfill cannot write what the checkout refuses.

> [!WARNING]
>
> The realised margin on a discounted order is `price − discount − cost`. The
> discount is stored on the shop order for exactly this reason: without it the
> finance view would report the full markup on a sale that earned less.

### 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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.