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

# Subscriptions and entitlements

> What a subscription and an entitlement each are, how concurrency is bounded, and how a period is extended, changed, expired, and recovered

Two records carry this area, and keeping them apart is the whole design.

An **entitlement** is Omniflow's own statement of what one paid order bought: a plan version, a period, a traffic allowance, a device limit, and a squad set. A **subscription** is the stable, customer-visible handle for exactly one Remnawave user — a slot, a label, and a remote user identifier. One subscription accumulates entitlements over time as the customer renews; each entitlement belongs to exactly one order.

By default a customer owns exactly one subscription, and every screen behaves as it did before concurrency existed. An operator who needs more — a household with separate device pools, a customer running two regions — turns it on explicitly.

## Entitlements

An entitlement row carries what Omniflow owes, plus the last thing it observed on the other side: `observed_state`, `remnawave_user_id`, and `reconciled_at`. Keeping the two apart is what lets Omniflow tell what it wants from what it last saw.

It exists as its own record because payment state, entitlement state, and Remnawave state fail independently. A successful payment has to survive a Remnawave outage: the money is recorded, the entitlement is written, and provisioning is retried afterwards. The schema enforces the rest — `UNIQUE (order_id)` means one order can never mint two entitlements, and a check requiring the end to be after the start means a zero-length or negative period is unrepresentable.

Nobody creates an entitlement by hand. It is written by the settlement of a paid order, or by a gift claim, inside the same transaction that records the money.

| Status | Meaning |
| - | - |
| `pending` | Settled but not yet pushed to Remnawave |
| `active` | Remnawave reports the user as active |
| `limited` | Remnawave reports the user as limited, typically traffic exhausted |
| `disabled` | Remnawave reports the user as disabled |
| `expired` | Remnawave reports the user as expired |
| `superseded` | A later entitlement on the same subscription replaced it |
| `failed` | Remnawave reported a status this build does not recognise |

Everything except `pending` and `superseded` is written from the observed Remnawave status. `superseded` is written locally, after a replacement has actually been provisioned.

> [!NOTE]
>
> An entitlement's stored status never becomes `expired` on a timer. Expiry is
> computed for display from `ends_at`, and the stored status only changes to
> `expired` when a reconciliation pass reads that back from Remnawave. A customer
> screen showing "expired" and a database row still reading `active` are the same
> fact.

Nothing ever deletes an entitlement. Retention deletes resolved drift rows and nothing else in this area.

## The subscription model

| Column | Meaning |
| - | - |
| `slot` | Position within the customer's subscriptions, starting at 1 |
| `label` | Customer-chosen name, up to 40 characters, shown in every screen and notification |
| `remnawave_user_id` | The Remnawave user this subscription provisions |
| `status` | `active` or `closed` |

One customer may own several Remnawave users, and a subscription is the stable handle for exactly one of them. Without a handle in between, Omniflow would have to guess which remote user an alert, a renewal, or a device removal was about.

Entitlements, orders, notification deliveries, and auto-renew settings all carry a `subscription_id`. Wallet balance, promotion eligibility, referral attribution, and trial claims deliberately do not: those are properties of the **customer**, so opening a second subscription grants no second trial and no second referral reward.

### What is per subscription and what is per customer

| Per subscription | Per customer |
| - | - |
| Plan, period, squads, device limit, traffic, expiry | Wallet balance and ledger |
| Remnawave user, access link, devices | Promotion and promo-code eligibility |
| Expiry and traffic alerts | Referral attribution and rewards |
| Renewal, upgrade, downgrade | Trial eligibility and abuse controls |
| Auto-renew intent | Support tickets, news, consent, locale |

Trial abuse controls count customers, never subscriptions. `trial_claims` is keyed on the customer, so the second subscription of a customer who already used their trial cannot claim another one.

### Labels

A label is trimmed, bounded to 40 runes, and rejected outright if it is empty or contains control characters — never silently truncated, because it is shown back to the customer verbatim.

Customers rename with `PATCH /v1/account/subscriptions/{subscriptionID}`; the bot's rename route does the same. An operator renames with `PATCH /v1/panel/customers/{customerID}/subscriptions/{subscriptionID}` (`subscriptions.write`). Both accept the label and nothing else.

## Concurrency

```bash theme={null}
APP_MULTI_SUBSCRIPTION_ENABLED=true
APP_MAX_SUBSCRIPTIONS_PER_CUSTOMER=3
```

`APP_MAX_SUBSCRIPTIONS_PER_CUSTOMER` defaults to `3` and is clamped to at least 1. While `APP_MULTI_SUBSCRIPTION_ENABLED` is false, the effective ceiling is one whatever the maximum says.

A plan may set a tighter ceiling of its own through `plans.max_concurrent_per_customer`, which narrows the installation limit 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. See [Catalog and promotions](https://omniflow-vpn.mintlify.site/commerce/catalog-promotions.md) for where that cap is edited.

A customer at either ceiling gets a stable refusal reason, checked in this order:

| Reason | Raised when |
| - | - |
| `multi_subscription_disabled` | Concurrency is off and the customer already holds one |
| `subscription_limit_reached` | Concurrency is on and the installation ceiling is full |
| `plan_limit_reached` | The plan's own cap is full |

The limit is enforced where the money is, not in the interface. The count is taken under a transaction-scoped advisory lock on the customer, so two simultaneous purchases cannot both observe a stale count and both pass.

Only a **purchase** may open a new subscription. A renewal, upgrade, or downgrade always names an existing one. The web checkout refuses to guess: with concurrency on and more than one subscription held, opening one of those operations without a `subscriptionId` is refused rather than defaulted, because clearing a subscription target by accident is how a renewal silently becomes a second subscription.

The subscription row is written when the order for it is created, before any money moves, so it can be targeted by the payment and counted against the ceiling while the checkout is open. If that order then closes unpaid — cancelled by the customer or an operator, or expired by the sweep — the row is removed again, provided nothing else ever used it: no entitlement, no other live order, no Remnawave user, no auto-renew setting, notification, cart, checkout session, or dunning attempt. The closed order is detached from it first. An operator's cancel does this in the same transaction; the expiry sweep does it for every customer whose order it closed; a customer's cancel on either surface calls the same cleanup after it commits. A subscription that was ever provisioned is never removed by this — that is the operator procedure under [Reducing a customer to one subscription](#reducing-a-customer-to-one-subscription).

**Money can still arrive for a detached order.** A manual transfer is often confirmed after its window, and a checkout left open in another tab can be paid after a cancel. Settlement then opens a fresh subscription in place of the one that was removed — under the same advisory lock and concurrency ceiling as a purchase — writes it onto the order, and builds the entitlement there, inside the settling transaction. It never falls back to another subscription the customer holds: a thirty-day second subscription confirmed late used to land on the primary one and overwrite months of remaining time. When the ceiling will not allow another subscription, the payment is classified `subscription_unavailable` and does not settle; an operator decides. For the same reason the fulfillment worker never adopts an entitlement onto a subscription other than its own and its order's: one whose subscription cannot be established fails permanently with `subscription_unresolved` instead of being provisioned onto the primary.

> [!WARNING]
>
> `/admin` has a concurrent-subscription screen at
> `PUT /v1/panel/settings/commerce/subscriptions` (`settings.write`), and it does
> **not** govern enforcement. It writes `commerce_settings`, while the policy
> actually checked at purchase comes from `APP_MULTI_SUBSCRIPTION_ENABLED` and
> `APP_MAX_SUBSCRIPTIONS_PER_CUSTOMER`, read once at process start.
>
> The stored row is read in exactly one place: it decides whether the customer
> dashboard shows a subscription switcher. Changing the limit means changing the
> environment and restarting the API, the bot, and the worker.

## What changes for the customer

With concurrency on:

* the main menu opens a subscription switcher instead of the single subscription screen;
* every purchase, renewal, upgrade, downgrade, and add-on names its target subscription explicitly;
* device management, link rotation, and connection instructions act on the selected subscription's Remnawave user;
* expiry, traffic, renewal, grace, recovery, and fulfillment notices name the subscription they are about.

With it off, none of that appears. A customer with one subscription sees no extra selection step, and alerts carry no subscription label, because naming the only one would be noise.

The switcher on the web dashboard follows the operator's stored setting but is forced on whenever the customer holds more than one subscription — hiding subscriptions they own would strand one of them.

In the bot, a subscription screen offers rename, connect, devices, renew, link rotation, and add-ons. In the browser, `/account` lists the cards, `/account/subscriptions/{id}` shows one with its traffic meter, rename form, and rotation control, and `/account/devices` shows devices with a switcher when there is more than one. [The customer web panel](https://omniflow-vpn.mintlify.site/customer/web-panel.md) and [The Telegram bot](https://omniflow-vpn.mintlify.site/customer/telegram-bot.md) cover both surfaces in full.

## Alert isolation

Notification deduplication is keyed on `(user_id, kind, subscription_id, dedupe_key)` with nulls not distinct. Without the subscription in the key, two subscriptions expiring on the same day would collide on one dedupe key and the customer would be told about only one of them. A busy subscription can never suppress a quiet one's notice.

## Fulfillment

Each subscription provisions its own Remnawave user, derived deterministically from the subscription identifier:

```text theme={null}
omniflow_<first 16 hex characters of the subscription id>
```

An installation upgraded from v0.4 keeps the customer-derived name for slot 1. The worker looks for the subscription-derived name first and the legacy customer-derived name second, so an existing Remnawave user is adopted rather than duplicated. A slot above 1 with no mapping never falls back to the legacy name, so a second subscription can never be confused with the first one's remote user.

The customer-level `remnawave_users` mapping continues to track the **primary** subscription (slot 1). That is what keeps the pre-commerce self-service screens resolving unchanged.

Drift detection, reconciliation, and superseding are all scoped to one subscription. Buying a second subscription never retires the first one's entitlement. [Fulfillment](https://omniflow-vpn.mintlify.site/commerce/fulfillment.md) has the operation pipeline, the retry policy, and what a failure looks like.

## Changing a subscription: the arithmetic

An order's operation plus the plan version's policies decide three instants — when the entitlement starts, when it ends, and when provisioning should actually run. Let *base* be the current entitlement's end when that is still in the future, and now otherwise.

| Operation | Policy | Starts | Ends | Provisioned |
| - | - | - | - | - |
| `purchase` | — | now | now + duration | now |
| `extension` / `renewal` | — | now | base + duration | now |
| `upgrade` | `replace` | now | now + duration | now |
| `upgrade` | `extend` | now | base + duration | now |
| `upgrade` | `forbid` | refused | | |
| `downgrade` | `immediate` | now | now + duration | now |
| `downgrade` | `at_expiry` | base | base + duration | base |
| `downgrade` | `forbid` | refused | | |

**There is no proration anywhere in this table.** An upgrade charges the new plan's full catalogue price; `replace` discards the remaining time and `extend` keeps it. Omniflow never guesses what a mid-period plan change is worth, which is why the choice is a per-version policy an operator sets rather than a formula.

An `at_expiry` downgrade is what the deferred provisioning instant exists for: the fulfillment job snoozes until that moment rather than occupying a worker, and the previous entitlement is superseded only after the replacement has actually been provisioned. Until then both surfaces keep showing the plan in force: every customer-facing read prefers the entitlement whose start has passed over one scheduled for later, so a deferred downgrade does not appear as "being set up" for the rest of the period it has not yet begun.

`extension` and `renewal` do identical arithmetic and remain separate operations because the *reason* differs — one the customer asked for, one an automatic charge produced — and support has to be able to tell them apart.

A forbidden operation is unreachable rather than merely unoffered. The lifecycle rule lives in `internal/commerce` and is enforced inside order creation against the subscription the order resolves to, so every caller — the web checkout, the bot, the bearer-token API, a saved cart — meets the same refusal, HTTP 422 `operation_forbidden`:

* a `purchase` is refused on a subscription whose entitlement is still in force (a paused one counts as in force) — it would schedule from now and throw the remaining time away; with no subscription named, a purchase that would default onto a live primary is refused rather than guessed;
* an `extension` must be of the plan the subscription holds; extending a lapsed subscription buys a fresh period from now and is allowed;
* an `upgrade` must go to a different plan priced at or above the held one, a `downgrade` to one priced below it, and both need a live plan to move from;
* the plan version's policy refuses `forbid` as before.

A `renewal` is the renewal worker's own operation, opened from the customer's standing consent for the subscription it names, and is not reclassified. A duration of zero or less is refused outright.

### What a customer is offered

`GET /v1/account/plans` returns an `operations` array per plan, computed on the server. A customer with no subscriptions is offered `purchase`. A customer with one is offered `purchase` when the concurrency ceiling still allows it, plus `extension`, plus `upgrade` and `downgrade` wherever the policy is not `forbid`. The panel renders buttons; it does not decide policy — a panel that decided for itself which buttons to render would be a second implementation of the rules, and the two would eventually disagree in the customer's favour or against it, both of which are bugs. The array is `commerce.OfferedOperations`, the same rule order creation enforces, so everything offered is accepted.

### Cancellation

`cancellation_policy` is stored on the plan version, snapshotted onto the order line, and shown, and **nothing acts on it**. There is no cancel operation in the order model, no cancel route, and no cancel control on any surface. A subscription ends by reaching its end date without a renewal.

An operator who needs to stop access before then disables the subscription through the fulfillment pipeline — `POST /v1/panel/customers/{customerID}/subscriptions/{subscriptionID}/operations` with `disable`, a reason, and `subscriptions.write` — which is a Remnawave state change, not a cancellation, and does not refund anything. Money comes back through the refund path in [Orders and payments](https://omniflow-vpn.mintlify.site/commerce/orders-payments.md).

## Phases: what a customer is told

One function reduces an entitlement plus the observed Remnawave traffic into a single phase, so the bot and the browser cannot describe the same subscription differently:

| Phase | Condition |
| - | - |
| `none` | No entitlement, or the entitlement was superseded |
| `provisioning` | Status `pending` — settled, waiting on the first Remnawave push |
| `failed` | Status `failed` |
| `disabled` | Status `disabled` |
| `expired` | Remnawave reports the user expired, whatever the clock says about the grace window |
| `paused` | Status `paused` — suspended by an operator with the remaining days preserved |
| `grace` | Past the end, with a grace period configured and not yet elapsed, while Remnawave still reports the user active or limited |
| `expired` | Past the end with no grace left |
| `limited` | Status `limited`, or used bytes have reached a positive limit |
| `expiring_soon` | Within 7 days of the end |
| `active` | Otherwise |

The 7-day window matches the earliest renewal reminder, so the bot never shows "active" while it is already asking the customer to renew.

Days left are computed on the server and **rounded up**, because a subscription with eight hours left has a day left from the customer's point of view. Traffic percentage is clamped to 0–100 so an overage renders as a full bar, and "unlimited" is carried explicitly rather than inferred from a zero limit — a progress bar with no ceiling is not a bar at zero per cent.

### Grace periods

`plan_versions.grace_period_seconds` defaults to `0`, and zero means expiry is immediate. There is no implicit grace anywhere. It is set per plan version in the version editor, entered in whole days, and exists so that expiry copy is not guesswork: it keeps access alive after the end so the customer can be told exactly how long they have left to renew.

Grace is carried to the customer as `gracePeriodSeconds` on the plan payloads, and the phase ladder reports `grace` for as long as it lasts.

It is also carried to Remnawave, which is what makes the promise real. The entitlement's `ends_at` stays the paid end — renewal arithmetic, reminders, and reporting read it — but the expiry the fulfillment worker pushes to Remnawave is `ends_at` plus the plan version's grace, for every operation: the first `create`, an extension, a resume after a pause, and the reconciliation sweep. Drift is measured against that same sum, so a remote expiry that carries the grace is agreement rather than a `expiry` drift. Because Remnawave then keeps the user alive through the window, a reported `EXPIRED` status means the window is truly over, and the phase ladder trusts it over the clock: an entitlement whose remote user is expired is `expired`, never `grace`.

## Pausing

Cancellation and refund both end a subscription. Neither covers the case people
actually ask for — "I am away for six weeks, stop the clock" — and the
workaround an operator otherwise reaches for is to disable the Remnawave user,
which stops access and does **not** stop the clock. The customer comes back to a
subscription that expired while they could not use it.

A pause is therefore two facts, not one.

**Access stops.** The Remnawave user is disabled, through the ordinary
fulfillment pipeline, so it carries the same idempotency key, retry policy, and
history as any other change.

**The clock stops.** `entitlements.paused_at` records when, and every
remaining-time figure on both customer surfaces is measured from that instant
instead of from now. A paused subscription that counted down to zero on the
customer's own screen would be the feature contradicting itself in the one place
the customer looks.

Resuming moves `ends_at` forward by exactly the elapsed pause and records the
same amount in `paused_seconds` — so an entitlement whose expiry sits later than
its order paid for is explained by its own columns rather than looking like a
mistake.

### What holds it together

| Property | Where it lives |
| - | - |
| Only an active or limited subscription can be paused | The `UPDATE` predicate, so two operators pressing the button produce one pause and one refusal |
| Only a paused one can be resumed | The same, so a double-submitted button cannot hand out the elapsed time twice |
| `status = 'paused'` exactly when `paused_at` is set | A table constraint |
| The expiry pushed to Remnawave is the one the row holds | Read back from the update inside the same transaction |

Resume is **one** fulfillment operation rather than two, and the order is why:
the new expiry is pushed before the user is re-enabled, so the user is never
briefly enabled carrying an expiry that has since passed.

### Buying a change while paused

A paused entitlement is still the subscription's live entitlement, and a change
bought while it is paused — an extension, a gift or access code that extends, an
upgrade or downgrade — builds on it and retires it, exactly as it would a
running one. What it builds on is the time the pause was preserving: the new
entitlement's base is now plus `ends_at − paused_at`, so the customer gets back
precisely the days a resume would have given them, and the bought period is
added on top. Provisioning the new entitlement supersedes the paused one and
closes its pause in the same write — `paused_at` is cleared and the elapsed
pause is added to `paused_seconds` — so a later resume of the old row is
refused and the days cannot be handed out twice. The paid change resumes the
subscription: the new entitlement is enabled in Remnawave like any other paid
change.

An `at_expiry` downgrade is the one change that does not take effect now — it
waits for the current period to end. Scheduled behind a paused entitlement it
would start at now plus the frozen remainder while the subscription stayed
disabled, and the preserved days would run out with the customer locked out.
So settling it **resumes the paused entitlement first**, in the same
transaction and exactly as an operator's resume would — `ends_at` moves forward
by the elapsed pause and a `resume` operation keyed `order:{orderID}:resume`
re-enables the user — and the downgrade starts when that resumed period ends.

Automatic renewal never fires while paused; it selects active and limited
entitlements only. An add-on is refused while paused with
`no_active_subscription`, because it is priced against the time the current
period has left and a stopped clock has no remaining period to price against.

### Reconciliation knows about it

A paused subscription and a disabled one are the same thing from Remnawave's
side, and only Omniflow knows the difference. Two cases are therefore treated as
agreement rather than drift:

* The remote user is **disabled** — what a pause looks like the moment after it
  is applied.
* The remote user is **expired** — what it looks like a week later, once real
  time has walked past the frozen expiry. Remnawave knows nothing about the
  pause. Treating this as the entitlement expiring would take away the days the
  pause exists to preserve, a week after the customer was told they were safe.

A paused entitlement whose remote user is **active** is real drift: somebody
re-enabled it in Remnawave and the customer is connecting on time nobody is
counting. That is reported.

### There is no customer-facing pause

Deliberately. A pause turns a dated entitlement into an indefinite one, and
letting the person holding it decide when the clock runs would make "thirty
days" mean "thirty days of my choosing, forever". An operator pausing on request
is a support action with a reason attached, which the audit entry records.

What the customer does get is an honest state: the bot and the panel both say
the subscription is paused and that the remaining days are kept, rather than
"disabled — contact support", which would send somebody to a ticket queue to be
told nothing is wrong.

Automatic renewal skips a paused subscription without any extra rule: the
renewal query already selects only `active` and `limited` entitlements.

## Reminders, grace notices, and recovery

The bot's notifier walks every subscription a customer owns and sends, per subscription:

| Notice | Trigger |
| - | - |
| Expiry | The Remnawave expiry is 7, 3, 1, or 0 days away |
| Traffic | Usage reaches 80% or 100% of a positive limit |
| Renewal | Whole days to the entitlement end reach 7, 3, 1, or 0 |
| Grace | Past the end and inside the grace window |
| Recovery | The recovery window after grace has begun and not yet closed |
| Fulfillment | The latest operation succeeded or failed |

An expired subscription nobody is told about is a lost customer, and a recoverable one is worth a one-tap path back. The recovery message carries a button that opens a purchase of the same plan version, plus links to the catalogue and the menu.

`APP_RECOVERY_WINDOW_HOURS` sets how long that offer stays open; the default is 14 days, which `.env.example` ships as `336`. Marketing volume caps do not apply to these — lifecycle notices go through the customer's own notification preferences and quiet hours instead. See [Notifications](https://omniflow-vpn.mintlify.site/customer/notifications.md).

Recovery is Telegram-only. There is no equivalent nudge in the customer web panel.

A customer imported before subscriptions existed, holding only a customer-level Remnawave mapping, still gets alerts: the notifier falls back to a summary carrying just the remote identifier. Subscription labels are added to alerts only when the customer holds more than one.

## Automatic renewal

Auto-renew is consent to be charged for the next period without being present, and it is off unless the customer turned it on. It is configured in the Telegram bot only — there is no auto-renew endpoint or screen in the customer web panel or in `/admin`.

[Recurring payments](https://omniflow-vpn.mintlify.site/commerce/recurring-payments.md) covers the consent record, the funding sources, the lead time, the dunning schedule, and the current state of the write path.

## Turning concurrency on and off

### Turning it on

Nothing has to be migrated. The migration that introduced subscriptions already created one per existing customer, adopting the Remnawave user they had. Flipping `APP_MULTI_SUBSCRIPTION_ENABLED` to `true` only changes what customers may add from that point on.

### Turning it off

Setting it back to `false` is safe and non-destructive:

* existing extra subscriptions keep working, keep renewing, and keep their Remnawave users;
* no customer may open another one;
* a customer who already has more than one still sees the switcher, because hiding subscriptions they own would be worse than showing them.

Nothing is orphaned in either direction: the switch controls creation, never existing state.

### Reducing a customer to one subscription

> [!WARNING]
>
> There is no way to close a subscription. The `status` column allows `closed`
> and a query exists to set it, but nothing calls it: no route, no panel control,
> and no bot action. A subscription that has been opened stays on the customer's
> record.

What an operator can do instead, from `/admin/customers/{customerId}` with `subscriptions.write` and a reason:

**1. Let it lapse, or disable it**

Let the extra subscription reach its end date without a renewal, or queue a
`disable` operation against it so its Remnawave user stops working
immediately.

**2. Confirm nothing is pending**

Check the subscription's entitlement is `expired` or `superseded` and that no
fulfillment operation is still `pending` or `retrying` under
`/admin` → System → Jobs.

**3. Leave it in place**

The subscription row remains, with no live entitlement. It occupies a
concurrency slot, so a customer at the ceiling who wants a different plan
needs the ceiling raised rather than the old subscription removed.

Disabling a subscription does not delete its Remnawave user, and neither does anything else in Omniflow. Removing that is a deliberate action in the Remnawave panel, so nothing here destroys access a customer paid for.

> [!NOTE]
>
> Suspending a **customer** does not reach Remnawave either. Suspension records
> the status, a lifecycle event, and an audit row; no part of the fulfillment
> pipeline reads customer status. A suspended customer keeps working access until
> their period ends — the one thing suspension does stop is an automatic renewal
> charge. Disable the subscription as well if access is meant to stop now.

## Related pages

> **[Catalog and promotions](https://omniflow-vpn.mintlify.site/commerce/catalog-promotions.md)**
>
> Where duration, grace, squads, and the upgrade and downgrade policies are authored.

> **[Fulfillment](https://omniflow-vpn.mintlify.site/commerce/fulfillment.md)**
>
> The operation pipeline, reconciliation, drift, and operator-requested changes.

> **[Add-ons and the configurator](https://omniflow-vpn.mintlify.site/commerce/addons-configurator.md)**
>
> Extra traffic, device slots, and squads on top of a live entitlement.

> **[Recurring payments](https://omniflow-vpn.mintlify.site/commerce/recurring-payments.md)**
>
> Auto-renew consent, funding sources, retries, and failed-charge review.


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