Skip to content

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 nextRunDate prominently in the list view alongside the isActive badge.
  • Surface lastRunError as a row-level warning. When set, the most recent occurrence failed silently — give the user a "Run again" button (calls materializeRecurringInvoice) and a link to the failure detail.
  • pauseRecurringInvoice / resumeRecurringInvoice are quick toggles.
  • skipRecurringInvoice advances nextRunDate by one cadence without creating an invoice — useful when the user already billed the client manually that month.
  • materializeRecurringInvoice is the manual "Run now" button. It uses nextRunDate as the invoice date.
  • For quarterly cadences pre-populate dayOfMonth from the picked startDate so users don't accidentally end up with float-day behavior.
  • Cadence picker should default to frequency=MONTHLY with interval as 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

  1. 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).
  2. Create / edit form — split into:
  3. Header: name, client, AR account, payment terms, PAC mode, retention code, salesperson, project, office, ad-hoc client overrides, note, observations.
  4. Cadence: frequency, interval, day-of-month (when MONTHLY/QUARTERLY), start date, optional end date or occurrences-remaining cap.
  5. Lines: standard invoice line grid.
  6. Detail page — header, cadence summary, line items, "Generated invoices" history. The history list can be derived two ways: (a) follow lastInvoiceId for the most recent, (b) query invoices filtered by clientId + date range until we expose a recurringInvoiceId link on Invoice (see follow-ups).
  7. Failure banner — if lastRunError is non-null, render at the top of the detail page with the error text, the lastRunAt timestamp, 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 alongside recurring_journals_run_due_task.
  • Dry-run: add --dry-run to count due templates per tenant without creating invoices.
  • From the UI (admin button): runRecurringInvoicesDueToday mutation. Returns the count of successfully materialized invoices for the current tenant.

Open follow-ups (not yet implemented)

  • Invoice does not yet carry a recurring_invoice_id source link. Listing the history of invoices generated from a template currently means following lastInvoiceId (most recent only). Add a recurring_invoice_id FK on invoices or a separate RecurringInvoiceRun audit 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_runs table 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.