Skip to content

Recurring expenses

Backend domain: app/graphql/recurring_expenses/ Migration: add_recurring_expenses RBAC Path / Resource: RECURRING_EXPENSES

Overview

A RecurringExpense is a template (name + expense/payment accounts + amounts + optional category, project, description, invoice number) plus a cadence (frequency, interval, day-of-month/week, optional end-date / occurrence count). The backend tracks nextRunDate, lastRunAt, lastExpenseId, lastRunError, and isActive.

materializeRecurringExpense(id) posts one occurrence on demand. runRecurringExpensesDueToday materializes every active template whose nextRunDate <= today. The daily worker (app/admin/tasks/recurring_expenses_run_due_task.py) does the same fan-out across every tenant using the master-DB registry.

Each materialization calls ExpenseMutationService.create_expense, so account validation and the auto-generated expenseNumber continue to apply.

Date arithmetic uses the same helper as recurring journals: monthly cadences honour dayOfMonth with month-length clamping (day 31 in February → last day of February).

Setup prerequisites

  • Expense account (debit) and payment account (cash/bank, credit) must exist.
  • Optional: an ExpenseCategory and/or Project to attribute the expense to.

GraphQL surface

Queries

recurringExpenses: [RecurringExpense!]!
recurringExpense(recurringExpenseId: UUID!): RecurringExpense!

RecurringExpense fields: id, name, frequency (DAILY | WEEKLY | MONTHLY | QUARTERLY | YEARLY), interval, dayOfMonth, dayOfWeek, startDate, endDate, occurrencesRemaining, nextRunDate, lastRunAt, lastExpenseId, lastRunError, isActive, accountId, paymentAccountId, expenseCategoryId, projectId, descriptionTemplate, invoiceNumberTemplate, subtotalAmount, taxAmount, discountAmount.

Mutations

createRecurringExpense(recurringExpense: RecurringExpenseInput!): RecurringExpense!
updateRecurringExpense(recurringExpense: RecurringExpenseUpdateInput!): RecurringExpense!
pauseRecurringExpense(recurringExpenseId: UUID!): RecurringExpense!
resumeRecurringExpense(recurringExpenseId: UUID!): RecurringExpense!
skipRecurringExpense(recurringExpenseId: UUID!): RecurringExpense!
deleteRecurringExpense(recurringExpenseId: UUID!): Boolean!
materializeRecurringExpense(recurringExpenseId: UUID!): RecurringExpense!
runRecurringExpensesDueToday: Int!

RecurringExpenseInput: name, frequency, interval, startDate, accountId, paymentAccountId, subtotalAmount, taxAmount, discountAmount, plus optional expenseCategoryId, projectId, descriptionTemplate, invoiceNumberTemplate, endDate, occurrencesRemaining, dayOfMonth, dayOfWeek.

RecurringExpenseUpdateInput adds a required id.

Behaviour to handle in the UI

  • The create/edit form is an expense form plus a cadence section.
  • subtotalAmount, taxAmount, discountAmount are entered as decimals; the backend computes taxRate, discountRate, and totalAmount on each materialized expense (same logic as the standard expense form).
  • Show nextRunDate prominently in the list so users know what's coming.
  • pauseRecurringExpense / resumeRecurringExpense are quick toggles in the row context menu.
  • skipRecurringExpense advances nextRunDate by one cadence without posting — useful for "we already paid this manually this month".
  • materializeRecurringExpense is the manual "Run now" button.
  • If lastRunError is non-null the row should show a warning badge; the daily worker writes the error there and still advances nextRunDate so it does not get stuck.

Error states

Backend behaviour When Frontend message
ValueError ("amounts must be non-negative") Create/update with negative numbers "Amounts must be ≥ 0."
ValueError ("Discount cannot exceed subtotal") Discount > subtotal "Discount cannot be greater than the subtotal."
ValueError ("interval must be >= 1") Bad cadence "Interval must be at least 1."
ValueError ("end_date … before start_date") Bad date range "End date must be on or after the start date."
Underlying validate_expense_accounts failure Bad account combination Surface as the account-validation message from the standard expense form.

Suggested UX flow

  1. Recurring expenses list with status badge (Active / Paused), nextRunDate, last run, last error, total amount per occurrence.
  2. Create / edit form with cadence picker, the same fields as the regular expense form, plus optional description/invoice-number templates.
  3. Detail page showing the next 6 occurrences (frontend-computed from nextRunDate + cadence) and a history list (use lastExpenseId plus an expenses query filter on description / invoiceNumber until an explicit source link is added).

Scheduled worker

app/admin/tasks/recurring_expenses_run_due_task.py is the daily fan-out cron — wire it as a Render daily cron alongside recurring_journals_run_due_task and recurring_invoices_run_due_task. Flags:

  • --dry-run counts due rows per tenant without posting.
  • --reseed rebuilds the master registry (tenant_recurring_expense_registry) from each tenant DB; use after manual fixes or accidental drift.

Open follow-ups

  • Expense does not yet carry a recurring_expense_id source link, so listing past occurrences requires matching by lastExpenseId (latest only) or by template description. Either expose an expensesForRecurringExpense(id) query or add a source-link column.