A recurring series issues normal invoices. The Core engine discovers schedules and their selected autopay methods from recurring_invoice. There is no autopay on a one-off invoice. Disabling autopay leaves recurrence running. Schedule setup remains backend/database-only.
Apply migration 024 before deploying the updated workers. Existing invoices are unaffected until explicitly attached to a schedule. No existing customer payment method is automatically opted in.
The invoice table is unchanged. recurred_invoice contains only generated invoices, linked by recurring_invoice_id and invoice_id. Its required scheduled_for records the intended occurrence time, distinct from the invoice's actual creation time. The canonical invoice is referenced only by recurring_invoice.canonical_invoice_id. That join identifies it as a non-payable source, without a discriminator column. Public bill and auth-code endpoints do not expose canonicals, and the charge path rejects them under the invoice lock. Only spawned invoices are payable.
cron uses five fields: minute hour day-of-month month day-of-week. It must select one fixed minute and hour, never hourly or sub-hourly billing. Weekdays use 0 (Sunday) through 6 (Saturday), with 7 also accepted for Sunday. Cronos handles syntax validation and calendar occurrences, including named months/weekdays, ranges and steps; our adapter enforces billing patterns and anchor-aligned intervals.anchor_local is the first payable invoice's local date and time and must match the cron expression, with no seconds. The canonical supplies its content but is never charged.interval_count defaults to 1. For a single weekday it permits 1, 2 or 4 weeks; for a single monthly day with * for month it permits 1, 2, 3, 6 or 12 months. Other patterns use 1. Intervals align to the anchor, not week-of-year parity or month-number parity.time_zone is a timezone identifier such as America/Los_Angeles. Schedule models default to UTC; set the billing timezone explicitly for local-time schedules. Manual database inserts must supply time_zone (there is no database default).L in day-of-month means the last day: January 31 becomes February 28 (29 in leap years), then March 31. Numeric days follow cron literally: day 31 skips months without a 31st. Use L for uninterrupted month-end billing.*/n steps. Do not combine day-of-month with day-of-week; explicit months require day-of-month and interval 1.ends_at is inclusive. Set the canonical invoice's canceled_at to stop future occurrences. Keep the schedule row so the canonical never becomes payable. Already-issued invoices and queued payments remain unchanged.NeedsReview and logs a warning; it does not issue or charge a backlog. Cancel and create a new canonical/schedule to start again at a future date.Generation and cancellation lock the schedule, then its canonical invoice. Creating the invoice, linking its scheduled occurrence, advancing the cursor, and queuing notifications and any automatic charge commit together. Duplicate scans become no-ops. The cursor and invoices remain authoritative when completed outbox history is deleted. Generation happens directly in the poller; notification delivery uses outbox work.
All examples bill at 09:00 in the configured timezone. Choose an anchor that matches the expression.
| Schedule | Cron | Interval |
|---|---|---|
| Daily | 0 9 * * * |
1 |
| Weekdays | 0 9 * * 1-5 |
1 |
| Mondays and Thursdays | 0 9 * * 1,4 |
1 |
| Weekly on Monday | 0 9 * * 1 |
1 |
| Every other Monday | 0 9 * * 1 |
2 |
| Every four weeks on Monday | 0 9 * * 1 |
4 |
| First and fifteenth | 0 9 1,15 * * |
1 |
| Fifteenth and month-end | 0 9 15,L * * |
1 |
| Monthly on the first | 0 9 1 * * |
1 |
| Every two months on the first | 0 9 1 * * |
2 |
| Quarterly, aligned to anchor month | 0 9 1 * * |
3 |
| Calendar quarters (Jan/Apr/Jul/Oct) | 0 9 1 */3 * |
1 |
| Every six months | 0 9 1 * * |
6 |
| Annually, aligned to anchor month | 0 9 1 * * |
12 |
| Every December 15 | 0 9 15 12 * |
1 |
These are billing expressions interpreted by IScheduler, not the worker's six-field polling cron. This independent service accepts a Schedule, with no invoice or database dependency: Validate checks the configuration, At calculates an occurrence's UTC time, and ShouldFire(schedule, occurrence, now) checks whether that occurrence is due and within the optional end date. It is stateless; the caller owns cursor advancement and duplicate prevention. Validation runs both when creating a schedule and when processing database-created schedules. A malformed expression or inconsistent cursor cannot publish a charge.
Use a new unpaid invoice as the fixed-amount canonical. Do not repurpose a paid or submitted invoice: copy its content into a new invoice instead, preserving the original payment history. Replace both sample IDs and choose a future anchor. This example creates manual recurring billing: both payment-method columns default to null.
begin;
select id from invoice where id = 'abcdefghij' for update;
insert into recurring_invoice
(id, canonical_invoice_id, cron, interval_count, anchor_local, time_zone, next_at)
select 'recurr0001', id, '0 9 1 * *', 1,
timestamp '2026-12-01 09:00:00', 'America/Los_Angeles',
timestamp '2026-12-01 09:00:00' at time zone 'America/Los_Angeles'
from invoice
where id = 'abcdefghij'
and customer_id is not null and canceled_at is null and total > 0
and payment_status = 'Unknown' and total_paid = 0 and payment_xid is null
and payout_status = 'Unknown' and payout_xid is null
and not exists (select 1 from work.outbox
where type = 'ChargeInvoice' and payload->>'InvoiceId' = invoice.id
and status in ('Pending', 'Processing', 'NeedsReview'))
and not exists (select 1 from recurred_invoice where invoice_id = invoice.id)
and not exists (select 1 from recurring_invoice where canonical_invoice_id = invoice.id);
commit;
Keep next_at consistent with anchor_local, cron, interval, and next_occurrence (zero-based). For a new schedule, next_at is the matching anchor converted to UTC; avoid DST gaps/overlaps when inserting by SQL or calculate through IScheduler.At. Do not reset the occurrence counter or rewrite invoice/payment references to retry a charge. Inserting a schedule alone never implies customer consent.
Each recurring_invoice row has two nullable fields:
ach_payment_method_xid: the customer's saved ACH payment-method reference.card_payment_method_xid: the customer's saved card payment-method reference.Both null means manual payment. Populate exactly one to enable autopay; the record means approval has already happened in the layer that wrote it. There is no authorization table, terms/hash snapshot, enrollment notification, or challenge flow in the engine.
The selected reference must match the canonical invoice customer's current saved method, with an available provider profile; ACH must be verified. The normal payment pipeline resolves the gateway and calculates the fee-inclusive amount. A missing, stale, or conflicting selection creates the invoice for manual payment, logs a warning with the reason, and clears autopay for future occurrences. No method is chosen implicitly when both fields are set.
For example, select the customer's latest saved card:
update recurring_invoice r
set ach_payment_method_xid = null,
card_payment_method_xid = (
select c.payment_method_xid
from customer_card_account c
join invoice i on i.customer_id = c.customer_id
where i.id = r.canonical_invoice_id
order by c.created_at desc, c.id desc
limit 1
)
where r.id = 'recurr0001';
To deliberately reuse the method recorded on a previous paid invoice for the same customer instead (which need not be the customer's latest method):
update recurring_invoice r
set ach_payment_method_xid = i.ach_payment_method_xid,
card_payment_method_xid = i.card_payment_method_xid
from invoice i, invoice canonical
where r.id = 'recurr0001' and canonical.id = r.canonical_invoice_id
and i.id = 'paidbill01' and i.customer_id = canonical.customer_id;
The normal saved-method eligibility check still applies. The invoice references record the selected method; they do not establish future authorization or availability.
To disable future autopay:
update recurring_invoice
set ach_payment_method_xid = null, card_payment_method_xid = null
where id = 'recurr0001';
No outbox insertion is needed. The worker queues an ordinary charge for each due invoice. Direct database configuration does not charge the canonical invoice retroactively.
To stop future invoice generation:
update invoice i
set canceled_at = coalesce(i.canceled_at, now())
from recurring_invoice r
where r.id = 'recurr0001' and i.id = r.canonical_invoice_id;
Keep the schedule and its occurrence links. RecurringInvoiceService.CancelAsync provides the same behavior with ownership checks and transaction locks. Canceling twice preserves the original timestamp. New schedules use new canonicals and IDs. Already-queued payments and notifications continue unchanged.
Automatic payments use ChargeInvoice and the existing provider idempotency, settlement, invoice payout, float gate and Rising Tide flows. Collections do not create ledger entries; actual bank movements still do.
The saved method is checked before a payment is queued. Each occurrence copies the current canonical invoice; the normal charge pipeline calculates the amount and fees. Canceling the canonical stops future generation, not already-generated invoices. Publication is the commitment boundary: after enqueueing, it is an ordinary charge and continues even if the canonical is canceled, autopay is disabled, or the saved method changes. Nothing scans, cancels or rewrites outbox items in response to those changes.
Autopay uses the existing payment status, reconciliation, receipt, and internal alert behavior without introducing separate payment-result handling. An unavailable saved method stops future automatic-payment publication.
Each generated invoice queues one RecurringInvoiceNotice outbox item per recipient (customer/vendor). The payload records whether autopay was queued or could not be queued. Delivery failures use normal work retries; unresolved items remain available for recovery. Canceling a schedule does not remove these notices, and cleaning completed work does not recreate them. A crash immediately after sending may repeat an email, not a payment. Later payment issues use existing payment status and operational logs, not extra invoice fields. Existing accepted-submission receipts continue through the normal payment handler.
Normal creation notices use the unchanged invoice-created-customer and
invoice-created-vendor templates, whether or not autopay was queued. Attention
notices use recurring-attention-customer for the customer only. The vendor receives
the normal invoice-created email without automatic-payment failure details.
Attention is logged at Warning and retained by the enabled
work.log sink with operational identifiers and reason codes.
select s.id, s.status, s.cron, s.interval_count, s.next_at, s.next_occurrence,
canonical.canceled_at,
(s.ach_payment_method_xid is not null or s.card_payment_method_xid is not null) as autopay
from recurring_invoice s join invoice canonical on canonical.id = s.canonical_invoice_id
order by s.next_at;
select i.id, i.invoice_number, r.scheduled_for, i.created_at, i.payment_status, i.payment_xid
from recurred_invoice r join invoice i on i.id = r.invoice_id
where r.recurring_invoice_id = 'recurr0001'
order by r.scheduled_for, i.created_at;