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

# Recurring payments

> Auto-renew consent, the charge schedule, the bounded dunning retries, and what a customer can turn off

Automatic charging is the one place in the product where money moves with nobody
watching. Three rules govern it, and all three are enforced rather than
documented.

The rules themselves live in `internal/recurring`, a package with no database,
no transport, and no provider import, so they are unit-tested on their own. The
worker in `internal/renewal` owns the transaction boundaries and the provider
calls. That split is why the schedule below can be read as a specification
rather than as a description of one code path.

> [!WARNING]
>
> **Current implementation state.** Auto-renew has one surface — the Telegram
> bot — which arms and disarms it per subscription. No code path saves a
> payment method, so the wallet is the only funding source a customer can
> choose. The rules, the schedule, and the operator read surfaces described on
> this page are implemented. [What does not work yet](#what-does-not-work-yet)
> states exactly which parts are not, so an operator can tell what to expect
> before enabling a provider for recurring charges.

## Auto-renew is opt-in

There is no default, no inherited setting, and no "enabled because the plan
supports it". A setting carries the instant the customer agreed, and a setting
with no consent record is not chargeable however it came to be enabled — the
money-moving decision depends on the evidence of agreement, not on a boolean
that happens to be true.

The database enforces the same thing rather than trusting the application:
`auto_renew_settings` carries
`CHECK (enabled = false OR consent_at IS NOT NULL)`, so a row that claims to be
enabled without evidence of agreement cannot be written at all.

Existing rows from v0.5 were backfilled with the moment the setting was last
written, because those rows were created by a customer explicitly enabling
auto-renew in the bot.

### The four states

| State | Meaning |
| - | - |
| `idle` | Auto-renew is off. The worker does not look at the row. |
| `scheduled` | Armed, waiting for the lead time before the entitlement's end. |
| `dunning` | A charge failed and the retry schedule is running. |
| `suspended` | The retries were exhausted. Consent is kept; the worker stops picking the row up. |

Success returns a cycle to `scheduled` rather than `idle` — the customer's
consent is unchanged and the next period will be attempted in turn.

## A provider must be able, and permitted

Two gates, both required:

* The **adapter** declares whether it can bind a payment method at all. This is
  compiled in, through `payments.Capabilities.Recurring`. Of the shipped
  adapters only YooKassa declares it; Telegram Stars and CryptoBot do not.
* The **operator's own merchant account** must have passed a capability test,
  recorded in `payment_provider_settings`. Several acquirers grant card binding
  per merchant rather than per integration, so the adapter's word alone is not
  enough.

The operator switch can only narrow what the adapter declares, never widen it.
The database refuses `recurring_enabled = true` alongside anything but a passing
test, and the API refuses to enable it for an adapter that does not declare the
capability — so a row cannot claim a capability the integration does not have.

To record a test result, call
`POST /v1/panel/settings/providers/{provider}/recurring` with `settings.write`,
passing `{merchantId, passed, enable}`. Asking to enable an adapter that does not
declare the capability returns `422 recurring_unsupported` with the message that
this payment adapter cannot store a payment method. Per-provider setup is in
[Payment providers](https://omniflow-vpn.mintlify.site/integrations/payments.md).

## Saved methods hold no instrument

A saved method is a provider-issued token and a masked label the provider
returned. Omniflow neither parses nor derives that label, and no column can hold
a card number, an expiry, or a verification value.

Re-presenting the same provider token refreshes the existing row rather than
creating a second one: the provider still holds one binding. Removing a method
marks it revoked and keeps the row, so a historical charge still resolves to the
method that made it. A partial unique index allows exactly one default per
customer, and setting a new default clears the old one in the same transaction,
so no window exists in which a customer has none.

Revoking a method does not cancel auto-renew. The settings row keeps its consent
record and simply stops being chargeable, which the renewal path reports as a
failure the customer is told about — rather than silently switching to a funding
source they did not choose.

## Lead time

A renewal is attempted ahead of expiry, three days by default and at most
fourteen. The default is not arbitrary: the whole retry schedule below spans
forty hours, so it finishes before access actually lapses. A customer whose card
was briefly declined is not cut off while a retry is still pending. A lead time
of zero or less is clamped back to the default, and anything above fourteen days
is clamped down — a renewal attempted a month early charges for a period the
customer has not reached and may not want.

The bot offers one, three, or seven days rather than a free-text duration,
because the only question a customer is really answering is how much warning
they want if the card fails.

## Dunning

A failed charge retries a bounded number of times and then stops.

| Attempt | Delay before it |
| - | - |
| 1 | immediate |
| 2 | 4 hours |
| 3 | 12 hours |
| 4 | 24 hours |

Hours rather than minutes, because the reasons an automatic charge fails — an
expired card, a daily limit, a bank hold — are resolved by a person in hours.
That the four attempts total forty hours and the default lead time is
seventy-two is asserted by a unit test rather than left as a coincidence, since
it is the property that keeps a customer from losing access while a retry is
still outstanding.

Not every failure notifies the customer. The first is usually resolved by the
retry, and a message per attempt trains a customer to ignore the one that
matters; the second failure and the final abandonment are the two that change
what the customer has to do.

After the fourth failure the cycle is abandoned, the settings move to
`suspended`, and the customer is handed back to manual renewal. Consent is not
discarded — the worker simply stops picking the row up.

### Cycle keys

Attempts are unique per `(cycle_key, attempt)`, where the cycle key identifies
one subscription's one renewal period: the entitlement identifier and its end
instant to the second, in UTC. The same expression is computed in SQL by the
query that finds due renewals, so the worker and the uniqueness constraint agree
on what "this renewal" is. A replayed job inserts nothing and reads back the
attempt that already exists.

Second precision is enough because an entitlement's end instant does not move
within a cycle — and if it does, that is a different cycle by definition.

### Failure codes

The code is stored on the attempt row, where support can read it, rather than
shown to the customer: `insufficient_wallet_balance` is operator vocabulary.

| Code | What happened |
| - | - |
| `consent_withdrawn` | Auto-renew was turned off, or suspended, between scheduling and charging |
| `plan_unavailable` | The stored plan version or currency is missing |
| `order_failed` | The renewal order could not be created |
| `order_expired`, `order_cancelled` | The cycle's order closed without a payment before this attempt ran |
| `insufficient_wallet_balance` | A wallet-funded renewal found the available balance short of the price; no order was opened |
| `no_payment_method` | The attempt names no method, or the method row is gone |
| `payment_method_<status>` | The saved method is not `active` |
| `recurring_not_enabled` | The operator switch or capability test does not permit it |
| `recurring_unsupported` | The adapter cannot charge a stored method |
| `provider_unavailable`, `provider_timeout`, `declined`, `charge_failed` | The provider refused or did not complete |

## What the worker actually does

One cycle takes the customer's money at most once, and that rests on three
mechanisms rather than on care.

**The schedule is a table.** `dunning_attempts` carries the attempt number, its
due time, and its outcome, with a unique key on `(cycle_key, attempt)`. A
restarted worker resumes from it rather than starting over, and a duplicated
pass inserts nothing. Writing the attempt row before charging anything is what
makes the charge resumable: a worker that dies between scheduling and charging
leaves a scheduled attempt for the next pass to find, rather than a renewal that
silently never happened.

**Every attempt on a cycle shares one order.** The order's idempotency key is
`renewal:<cycleKey>`, so a retry finds the order the previous attempt opened
instead of billing for a second period. The provider key is
`renewal:<cycleKey>:<attempt>` instead, so a retry of *this* attempt reaches the
same provider payment while a later attempt is a genuinely new charge against an
order that is still unpaid. Because one order serves the whole schedule, it is
opened with a payment window of seventy-six hours — the forty-hour dunning
window plus a day and a half for deferred attempts — rather than the one-hour
window a checkout gets, so the fourth attempt still finds it payable. An order
that has nonetheless expired or been cancelled fails the attempt with
`order_expired` or `order_cancelled`; nothing can be charged against it.

**A wallet renewal is only opened once the balance covers it.** Each
wallet-funded attempt prices the renewal first and reads the available balance
as it stands at that moment. If the balance is short, the attempt fails with
`insufficient_wallet_balance` and no order is written, so nothing is reserved
against the customer's wallet for the rest of the schedule and a top-up made
after the first decline is seen by the next attempt. Only when the balance
covers the price is the order created — paid in the same transaction, with the
entitlement extended and provisioning queued.

**A synchronous settlement is a settlement.** A saved-method charge with
capture can come back from the provider already `succeeded`. That result goes
through the same classification and settlement path a webhook would produce —
the order moves to `paid`, the entitlement is extended, and the fulfillment job
is queued — rather than being written onto the payment intent alone. Should that
settlement not commit, the intent keeps its provider reference in `processing`
and the payment reconciler settles it on its next pass; the reconciler also
picks up a `succeeded` intent whose order is still `pending`, which is what a
charge recorded without settlement looks like.

**An outstanding payment blocks a new charge.** If the provider has been asked
and has not answered, the attempt is deferred rather than failed: the honest
reading of silence is "unknown", and charging again on unknown is how a customer
gets billed twice.

Every outcome moves the auto-renew row for that subscription: its `state`
follows the outcome (`scheduled` after success, `dunning` after a failure,
`suspended` after abandonment), `last_attempt_at` records the moment, and
`last_failure_code` carries the code above. A settings row that has been
deleted leaves the attempt's own outcome as the record.

Consent is re-checked at the moment of charging rather than at the moment of
scheduling, because the second is the one that moves money. A customer who
turned auto-renew off in between is resolved as `abandoned` with
`consent_withdrawn` and is not charged. A customer whose account is not `active`
is never selected in the first place.

A declined charge does not retire the saved method. Only an adapter that reports
the capability as unsupported, or an error naming the payment method itself,
marks a method `failed` — a daily limit or a temporary hold declines a perfectly
good card, and forgetting it would turn one bad day into a lost subscription.

The pass runs every minute in the worker process, one hundred rows at a time,
first scheduling what has come due and then charging what is scheduled. Neither
the interval nor the batch size is exposed as an environment variable. The
worker builds the same adapter set the API does, because a worker missing an
adapter could only record failures.

## What the customer controls

Everything on this page that a customer can change lives in the Telegram bot,
under **Settings → Auto-renew** (the `autorenew` route).

The screen states the three things somebody needs to know before agreeing to a
charge they will not be present for: what plan, from where, and how long before
expiry. It also names the current state in words when it is not the ordinary
one, so a customer whose card just failed does not have to work out from a
silent screen why nothing happened.

Every setting belongs to one subscription — the table is keyed on
`(user_id, subscription_id)` — so each callback below carries the subscription
it changes as its last segment. With concurrent subscriptions enabled the route
first asks which subscription to configure (`renew-settings:<subscriptionID>`);
on a single-subscription installation it resolves to the primary one.

| Control | Callback | Effect |
| - | - | - |
| Enable | `autorenew:on:<subscriptionID>` | Picks the first payment choice whose adapter declares recurring support, records the subscription, plan, provider, currency, `consent_at`, and state `scheduled` |
| Disable | `autorenew:off:<subscriptionID>` | Clears consent, sets state `idle`, stamps `cancelled_at`, and leaves saved methods alone |
| Funding | `renew-funding:wallet:<subscriptionID>`, `renew-funding:saved_method:<subscriptionID>` | Chooses where the charge comes from. The saved-method button is rendered only when a method is saved |
| Lead time | `renew-lead:1:<subscriptionID>`, `:3:…`, `:7:…` | Days of warning before expiry |
| Payment methods | `methods` route | List, `method-default:<id>`, `method-remove:<id>` |

If no enabled provider declares recurring support for the customer's currency,
the screen says so and offers nothing to switch on, rather than presenting a
toggle that would fail.

Enabling auto-renew is what records consent — the worker checks that timestamp
rather than the flag, so a row written by an import or a migration can never
produce a charge. Turning it off is deliberately *not* the same as asking for a
card to be forgotten: the saved methods stay.

Switching funding to a saved method with no active method returns a dedicated
"no method" message rather than a generic failure. Removing the last active
method falls auto-renew back to the wallet and suspends it, rather than leaving a
customer consented to a charge that can no longer be made; the consent record
stays, so re-adding a card does not mean agreeing again.

A wallet-funded renewal spends the balance and a card-funded one leaves it
alone. Silently draining a customer's wallet because their card is on file would
be a different agreement from the one they made. See
[Wallet and ledger](https://omniflow-vpn.mintlify.site/commerce/wallet-ledger.md) for how the balance is applied.

## Which failures the customer hears about

Not every one. The first decline is usually resolved by the retry, and a
notification per attempt teaches a customer to ignore them. The second failure
and the final abandonment are the two that change what the customer has to do,
and those are the two that are sent.

The decision is stored on the attempt as `notify_required` rather than
recomputed in the notifier's query, so the rule lives in one place. The message
says whether access continues and what to do.

Dunning notices deliberately bypass quiet hours and the notification
preferences. This is a payment notice about the customer's own subscription, in
the same class as a receipt: a customer about to lose access because a card was
declined is not served by holding the message until morning, and there is no
opt-out for it in the preference model either. The notice is bounded all the
same: it is tried on at most three passes, stops at once when Telegram reports
the chat blocked or never started, skips a customer whose chat is already known
to be unreachable, and is not sent once it is a week old. Everything else a
customer receives is covered in [Notifications](https://omniflow-vpn.mintlify.site/customer/notifications.md).

## What the panel shows

The failed-charge queue under **Finance → Failed charges**
(`GET /v1/panel/finance/failed-charges`, `finance.read`) is a review surface,
not a control surface. Nothing there re-triggers a charge: the customer has
already been told to renew, and charging them again from a panel click would be
an attempt they did not ask for.

A customer's charge history (`GET /v1/panel/customers/{customerID}/charges`) and
saved methods (`GET /v1/panel/customers/{customerID}/payment-methods`) appear on
their profile, both gated on `finance.read`. Neither the provider token nor any
card data is returned by either. An operator with `finance.write` may revoke a
method on the customer's behalf with
`DELETE /v1/panel/customers/{customerID}/payment-methods/{methodID}`.

There is no auto-renew endpoint under `/v1/account` or `/v1/panel`. An operator
cannot enable, disable, or reconfigure a customer's auto-renew at all, which is
consistent with consent being the customer's to give.

## What does not work yet

These are properties of the shipped code, not of a configuration.

**No code path creates a saved payment method.** The query that would store one
exists and has no callers, and the YooKassa adapter never asks the provider to
save a method, so no token is ever returned to bind. Every customer's saved-method
list is therefore empty, `funding = 'saved_method'` cannot be selected, and the
only reachable funding source is the wallet. The removal and revocation logic
described above is implemented; the creation is not.

**Auto-renew has no web or panel surface.** It appears nowhere in the customer
web panel and nowhere in `/admin` beyond the read-only charge history and the
provider capability switch.

Taken together: auto-renew funded **from the wallet** works end to end — the bot
arms it, the worker schedules the cycle, charges the wallet at the lead time,
retries on the dunning schedule, and suspends after the final failure. Auto-renew
funded from a **saved card** cannot happen yet, because no saved method is ever
created; an installation that enables recurring for YooKassa and records a
passing capability test still sees no card charge. The renewal reminders
described in [Subscriptions](https://omniflow-vpn.mintlify.site/commerce/subscriptions.md) are delivered on the same
seven, three, one, and zero-day schedule either way.

## Related pages

> **[Orders and payments](https://omniflow-vpn.mintlify.site/commerce/orders-payments.md)**
>
> The order state machine, providers, webhooks, and refunds.

> **[Payment providers](https://omniflow-vpn.mintlify.site/integrations/payments.md)**
>
> Per-provider setup, including the recurring capability switch.

> **[Subscriptions](https://omniflow-vpn.mintlify.site/commerce/subscriptions.md)**
>
> Renewal arithmetic, reminders, grace, and recovery.

> **[Wallet and ledger](https://omniflow-vpn.mintlify.site/commerce/wallet-ledger.md)**
>
> How a wallet-funded renewal is settled and recorded.


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