Several parts of this area have no control in
/admin and are reachable only
through the API. Each one says so where it appears, and
What the admin panel cannot do collects them
in one place.Plans
A plan row carries no price and no duration. It carriescode, kind, visible, sort_order, archived_at, and max_concurrent_per_customer.
code must match ^[a-z][a-z0-9_-]{1,63}$ and is globally unique. kind is one of trial, one_time, recurring, free, or manual — but only trial changes behaviour anywhere in the code. The other four are stored, validated, and rendered as labels; nothing branches on them.
Creating a plan
There is no create control in/admin/catalog. The only creation path is the operator-token API:
code, so re-posting an existing code returns the existing plan rather than conflicting. A locale other than ru or en is refused with 422 plan_rejected.
Editing a plan
Open/admin/catalog and stay on the Plans tab. The table lists every plan, archived ones included, with its code, kind, current version as v{n}, the number of order lines that reference it, the max-per-customer cap, and a Visible switch.
Flipping Visible issues PATCH /v1/panel/catalog/plans/{planID} (catalog.write) carrying visible, sortOrder, and maxConcurrentPerCustomer together, in one transaction. They travel together so two operators editing one plan cannot each drop the other’s field. The panel displays the cap but has no input for it: the current value is echoed back unchanged when the switch is flipped. Changing it means calling the endpoint directly.
maxConcurrentPerCustomer below 1 is refused with 422. The switch is disabled for archived plans and for operators without catalog.write.
The order-line count sits next to the plan for a reason: archiving a plan that historical orders reference is safe because versions are immutable, but it is still a decision worth making while looking at how many orders there are.
Immutable plan versions
There is no update route for a plan version, and that is deliberate. The comment on the endpoint states it plainly: a version is immutable once an order references it, so an editor that could change one would silently re-price history. Changing a price means publishing version n+1 and letting version n keep pricing the orders that already used it. The guarantee is doubled at the order. An order line storesplan_version_id and a JSON snapshot of the version’s period, duration, traffic, device limit, squads, and the three lifecycle policies as they were at purchase. Even if a version row were altered by hand, the order carries its own copy.
Publishing a version
1
Open the plan
Go to
/admin/catalog, find the plan, and press New version. The editor
opens inline, pre-filled from the current version, because a price change is
usually the only difference between one version and the next.2
Fill the version
Every field below is part of the contract the customer buys.
3
Give a reason and publish
The form requires a reason, sent as
X-Operator-Reason. Pressing Publish
version calls POST /v1/panel/catalog/plans/{planID}/versions
(catalog.write) and returns the created version. The screen states the
consequence: existing orders keep the version they were bought at, and
publishing does not change any price a customer has already paid.
Nulls and zeroes mean different things here, and the code says so twice: an absent traffic allowance or device limit is unlimited, while a zero would mean a plan that permits nothing. A panel that had to guess which a zero meant would eventually advertise the wrong one, so a zero is refused instead.
The grace period exists so that expiry copy is not guesswork: it keeps access alive after
ends_at so a customer can be told exactly how long they have left to renew. See Subscriptions for how it is evaluated.
What publishing refuses
- A version with no prices at all is refused. A version nobody can buy is not a version.
durationSecondsof zero or less, and a negativegracePeriodSeconds, are refused.- A present
trafficAllowanceBytesordeviceLimitmust be greater than zero. - Version numbers are allocated under a transaction-scoped advisory lock on the plan, so two operators pressing Publish version at the same moment cannot both mint version 4. The database also holds
UNIQUE (plan_id, version).
Two editor fields the database rejects
The panel validatessquadSelection against automatic, customer_choice, fixed and trialEligibility against new_customer, never, any_customer, and the web editor offers exactly those values. The plan_versions CHECK constraints allow automatic, optional, required and new_customer, never_trialled, any.
The database also allows none and half_year billing periods and a forbid cancellation policy; the panel validator does not, so those three are unreachable from /admin.
Add-ons offered by a version
plan_version_addons decides which add-ons a plan version offers, and it is populated only by the addonIds array on POST /v1/panel/catalog/plans/{planID}/versions. Publishing replaces the set rather than merging it, so it is exactly what the operator chose rather than the union of every edit.
The panel’s version editor does not send addonIds. Because publishing clears the set first, every plan version published through /admin/catalog offers no add-ons. Populating the offer set means calling the endpoint directly. Add-ons and the subscription configurator covers what the join protects against.
Prices
plan_prices is one row per (plan_version_id, currency) holding an integer amount_minor. There is no decimal anywhere in Omniflow: money is always minor units — kopeks, cents, Stars — and a currency is validated against ^[A-Z]{3}$.
The version editor’s Prices field is free text: RUB=49900, USD=599. Decimals are rejected outright, because “4.99” is ambiguous about which minor unit it means.
A version may be priced in as many currencies as an operator wants, and it usually needs more than one, because the chosen payment provider fixes the order currency. The checkout shows the price beside each payment method for that reason: the same plan can cost a different number through a different provider, and hiding that until the provider’s own page would be a nasty surprise.
At order time, the lookup joins on the requested currency. No row means ErrPlanUnavailable and HTTP 404 plan_unavailable.
APP_DEFAULT_CURRENCY (default RUB) is the settlement currency both customer surfaces browse in; use XTR for a Telegram-Stars-only installation. It is validated at boot. See Configuration.
Localisation
plan_localizations holds (plan_id, locale, name, description) with locale restricted to ru and en. name is 1–120 characters, description at most 2000.
The customer catalogue joins localizations rather than left-joining them, so a plan with no copy in a locale vanishes for customers reading in that locale rather than rendering blank. The same rule is enforced explicitly on the add-on save path: 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.
Copy is written with PUT /v1/panel/catalog/plans/{planID}/localizations/{locale} (catalog.write), body name and description, answering 204. A locale other than ru or en answers 422. There is no localization editor in the panel — the endpoint and the localizations map on POST /v1/admin/plans are the only two ways in.
On the customer web panel an explicit ?locale= query wins over the customer’s stored preference, so a language switch takes effect before the profile is saved.
Publishing, hiding, archiving
Three controls with three different meanings:
Archiving hides the plan and stops new purchases from the catalogue. It deliberately does not touch existing entitlements: a customer who bought a plan keeps what they bought, and their order keeps the immutable version snapshot it was priced against.
Archiving is idempotent and reversible —
POST /v1/panel/catalog/plans/{planID}/archive (catalog.write) with archived true or false, answering 204. Restoring clears archived_at but does not set visible back to true, so a restored plan stays hidden until an operator says otherwise. The panel renders an “Archived” badge and hides New version for archived plans, but offers no control to set or clear the flag; the endpoint is the only route.
Audit actions: panel.plan.updated, panel.plan.archived, panel.plan.restored, all in the configuration category.
Both customer catalogue reads filter on visible AND archived_at IS NULL. The single-plan read used to open a checkout filters only on archived_at, so a hidden-but-unarchived plan stays purchasable by direct plan-version link.
Retiring a version
POST /v1/panel/catalog/plan-versions/{planVersionID}/retire (catalog.write, 204, audit panel.plan_version.retired) stamps retired_at. A retired version stops being the one the catalogue offers without archiving the plan. Retiring an already-retired version matches no row and answers 409 state_conflict.
The catalogue picks the highest-numbered version with retired_at IS NULL, so retiring the newest falls back to the previous one. Publishing a new version does not retire the old one; the newest simply wins for browsing while older versions remain orderable by ID.
There is no retire control in the panel.
Trials and trial abuse controls
A trial is a plan withkind = 'trial' plus a per-version trial_eligibility rule. Claiming one writes a row into trial_claims, whose primary key is the customer — one trial per customer per installation, permanently, and no second trial for a second subscription.
A free plan anyone can take repeatedly is a free VPN, so the guards are layered and each one names a different abuse:
The rules, as stored:
new_customer (the default), never_trialled, and any. APP_TRIAL_MINIMUM_ACCOUNT_AGE_HOURS (default 0) supplies the minimum account age, and the API, the bot, and the worker all read the same variable — a trial that a chat refuses and a browser grants would be an abuse control with a hole in it.
What a customer sees. An ineligible trial plan is still listed, with the reason rendered underneath it in their language, in /account/store and in the bot’s plan list. Hiding the plan would leave a customer wondering where the free month went. GET /v1/account/plans carries eligible and ineligibleReason per plan for exactly that.
Eligibility is checked twice. The catalogue check is a courtesy; refusing at confirmation is the guarantee. Confirming an ineligible trial answers 422 with a type of trial_ followed by the reason.
The claim itself is written inside the order’s transaction with ON CONFLICT (user_id) DO NOTHING. Zero rows affected rolls the whole order back and answers 409 trial_already_used, so a duplicate tap or a concurrent request can never mint a second trial.
The per-plan concurrency cap
plans.max_concurrent_per_customer bounds how many active subscriptions of that plan one customer may hold. It narrows the installation-wide ceiling and can never widen it: both are checked at purchase time, so a plan configured above the installation limit is bounded by it rather than overriding it.
The checks run in a fixed order after a transaction-scoped advisory lock on the customer, so two concurrent purchases cannot both observe a stale total:
multi_subscription_disabled— concurrency is off and the customer already has one.subscription_limit_reached— concurrency is on and the installation ceiling is full.plan_limit_reached— the plan’s own cap is full.
APP_MULTI_SUBSCRIPTION_ENABLED and APP_MAX_SUBSCRIPTIONS_PER_CUSTOMER; Concurrent subscriptions covers them.
Squad sets on a version
A version carries two collections:plan_versions.remnawave_squad_ids, always assigned, and plan_version_squads, the selectable set with a Russian and an English label per row. squad_selection decides whether the customer chooses at all.
The resolved set is deduplicated and sorted, so two customers who pick the same squads in a different order produce byte-identical entitlement state. Without that, drift reconciliation would report a mismatch that is not real. Selection is validated server-side against the plan, because the identifiers arrive in customer-controlled callback data and request bodies.
The customer-facing half of this — the checkout fieldset, the bot configurator, and what each refusal looks like — is on Add-ons and the subscription configurator. Note the panel caveat above: a choosable selection cannot currently be authored from
/admin.
Promotions
Apromotions row is the campaign, not the redeemable code. It carries a lowercase code of its own, a reward kind and value, an optional currency, an optional validity window, an optional global redemption limit, a per-customer limit, a JSON eligibility rule, an active flag, stackable, precedence, and applies_to. Grouping the reward kinds under one record is what lets eligibility, redemption limits, and the audit trail be written once rather than once per kind.
Reward kinds
promotions_kind_known allows percent, fixed, wallet_credit, days, and trial.
Percent arithmetic is
subtotal × value ÷ 10000 with integer truncation, so rounding falls in the customer’s favour. A fixed discount is min(value, subtotal), so it can never exceed the price; the order layer refuses a discount above the subtotal and the database enforces discount_minor <= subtotal_minor as well.
Eligibility
Theeligibility JSON object is evaluated in SQL. Three keys are understood, and an absent key imposes no constraint:
The same query advertises a campaign on the plan page and accepts a code at redemption, so a campaign advertised as available is one the order would accept.
Creating and editing
Creation is the operator-token API only:code upserts, so re-posting an existing code attaches to the existing promotion.
/admin/catalog → Promotions is a read-mostly table: code, the reward rendered in its own unit, a Stacks/Exclusive badge with the precedence number, redemptions used against the limit, the total discount given, and an Active switch. Flipping it issues PATCH /v1/panel/catalog/promotions/{promotionID} (catalog.write, 204, audit panel.promotion.updated, category marketing) re-sending every editable field.
Kind is deliberately absent from the update payload. Changing a live promotion from a percentage discount into a wallet credit would silently change what every unredeemed code is worth; a different reward is a different promotion.
The service refuses value of zero or less, a perCustomerLimit below 1, and an end that is not after the start, all with 422. The database adds: value > 0; percent implies value <= 10000; fixed and wallet_credit imply a currency; days and trial imply value <= 3650, because ten years of granted days is far beyond any legitimate promotion and is the shape a mistyped value takes.
A promotion’s plan scope is write-once. It is set at creation and there is no endpoint, screen, or query that reads it back or changes it afterwards.
Promo codes
promo_codes are the redeemable bearer values under a promotion. Several codes may point at one promotion, each with its own optional redemption limit and active flag, which is what lets an operator hand a different code to a different audience while keeping one set of rules.
The plan page lists a promotion’s existence but never its codes. A code is a bearer value handed to a particular audience; publishing every code on the plan page would hand all of them to everybody.
Normalisation
NormalizePromoCode trims, uppercases, and requires ^[A-Z0-9_-]{3,64}$. Anything else is promo_invalid before any database read happens. promo_codes.normalized_code is globally unique, and creating a code that already exists attaches to whichever promotion already owns it.
Applying one
Customer, web. On/account/checkout, type the code into the Promo code field and press Apply, which calls POST /v1/account/checkout/promo. An accepted code renders as a chip with a Remove button (DELETE /v1/account/checkout/promo). A refusal comes back as HTTP 200 carrying a reason, not as an error, and is rendered as a warning under the field, so the checkout survives it and the customer can try another code without losing what they had already configured.
Customer, Telegram. On the checkout screen, press Promo code and send the code as one message. The bot answers with a confirmation or the refusal reason and re-renders the checkout.
Operator. There is no promo-code screen. GET /v1/panel/catalog/promotions/{promotionID}/codes (catalog.read) returns each code with its redemption count, and POST /v1/panel/catalog/promo-codes/{promoCodeID}/status (catalog.write) with active enables or disables one. Disabling stops new redemptions and leaves every completed one alone. A code is never deleted: the redemptions that reference it are financial records.
Every refusal a customer can be shown
promo_below_cost is deliberately its own reason: an ineligible code is the wrong code, and this one is the right code on the wrong product.
Both customer surfaces translate these values themselves rather than showing the server’s prose, and both fall back to an honest “the code was refused” sentence for a reason a newer server sends that the build has never heard of.
A code that stops being valid mid-checkout does not break the screen. The quote is recomputed without the code and reports the rejection alongside it.
Atomic redemption
Redemption is not a separate step. It happens inside the order’s own transaction:- Order creation runs at
Serializable. - The code is selected joined to its promotion
FOR UPDATE, filtering on both being active. Two customers redeeming the last use of a limited code serialise rather than both succeeding. - One query returns three counters: the promotion’s total, this customer’s, and this code’s.
- Plan scope, then customer eligibility, are checked.
- The limits are compared and the discount is priced.
- The redemption row is written in the same transaction as the order.
promo_redemptions.order_id is UNIQUE, so at most one promotion is redeemable per order.
Previewing a price runs the same reads at ReadCommitted and then rolls back, so a preview can never consume a redemption or reserve wallet credit. That is what makes the price a customer confirms the price the order is created from.
A redemption whose order closes unpaid does not count. The redemption row is written when the order is created, before any money moves, and it is never deleted — it is a financial record. What the counters in step 3 count is therefore qualified by the order’s state: a redemption behind an order that was cancelled or expired is left out of the promotion’s total, the customer’s count, and the code’s count, so a one-per-customer offer survives “pressed Pay, changed my mind” and a limited public code is not exhausted by checkouts that never paid. A redemption behind a live pending order still counts, which is what stops two parallel checkouts from both passing a limit of one. The same rule applies to the one trial a customer gets: a trial claim whose order closed unpaid is taken over by the next trial order rather than refusing it.
A closed order paid late is re-checked against those limits. A closed order can still be paid — see the state machine — and since it stopped counting the moment it closed, its promotion or trial may have been claimed by another order since. Settlement re-counts the promotion with the same rule order creation applies, under the same row locks, and looks at who holds the trial claim. If settling would take the promotion past a limit, or the trial claim now belongs to an order that has not closed, the payment is classified limit_exceeded, does not settle, and an operator is told. Otherwise it settles and a trial order takes its claim back.
The redemption counts shown in
/admin/catalog → Promotions and returned by the promo-code listing count every redemption row, closed orders included. They describe how often a code was entered, not how many uses it has left.Rate limiting
Bot rate limiting fails closed for every money-moving scope and open only for navigation.
Scope: plans or goods
promotions.applies_to names the catalogue a promotion discounts — plans or goods, never both. Within a catalogue, scope is promotion_plans or promotion_goods, and an empty scope means every plan or every visible product.
An unscoped promotion already meant “every plan”. Had goods simply joined that set, every promotion an operator has ever written would silently have started discounting shop orders the moment they upgraded. That is the wrong default twice over: nobody agreed to it, and the two prices are not the same kind of number. Existing rows default to plans, so nothing widened on upgrade.
The applies_to clause is load-bearing rather than belt-and-braces: an unscoped goods promotion has no plan-scope rows, so without it, it would pass the wildcard branch and discount every plan. The domain layer repeats the check inside the discount function itself so the two catalogues cannot drift. A database trigger refuses a goods-scope row on a promotion whose applies_to is not goods, because a plan promotion accumulating goods scoping rows reads as configured and behaves as absent.
applies_to has no write path in any API. Neither the creation endpoint nor
the panel update sets it, so every promotion created through Omniflow is a
plans promotion. A goods promotion today requires writing the column and the
promotion_goods rows with SQL.The goods price floor
A promotion applied to a digital-goods order may not take the charged price below what the upstream provider charges for the same thing. A plan price is a number the operator chose, and discounting it costs them margin they set themselves — the floor there is zero, and an operator may give a plan away. A shop price is a provider quote plus a markup, and 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, so it is refused withpromo_below_cost and belongs in the markup control instead. It is refused rather than quietly capped, because a percentage that silently becomes a different percentage is the mismatch that generates a support ticket.
Two consequences follow: a product sold at an operator-configured fixed price has no provider quote behind it and can be discounted to zero, and a product whose cost the gateway will not disclose cannot be discounted at all. The floor is a database constraint as well as an application check, so a script or a backfill cannot write what the checkout refuses. Gifts and digital goods covers the shop side in full.
Stacking and precedence
promotions.stackable defaults to false and promotions.precedence to 0. As designed, stacking off by default means two promotions apply together only when both say they may, and precedence orders evaluation so the outcome of applying several is deterministic rather than dependent on the order the customer typed them in.
Neither field is read by any pricing path. They are stored, editable, sent on
every promotion update, and rendered as a badge in the panel, and they reach no
code in the commerce domain.More decisively, one order can carry at most one promotion by construction: the
order input takes a single promo code, the bot checkout and the saved cart each
hold a single code column, and
promo_redemptions.order_id is unique. Stacking
cannot occur, and precedence has nothing to order.Personal offers
A personal offer is a promotion pointed at exactly one named customer, with its own Russian and English title and terms and its own validity window. It is a promotion with an audience of one plus its own presentation, so the redemption path, the limits, and the ledger effects are the ones already tested. The discount is not defined on the offer: it points at an existing promotion, because a second, offer-only discount engine would be a second set of rules to keep consistent.Creating one
1
Open the register
Go to
/admin/offers. The register lists offers with the English title over
the Russian one, a link to the customer, the window from start to expiry, a
status badge, and — when redeemed — a link to the order. Filter chips cover
all, active, redeemed, dismissed, expired, and revoked.2
Compose
Press New offer. Fill Customer (a UUID, typed in), Promotion (a select
populated from the promotions list), Title EN, Title RU, Terms EN, Terms RU,
Valid from, Valid until, and a reason. The two dates are pickers — a
calendar for the day, hour and minute from two lists — and Valid until
will not offer a day before the one Valid from names.
3
Check the preview
A two-column preview renders the message as the bot will send it in each
language, with the day countdown and the two buttons; a missing title in
either language shows in red. It is a mock and says so — the bot is a separate
process and the panel cannot ask it to draw a message.
4
Create
Submitting calls
POST /v1/panel/offers (marketing.write) and answers 201
with the offer. startsAt defaults to now. Audit panel.offer.created,
category marketing.Revoking
Each active row carries an inline reason box and a Revoke button, callingPOST /v1/panel/offers/{offerID}/revoke (marketing.write, 204, audit panel.offer.revoked). Revoking anything that is not active matches no row and answers 409.
A redeemed offer shows no revoke control. The customer already bought at that price, and the record of what they were offered is what makes the order explicable afterwards.
What the customer sees
Personal offers exist in Telegram only. There is no offers route, screen, or endpoint in the customer web panel, so a customer who only ever signs in to the browser cannot see or take one. The bot’s main menu grows a “Your offer” entry when at least one offer is waiting. Opening it lists each offer’s title and a day countdown with View offer and Not interested. Opening one shows the title, the terms, the countdown, the promo code to use, and a single-use notice. Taking an offer does not mark it redeemed. A customer who opens the catalogue and changes their mind has not used anything, and burning a single-use offer on a tap would be taking something from them for nothing. Dismissal is final, and the update predicate includes the customer, so an offer identifier from callback data — which the customer controls — cannot dismiss somebody else’s offer. The bot’s listing enforces the validity window in its own query rather than trusting the sweeper, so an offer never outlives its expiry on screen even if the sweeper is behind. Expiry itself is swept every five minutes by default. The offer shows the promotion’s first active promo code by creation order. A promotion with no code produces an offer with no code, and there is nothing for the customer to enter.What the admin panel cannot do
/admin/catalog can publish plan versions, toggle plan visibility, save add-ons, retire add-on versions, edit a promotion’s numbers, and toggle a promo code’s active flag. Everything below needs an API call, and the last two need SQL:
Three of these panel routes — the versions endpoint, the add-on save, and the add-on version retire — are implemented but absent from
api/openapi.yaml, so they do not appear in the generated client or the published API reference. See Integrating with the API for how to authenticate against them.
The panel forms all require a reason and send it as X-Operator-Reason, but no catalog or offer service actually checks for one. A direct API call publishes a plan version, saves an add-on, edits a promotion, or creates an offer with a null reason on the audit event.
The unauthenticated catalogue feed
GET /v1/catalog/plans takes an optional locale and requires no authentication. It is a legacy feed, and it does not deduplicate the price join: a plan priced in two currencies appears twice, once per currency. The customer-facing routes under /v1/account are the ones that return one entry per plan with eligibility already decided.
Wholesale code batches
Promo codes discount a purchase the customer still makes. Gifts are issued one at a time and are bought through an order. A reseller arrangement is neither: generate two hundred codes at an agreed price, hand over the list, and be able to kill whatever is left when the list leaks./admin/catalog holds them, because a batch decides what is sold and at what
agreed price — the same decision as publishing a plan version. The money never
moves through Omniflow, which is what wholesale means.
The codes exist once
Only the SHA-256 of each code is stored. The plaintext appears in exactly one place — the response to generating the batch — and nowhere afterwards. There is no endpoint that can produce them again and nothing in the database to produce them from. The screen therefore says so before the operator generates a batch, offers a download and a copy, and confirms before dismissing the list. An operator who loses it has to issue a new batch.What a batch is
Redeeming
A batch code and a gift code are the same sixteen Crockford characters — seeinternal/accesscode — so the customer surfaces offer one box and try both. A
customer holding a code has no way to know which kind it is, and asking them to
pick the right form would be asking them to know something only the operator
does.
Single redemption is a property of the UPDATE predicate: only an issued code
in a batch that is neither revoked nor expired matches, and the same statement
writes the redemption. Two simultaneous attempts on one code produce one
entitlement and one refusal, with no lock anybody has to remember to take.
Every refusal is the same refusal — unknown, already used, revoked, expired.
Distinguishing them would turn the endpoint into an oracle for which codes
exist, which is exactly what somebody working through guesses wants to know.
A redemption extends what the customer already has rather than replacing it,
for the same reason a gift does: replacing would mean a code that shortens
somebody’s subscription.
The redemption runs in a Serializable transaction, like every other path that
builds an entitlement on the customer’s current one. A code redeemed at the same
moment a renewal settles for the same subscription therefore cannot both read
the same current end: one of the two is aborted and the redemption is retried
against the other’s result, so the customer keeps both periods. A retried
attempt redeemed nothing, so the retry is safe, and a code somebody else
redeemed in the meantime is refused on it.
A redemption creates a zero-value order with the operation
code.entitlements.order_id is NOT NULL, and that is worth keeping: every
entitlement this installation has granted can be traced to the transaction
that produced it. No money arrives at redemption — the distributor paid
outside the product — so the order carries nothing, and the sales report shows
the redemption with empty money columns rather than inventing revenue on a day
nobody paid. The wholesale price lives on the batch, where the arrangement
actually is.Revoking the remainder
Revoking kills every code nobody has redeemed and reports how many that was. Redeemed codes are untouched: somebody is using the subscription each one produced, and taking that back is a different decision the operator has not made here. A reason is required, because this is the action taken when a list leaks and “why were these three hundred codes killed” is the question somebody asks six months later. It goes into the audit trail ascodes.batch.revoked.
Related pages
Add-ons and the configurator
Selectable squads, add-on versions, proration, and how capacity reaches an entitlement.
Concurrent subscriptions
Entitlements, the concurrency ceiling, upgrade and downgrade arithmetic, grace, and recovery.
Orders and payments
The order state machine the discount is applied inside, and the refund path.
Gifts and digital goods
The shop catalogue, its markup model, and the cost floor a discount may not breach.