Repository navigation
[Invoices]: Recurring invoice generation engine — materialize due invoices, advance next-run date, honor end date #641
Description
Part of Epic #52. The RecurringInvoice model (Modules/Invoices/Models/RecurringInvoice.php) is schedule-CRUD only: it stores invoice_id, numbering_id, frequency, start_at, end_at and is never read by any generator. There is no next_run_at, no job/command, and no scheduler registration anywhere in the app (Modules/Invoices/Jobs/ is empty; all registerCommandSchedules() bodies are commented out). This issue delivers the engine that turns schedules into real invoices.
Behavioral spec is InvoicePlane v1: application/modules/invoices/controllers/Cron.php (generation loop), application/modules/invoices/models/Mdl_invoices_recurring.php (active(), set_next_recur_date()), application/helpers/date_helper.php:194 (increment_date()).
Specific
Add a GenerateRecurringInvoicesService (Modules/Invoices/Services) with a generateDue(?Carbon $asOf = null): Collection method, plus the schema needed to drive it, so that every due recurring schedule produces exactly one new draft invoice per cycle.
Schema migration on recurring_invoices:
next_run_atDATE nullable — initialized tostart_aton create (v1:db_array['recur_next_date'] = recur_start_date,Mdl_invoices_recurring.php:110); NULL means stopped.last_run_atDATE nullable — set after each successful generation (idempotence audit).
Schema migration on invoices:
recurring_invoice_idFK nullable — provenance link from generated invoice back to its schedule (also unblocks [Invoices]: Recurring Invoices — filter by recurring status #143 and [Invoices]: Dashboard widget for Recurring Invoices #127 exactly as written).
Due selection (v1 active(), Mdl_invoices_recurring.php:138):
next_run_at <= today AND (end_at > today OR end_at IS NULL) — note v1 uses strict > on the end date: a schedule whose end_at is today does NOT generate.
Per due schedule, inside a DB transaction:
- Create an
Invoicefor the source invoice's customer withdate=next_run_at(not today),due_date=next_run_at+ the company's invoice-due terms, status = DRAFT,recurring_invoice_idset. Numbering comes fromrecurring_invoices.numbering_id(fallback: source invoice's numbering) and MUST respect thegenerate_invoice_number_for_draftsetting (see [Invoices]: Generate invoice number for draft recurring invoices #368). - Copy line items from
RecurringInvoiceItem(the profile's own item set — NOT from the source invoice; this is the v2 replacement for v1'sitem_is_recurringflag), including tax rates, quantities, prices, discounts, display order. - Advance
next_run_atby the schedule frequency (v1increment_date()semantics:DateIntervaladdition — e.g. monthly from Jan 31 → Mar 03 via PHP date math; document this and keep it deterministic) and setlast_run_at. - If
automatic_email_on_recuris enabled, dispatch the auto-email flow ([Invoices]: Automatically send invoice emails on status change #400); email failure must NOT roll back invoice creation or the next_run_at advance (v1 logs and continues,Cron.php:172-178).
Measurable
- Running the engine twice on the same day creates no duplicate invoices (idempotent via next_run_at advance inside the same transaction).
- A schedule whose
next_run_atis in the past by several cycles generates one invoice per run (v1 catch-up behavior: one at a time, next run picks up the next), and this behavior is documented in a test. - Schedules with
end_at <= todaynever generate. - All writes are company-scoped: the engine iterates schedules
withoutGlobalScopes()and stampscompany_idfrom each schedule row, never from session state.
Achievable
- New:
Modules/Invoices/Services/GenerateRecurringInvoicesService.php; migrations as above;next_run_atcolumn + status badge ("active" whennext_run_atset andend_atnull/future — v1 computedrecur_status,Mdl_invoices_recurring.php:61) added toRecurringInvoicesTable, default sort status thennext_run_atASC (v1default_order_by()). - Reuses:
InvoiceService/InvoiceCopyServicecreation paths,NumberingService, existingRecurringInvoiceItemdata. - Trigger wiring (artisan command + scheduler) is [Core]: Trigger recurring invoice scheduler via artisan command #395 — this issue only exposes the service API.
Relevant
This is the core promise of the Recurring-Invoices epic (#52 Scenario 2/6); without it the entire resource is dead weight and settings toggles (automatic_email_on_recur, cron_key) are decorative.
Time-Bound
One sprint; blocks #395, #400, #368, #362 and the schema-dependent parts of #127/#143.
Arrange
- Company with a customer, a source invoice, and a
RecurringInvoicewith 2RecurringInvoiceItemrows,frequency = monthly,start_at = next_run_at = today,end_at = null. - A second schedule with
end_at = today(must not fire) and a third withnext_run_at = tomorrow(must not fire).
Act
- Call
GenerateRecurringInvoicesService::generateDue(); then call it a second time the same day.
Assert
- Exactly one new invoice exists, status DRAFT,
recurring_invoice_idpointing at schedule 1; schedules 2 and 3 produced nothing. - The invoice's
dateequals the schedule's previousnext_run_at; due date offset matches company terms. - Invoice items match the 2
RecurringInvoiceItemrows (name, qty, price, tax rates, totals). - Schedule 1
next_run_atadvanced exactly one month;last_run_at = today. - Second run creates zero additional invoices.
- With
generate_invoice_number_for_draft = false, generated draft has empty/null number; with true, a number from the schedule's numbering group. - Recurring list shows status badge and next-run date; default sort active-first then next_run_at ASC.
- All generated data carries the schedule's
company_id(multi-company test with two companies each having a due schedule).
Activity