Recurring invoices (Ventas recurrentes)¶
Backend domain: app/graphql/recurring_invoices/
Migration: add_recurring_invoices
Cron task: app/admin/tasks/recurring_invoices_run_due_task.py
RBAC Path / Resource: RECURRING_INVOICES
Overview¶
A RecurringInvoice is an invoice template (client, AR account, line items, optional PAC metadata) plus a cadence (frequency, interval, day-of-month/week, optional end-date / occurrence count). The backend tracks nextRunDate, lastRunAt, lastInvoiceId, lastRunError, and isActive.
materializeRecurringInvoice(id) materializes one occurrence on demand. runRecurringInvoicesDueToday and the daily cron (recurring_invoices_run_due_task) fan out across every tenant and materialize every active template whose nextRunDate <= today.
Each materialization flows through the normal InvoiceMutationService.create_invoice, so the invoice goes through the regular PAC/DGI submission pipeline (same numeration, balance creation, retention validation, QB sync). The template's invoiceGenerationMode controls this — default is DGI (auto-submit to PAC); set PROFORM to materialize a draft.
Failure handling: if PAC submission (or any step in create_invoice) fails for one template, nextRunDate is still advanced to the next occurrence and the error message is recorded on the template as lastRunError. The next cron run will continue on cadence — failed occurrences are not retried automatically. Surface lastRunError in the UI so users notice and can re-run manually via materializeRecurringInvoice (which uses the current nextRunDate as the post date) once the underlying problem is fixed.
Date arithmetic: monthly/quarterly cadences honor dayOfMonth with month-length clamping — day 31 in February → February 28 (or 29 in leap years). This matches the spec: "si programas el día 31, en meses de 30 días se generará el día 30".
Setup prerequisites¶
Everything needed to create a regular invoice for the chosen client (AR account on the client, optional PAC office and retention code, item GL accounts, etc.). The template just stores what would normally be entered on the invoice form.
GraphQL surface¶
Queries¶
recurringInvoices: [RecurringInvoice!]!
recurringInvoice(recurringInvoiceId: UUID!): RecurringInvoice!
Mutations¶
createRecurringInvoice(recurringInvoice: RecurringInvoiceInput!): RecurringInvoice!
updateRecurringInvoice(recurringInvoice: RecurringInvoiceUpdateInput!): RecurringInvoice!
pauseRecurringInvoice(recurringInvoiceId: UUID!): RecurringInvoice!
resumeRecurringInvoice(recurringInvoiceId: UUID!): RecurringInvoice!
skipRecurringInvoice(recurringInvoiceId: UUID!): RecurringInvoice!
deleteRecurringInvoice(recurringInvoiceId: UUID!): Boolean!
materializeRecurringInvoice(recurringInvoiceId: UUID!): RecurringInvoice!
runRecurringInvoicesDueToday: Int!
RecurringInvoice fields¶
| Field | Type | Notes |
|---|---|---|
id |
UUID | |
name |
String | Display label, e.g. "Mensualidad gimnasio - Cliente X" |
clientId |
UUID | |
accountReceivableId |
UUID | |
frequency |
enum | DAILY \| WEEKLY \| MONTHLY \| QUARTERLY \| YEARLY |
interval |
Int | Every-N step, e.g. frequency=MONTHLY, interval=2 → every 2 months |
dayOfMonth |
Int? | Anchor day for monthly/quarterly; clamps to month end when needed |
dayOfWeek |
Int? | Reserved (0 = Monday, 6 = Sunday) |
startDate |
Date | First materialization date |
endDate |
Date? | After this date, the template auto-deactivates |
occurrencesRemaining |
Int? | Counts down to 0, then auto-deactivates |
nextRunDate |
Date | Always advances on each materialization (success or failure) |
lastRunAt |
DateTime? | Updated on every run attempt |
lastInvoiceId |
UUID? | ID of the most recent successfully materialized invoice |
lastRunError |
String? | Most recent failure message; null on success |
isActive |
Bool | Pause / resume toggle |
invoiceGenerationMode |
enum | DGI (default, submits to PAC) or PROFORM (draft) |
paymentTerms |
enum? | e.g. NET_30 |
salespersonId, projectId, officeId, codigoRetencion |
UUID/enum? | Copied onto each invoice |
note |
String? | Visible to client on the PDF (mirrors Invoice.note) |
observations |
String? | Internal-only — not copied to invoices |
emailsAdHoc |
[String!]? | Overrides client emails on materialized invoices |
adhocClientName, adhocClientRuc |
String? | Per-invoice ad-hoc client overrides |
lines: [RecurringInvoiceLine!]! |
async field | Line-item template |
RecurringInvoiceLine¶
| Field | Type |
|---|---|
id, recurringInvoiceId |
UUID |
lineNumber |
Int |
itemId, itemAdHoc |
UUID? / String? |
glAccountId |
UUID |
unitOfMeasureId |
UUID |
quantity, unitPrice, taxRate, discountRate, commissionRate |
Decimal |
cpbsCode, cpbsUnitOfMeasureId |
Int? / UUID? (required for government clients) |
note |
String? |
RecurringInvoiceInput (create)¶
All RecurringInvoice fields above except the read-only ones (nextRunDate, lastRunAt, lastInvoiceId, lastRunError, isActive). lines: [RecurringInvoiceLineInput!]! is required and must be non-empty. nextRunDate is initialized to startDate on the backend.
RecurringInvoiceUpdateInput¶
Same shape as RecurringInvoiceInput plus a required id. Updates replace lines wholesale (simpler than diffing, matches regular invoice edit semantics). If the new startDate is in the future relative to the current nextRunDate, the next run is pushed forward.
RecurringInvoiceLineInput¶
input RecurringInvoiceLineInput {
lineNumber: Int!
glAccountId: UUID!
unitOfMeasureId: UUID!
quantity: Decimal!
unitPrice: Decimal!
taxRate: Decimal! # ITBMS percentage, e.g. 7
discountRate: Decimal! # 0..100
commissionRate: Decimal = 0
itemId: UUID
itemAdHoc: String
cpbsCode: Int
cpbsUnitOfMeasureId: UUID
note: String
}
Behaviour to handle in the UI¶
- Form is a reduced invoice form + cadence section. Reuse the existing invoice line grid (item, qty, price, discount, ITBMS, description). The user does not pick a numeration: the materialized invoice receives its number from the active sequence at run time, like any normal invoice.
- Show
nextRunDateprominently in the list view alongside theisActivebadge. - Surface
lastRunErroras a row-level warning. When set, the most recent occurrence failed silently — give the user a "Run again" button (callsmaterializeRecurringInvoice) and a link to the failure detail. pauseRecurringInvoice/resumeRecurringInvoiceare quick toggles.skipRecurringInvoiceadvancesnextRunDateby one cadence without creating an invoice — useful when the user already billed the client manually that month.materializeRecurringInvoiceis the manual "Run now" button. It usesnextRunDateas the invoice date.- For quarterly cadences pre-populate
dayOfMonthfrom the pickedstartDateso users don't accidentally end up with float-day behavior. - Cadence picker should default to
frequency=MONTHLYwithintervalas a numeric input ("every N months"). The other frequencies are available but secondary.
Error states¶
| Backend behaviour | When | Frontend message |
|---|---|---|
ValueError("Recurring invoice must have at least one line") |
Create / update with empty lines | "Add at least one line item." |
ValueError("...quantity > 0") / ValueError("...unit_price cannot be negative") |
Bad line numbers | "Quantities must be positive; prices cannot be negative." |
ValueError("interval must be >= 1") |
Non-positive interval | "Repeat every N must be at least 1." |
ValueError("end_date must be on or after start_date") |
end_date < start_date | "End date must be after the start date." |
MissingRetentionCodeError (from invoice_mutation_service) |
Materializing for a retention-enabled client without codigoRetencion or client default |
"Set a retention code on the template or on the client." |
PacError (from PAC submission) |
PAC rejects the materialized invoice | Recorded on the template as lastRunError. Show as a warning banner with the message verbatim. |
Suggested UX flow¶
- List page — table of templates with: name, client, frequency description (e.g. "Cada 1 mes, día 15"),
nextRunDate, last status (success / failed / paused), and row actions (pause/resume, run now, edit, delete). - Create / edit form — split into:
- Header: name, client, AR account, payment terms, PAC mode, retention code, salesperson, project, office, ad-hoc client overrides, note, observations.
- Cadence: frequency, interval, day-of-month (when MONTHLY/QUARTERLY), start date, optional end date or occurrences-remaining cap.
- Lines: standard invoice line grid.
- Detail page — header, cadence summary, line items, "Generated invoices" history. The history list can be derived two ways: (a) follow
lastInvoiceIdfor the most recent, (b) query invoices filtered byclientId+ date range until we expose arecurringInvoiceIdlink onInvoice(see follow-ups). - Failure banner — if
lastRunErroris non-null, render at the top of the detail page with the error text, thelastRunAttimestamp, and a "Retry now" button.
Manual triggering & cron¶
- Daily fan-out (production):
uv run python -m app.admin.tasks.recurring_invoices_run_due_task. Wire this into Render's daily cron alongsiderecurring_journals_run_due_task. - Dry-run: add
--dry-runto count due templates per tenant without creating invoices. - From the UI (admin button):
runRecurringInvoicesDueTodaymutation. Returns the count of successfully materialized invoices for the current tenant.
Open follow-ups (not yet implemented)¶
Invoicedoes not yet carry arecurring_invoice_idsource link. Listing the history of invoices generated from a template currently means followinglastInvoiceId(most recent only). Add arecurring_invoice_idFK oninvoicesor a separateRecurringInvoiceRunaudit table to make full history queryable.- No retry queue for failed materializations — only the most recent error is kept on the template. If repeated PAC failures become a problem, store an append-only
recurring_invoice_runstable with(timestamp, status, invoice_id, error). - Period-close: unlike recurring journals (which interact with the period-close guard), invoices have their own date semantics. If/when a sales period close is introduced, revisit the materialization path.