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.
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
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.
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.
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.
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.
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 (theautorenew 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.
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 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 asnotify_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.
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 are delivered on the same
seven, three, one, and zero-day schedule either way.
Related pages
Orders and payments
The order state machine, providers, webhooks, and refunds.
Payment providers
Per-provider setup, including the recurring capability switch.
Subscriptions
Renewal arithmetic, reminders, grace, and recovery.
Wallet and ledger
How a wallet-funded renewal is settled and recorded.