Skip to content

[Invoices]: Recurring invoice generation engine — materialize due invoices, advance next-run date, honor end date #641

Open

Description

@nielsdrost7

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_at DATE nullable — initialized to start_at on create (v1: db_array['recur_next_date'] = recur_start_date, Mdl_invoices_recurring.php:110); NULL means stopped.
  • last_run_at DATE nullable — set after each successful generation (idempotence audit).

Schema migration on invoices:

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:

  1. Create an Invoice for the source invoice's customer with date = next_run_at (not today), due_date = next_run_at + the company's invoice-due terms, status = DRAFT, recurring_invoice_id set. Numbering comes from recurring_invoices.numbering_id (fallback: source invoice's numbering) and MUST respect the generate_invoice_number_for_draft setting (see [Invoices]: Generate invoice number for draft recurring invoices #368).
  2. Copy line items from RecurringInvoiceItem (the profile's own item set — NOT from the source invoice; this is the v2 replacement for v1's item_is_recurring flag), including tax rates, quantities, prices, discounts, display order.
  3. Advance next_run_at by the schedule frequency (v1 increment_date() semantics: DateInterval addition — e.g. monthly from Jan 31 → Mar 03 via PHP date math; document this and keep it deterministic) and set last_run_at.
  4. If automatic_email_on_recur is 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_at is 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 <= today never generate.
  • All writes are company-scoped: the engine iterates schedules withoutGlobalScopes() and stamps company_id from each schedule row, never from session state.

Achievable

  • New: Modules/Invoices/Services/GenerateRecurringInvoicesService.php; migrations as above; next_run_at column + status badge ("active" when next_run_at set and end_at null/future — v1 computed recur_status, Mdl_invoices_recurring.php:61) added to RecurringInvoicesTable, default sort status then next_run_at ASC (v1 default_order_by()).
  • Reuses: InvoiceService/InvoiceCopyService creation paths, NumberingService, existing RecurringInvoiceItem data.
  • 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 RecurringInvoice with 2 RecurringInvoiceItem rows, frequency = monthly, start_at = next_run_at = today, end_at = null.
  • A second schedule with end_at = today (must not fire) and a third with next_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_id pointing at schedule 1; schedules 2 and 3 produced nothing.
  • The invoice's date equals the schedule's previous next_run_at; due date offset matches company terms.
  • Invoice items match the 2 RecurringInvoiceItem rows (name, qty, price, tax rates, totals).
  • Schedule 1 next_run_at advanced 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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    refinedRefined with SMART plan / test suggestions

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions