Skip to main content
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.
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 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

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.

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

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.

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.