Recurring invoices and automatic payments

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.

Schedule behavior

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.

Billing examples

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.

Creating a schedule directly in the database

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.

Database-configured autopay

Each recurring_invoice row has two nullable fields:

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.

Payment safety and notifications

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.

Inspection

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;