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
ExpenseCategoryand/orProjectto 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,discountAmountare entered as decimals; the backend computestaxRate,discountRate, andtotalAmounton each materialized expense (same logic as the standard expense form).- Show
nextRunDateprominently in the list so users know what's coming. pauseRecurringExpense/resumeRecurringExpenseare quick toggles in the row context menu.skipRecurringExpenseadvancesnextRunDateby one cadence without posting — useful for "we already paid this manually this month".materializeRecurringExpenseis the manual "Run now" button.- If
lastRunErroris non-null the row should show a warning badge; the daily worker writes the error there and still advancesnextRunDateso 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¶
- Recurring expenses list with status badge (Active / Paused),
nextRunDate, last run, last error, total amount per occurrence. - Create / edit form with cadence picker, the same fields as the regular expense form, plus optional description/invoice-number templates.
- Detail page showing the next 6 occurrences (frontend-computed from
nextRunDate+ cadence) and a history list (uselastExpenseIdplus anexpensesquery filter ondescription/invoiceNumberuntil 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-runcounts due rows per tenant without posting.--reseedrebuilds the master registry (tenant_recurring_expense_registry) from each tenant DB; use after manual fixes or accidental drift.
Open follow-ups¶
Expensedoes not yet carry arecurring_expense_idsource link, so listing past occurrences requires matching bylastExpenseId(latest only) or by template description. Either expose anexpensesForRecurringExpense(id)query or add a source-link column.