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.
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.
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.- 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
trafficadd-on with no traffic, adevicesadd-on with no slots, asquadsadd-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.
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.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:
- A prorated charge never exceeds the catalog price.
- Rounding is always up, so a partial period never rounds to nothing.
- 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.
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: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 stayspaid, 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.
Related pages
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.