Skip to main content
Two things let a customer shape a subscription beyond picking a plan: choosing which squads it uses, and buying extra capacity part-way through a period. Both are authored on the plan version, both are validated on the server rather than in the interface, and both end up folded into a single entitlement so that Remnawave receives one desired state rather than a plan followed by a correction.

Plan-scoped squad sets

A plan version carries two squad collections:
  • remnawave_squad_ids — always assigned to every subscription on that plan;
  • plan_version_squads — the selectable set, each row carrying a Russian and an English label and a sort order.
squad_selection decides what the customer may do: An empty value behaves as automatic, and any value outside the three is refused outright rather than guessed at. The final squad set for the entitlement is the included squads plus the accepted selection, deduplicated and sorted. Sorting matters: two customers who pick the same squads in a different order must produce byte-identical entitlement state, otherwise drift detection would report a mismatch that is not real. A selection is validated against the plan on the server. Callback data naming a squad the plan does not offer is refused with squad_not_offered, so a crafted callback cannot attach a squad the customer never paid for. The configurator only appears when a plan actually offers a choice. A plan with automatic selection goes straight from the plan screen to payment, exactly as before.

The refusals in full

All five wrap one domain error and surface as HTTP 422 with the reason as the RFC 9457 type, which both customer surfaces translate into their own copy. The database backs the bounds up: min_selectable_squads at least 0, max_selectable_squads above 0 when set, a named cross-column check requiring the maximum to be at least the minimum, and labels of 1–80 characters in each language.

Choosing squads at checkout

Web. On /account/checkout, a Servers fieldset appears only when the plan reports the selection as configurable. Each checkbox sends PATCH /v1/account/checkout with the whole squadIds array, and the server re-prices and returns the entire checkout. The bounds the page shows are guidance only: the real check runs at order creation, because a second implementation of the same counting rule in React is exactly what would eventually let through a set the order then refuses. Telegram. Choosing a payment method opens the squad configurator first when the plan is configurable, and each squad: callback toggles one squad. GET /v1/account/plans/{planVersionID} returns the whole picture as squads with selection, minimum, maximum, configurable, and the offered list. The confirmed set is stored on the order in selected_squad_ids; if it is empty, settlement falls back to the plan version’s own set.
A choosable squad set cannot currently be authored from /admin. The plan version editor validates squadSelection against automatic, customer_choice, and fixed, while the plan_versions CHECK constraint allows automatic, optional, and required — the only three values the domain implements. Publishing anything but automatic from the panel fails at the database with HTTP 500 operation_failed, and optional or required sent to the panel is refused with 422 before it gets that far.Until the two lists agree, publish such a version by calling POST /v1/panel/catalog/plans/{planID}/versions directly with the database’s values. Catalog and promotions has the full version editor reference.

Add-ons

An add-on adds capacity to a subscription. It can be bought two ways:
  • With the plan, at checkout or in a saved cart. It is charged on the same order, so one confirmation is one payment, and it covers the whole first period at the catalog price.
  • Mid-period, against a live entitlement. It is charged on its own order, with operation = 'addon', and priced with its version’s proration rule against the time the period has left.
Three kinds exist: An add-on is offered only for plan versions that list it in plan_version_addons. Buying one against a plan that does not offer it is refused with addon_unavailable. That join is not only curation: the add-on identifier arrives in customer-controlled callback data or a URL path, so the join is the check that stops a customer attaching an add-on to a plan it was never priced for. An identifier that is not even a UUID is treated the same way rather than as a parse error.

Versioned pricing

Add-ons are versioned exactly like plans. The add-on row carries presentation and is mutable; the version carries what the customer gets and what it costs, and is not. addon_versions is immutable once an order references it, and order_addon_lines stores a snapshot of the version’s capacity, price, and proration rule at purchase time. Changing an add-on’s price creates a new version; it never rewrites a historical order.

Authoring an add-on

1

Open the catalogue

Go to /admin/catalog and switch to the Add-ons tab. The list shows the code, a kind badge, the current version as v{n}, and a Visible or Hidden badge. It is read-only — there is no edit or retire control on a row.
2

Describe it

Press New add-on. Fill Code, Kind, Name (EN), Name (RU), and the kind-dependent payload: Traffic in GB, Device slots, or a comma-separated list of squad IDs. Traffic is converted to bytes in the browser before it is sent.
3

Price it and bound it

Set Maximum quantity, the mid-period pricing rule, and Prices in the same RUB=19900, USD=299 form the plan editor uses. Flip Visible and type a reason.
4

Save

Submitting calls PUT /v1/panel/catalog/addons (catalog.write) and returns the add-on ID. Audit panel.addon.saved.
Saving an existing code upserts the add-on row — visibility and sort order only — and publishes a new version every time. There is no in-place edit, for the same reason plans have none: a historical order must keep costing what it cost. What the save refuses, all with 422:
  • A missing code, or a code that does not match ^[a-z][a-z0-9_-]{1,63}$;
  • A proration rule outside the three known ones;
  • A maximum quantity below 1;
  • No prices at all;
  • A traffic add-on with no traffic, a devices add-on with no slots, a squads add-on with no squads, or any other kind;
  • A name missing in either language. A missing translation is refused rather than stored blank, because an add-on with no name in one language is an empty row on the customer’s screen.
Retiring a version is POST /v1/panel/catalog/addon-versions/{addonVersionID}/retire (catalog.write, audit panel.addon_version.retired). Retiring one that is already retired matches no row and answers 404. There is no retire control in the panel either.
The add-on offer set on a plan version is populated only by the addonIds array on POST /v1/panel/catalog/plans/{planID}/versions, and publishing replaces that set rather than merging it. The panel’s version editor does not send the field, so every plan version published from /admin/catalog offers no add-ons at all. Populating it means calling the versions endpoint directly.
The offer check matches by add-on rather than by add-on version, so any live version of an offered add-on qualifies once the add-on is listed.

Buying add-ons with the plan

Web. /account/checkout renders an Add-ons section when the plan version offers any. Each entry is a toggle button; tapping it calls POST /v1/account/checkout/addons/{addonVersionID}, which toggles the line at quantity one and returns the whole re-priced checkout. The quote breakdown then reports addonMinor separately from subtotalMinor and discountMinor, because a customer told only what they owe cannot tell a promotion from an add-on, and the two have very different consequences if either is wrong. Telegram. The checkout screen grows an add-ons button when the plan offers any; the list toggles one entry per tap. Toggling an add-on the plan does not offer answers 404 plan_unavailable on the web and “that add-on is no longer available” in the bot. An archived add-on, a retired version, or one with no price in the order currency is refused with addon_unavailable.
A promo code discounts the plan price only. The subtotal is the plan price plus the add-on total, but the discount is computed against the plan price alone, so a percentage off never eats into add-on capacity.

Buying add-ons mid-period

Telegram. Subscriptions → pick one → Add-ons → tap an add-on. The purchase becomes its own order at quantity one; the wallet settles it when the balance covers it, and otherwise the ordinary payment screen appears. Web. Not available. There is no route under /v1/account for buying an add-on against a live subscription. The customer web panel can select add-ons inside a checkout and nowhere else. A mid-period purchase refuses with: Maintenance blocks the purchase entirely, like any other order. Reusing an idempotency key with different add-on parameters is refused rather than silently resolved to the earlier order. The same add-on twice in one order is refused outright.

Proration rules

The proration rule is a property of the add-on version, and it is always explicit. Omniflow never infers a partial price from remaining time unless the add-on says so. It applies only to a mid-period purchase; an add-on bought with the plan covers a full period and is charged at the catalog price whatever its rule says. Three properties hold for every rule, and each has its own unit test:
  1. A prorated charge never exceeds the catalog price.
  2. Rounding is always up, so a partial period never rounds to nothing.
  3. A purchase made before the period starts, or against a period with no remaining time, falls back to the full price. Charging zero for capacity that will still be delivered would give it away.
The intermediate multiplication is done in 128 bits. A long period and a large price would overflow a 64-bit product and produce a nonsensical charge.

Applying an add-on

Settlement folds the purchased capacity into the entitlement itself before the desired provisioning state is computed, so Remnawave receives one combined result rather than a plan followed by a correction:
An entitlement with an unlimited allowance stays unlimited: the update leaves a null traffic allowance or device limit null, because adding capacity to “no limit” is meaningless.Nothing on the customer surfaces prevents the purchase or warns about it, though. A traffic add-on offered on an unlimited plan is listed, priced, charged, and recorded, and grants nothing. Do not offer capacity add-ons on plan versions whose corresponding limit is unlimited.
Squads are a set and are never multiplied by quantity. Buying two of the same squad add-on charges twice and grants the same squads once, which is why max_quantity on a squad add-on should be 1. Every priced line is snapshotted onto order_addon_lines with the code, kind, version, traffic, slots, squads, proration rule, and period end, so an order explains itself years later without reading the catalogue.

Idempotent fulfillment

entitlement_addons is unique on (order_id, addon_version_id). A replayed settlement finds the row already present, skips it, and leaves the entitlement untouched. Only lines that were actually inserted contribute to the totals, so a partial replay cannot double-count. A mid-period add-on order enqueues its own fulfillment operation keyed order:<order id>:addon, carrying the set_limits operation for the combined result, and the order moves to fulfilled. An add-on bought with the plan needs no separate operation: it is already part of the entitlement the plan’s own create operation provisions. Fulfillment covers what happens to that operation next.

When there is nothing to attach to

If the target subscription has no live entitlement by the time an add-on settles, the money is not lost and nothing is rolled back. The order stays paid, an addon.no_active_entitlement audit event is written in the financial category with a failure outcome, and an operator notification of kind fulfillment_failure is queued, deduplicated on the order. Rolling back a recorded payment would be worse than leaving an operator a clear task — nothing here may roll back money that has already been taken.

API reference for this area

The add-on save and retire routes and the plan versions route are implemented but missing from api/openapi.yaml, so they are absent from the generated client and the published API reference. Integrating with the API explains how to call them anyway.

Catalog and promotions

Plans, immutable versions, prices, trials, and the promotion rules a discount obeys.

Concurrent subscriptions

The entitlement an add-on attaches to, and what happens when its period ends.

Fulfillment

How the combined desired state reaches Remnawave, and what a failed operation looks like.

Wallet top-ups and saved carts

Add-ons inside a saved cart, and why a cart is always re-priced before it charges.