Skip to content

[Invoices]: Ability to select from multiple invoice templates #411

Description

@nielsdrost7

Status 2026-07-13 — implemented (Phase 4, on feature/130-report-builder — PR: #608)

Implemented as planned with two as-built deviations, both simplifications:

  • No new columns were needed. The schema already had invoices.template, quotes.template, and companies.invoice_template / companies.quote_template — the implementation reuses them instead of adding pdf_template (zero migrations; models use $guarded = [], so no $fillable change either).
  • Resolution lives in one place: Modules/Core/Services/PdfGenerationService::resolveTemplate() handles both document types (document slug → company default column → system/<type>/default; company clones shadow same-slug system templates), rather than per-service duplicates in InvoiceService/QuoteService.

Delivered: "PDF Template" select on the invoice and quote forms (options from ReportTemplateStorage::optionsForType(), nullable with "Company default" placeholder); resolution-chain tests in PdfGenerationServiceTest (document slug wins, company default fallback, unknown/deleted slug falls through, system default floor) and picker tests in InvoiceTemplateSelectionTest (options per type, clone shadowing, persistence, clearing).


Planning Note — v2 Implementation (revised 2026-07-04 for the Report Builder)

Story
As a company admin, I want to choose a specific PDF template when creating or editing an invoice or quote, so that I can use different layouts for different clients or purposes (e.g. a formal template for corporate clients, a minimal template for freelance work) without changing the global setting each time.

v2 Context — deliver, the user clones
Templates are Report Builder templates (#130, epic #506): we ship system templates (system/invoice/default, system/quote/default) as files under storage/app/report_templates/, and users clone + customize them in the builder. There is no templates table — the dropdown lists templates by disk scan via ReportTemplateStorage (#528). The selected template is stored as a slug string on the document. Phase 4 of the roadmap: depends on #130 Phase 3 (ReportRenderer) and PR #575 (domPDF driver).

Acceptance Criteria (checked per as-built implementation — see status above)

  • The invoice's template slug is stored in a nullable string column (as-built: the pre-existing invoices.template column; no new pdf_template column was needed).
  • The quote's template slug is stored in a nullable string column (as-built: the pre-existing quotes.template column).
  • Invoice and Quote edit forms have an optional PDF Template select field.
  • The dropdown lists templates from ReportTemplateStorage — system templates + the current company's clones, invoice-type templates for invoices, quote-type for quotes (as-built: optionsForType()).
  • When saved, the selected template slug is persisted on the invoice/quote record.
  • PDF generation resolution chain: document's template slug → company default (as-built: companies.invoice_template / quote_template columns) → system/<type>/default.
  • The template selection is visible and editable after saving the record.
  • Both Invoice and Quote models mass-assign the column (as-built: $guarded = [], nothing to add).
  • A slug pointing at a deleted clone falls back down the chain (no 500).
  • Feature is covered by PHPUnit tests for both the service logic and the Filament form.

Implementation (as built)

  • Modules/Core/Services/PdfGenerationService.php: resolveTemplate(Invoice|Quote $document) — document slug → company default column → system default; company scope checked before system so clones shadow system templates
  • Modules/Core/Services/ReportTemplateStorage.php: optionsForType(ReportTemplateType $type) — slug ⇒ name options, clones shadowing system entries
  • Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php + Modules/Quotes/.../Schemas/QuoteForm.php: Select::make('template') with "Company default" placeholder
  • Tests: Modules/Core/Tests/Feature/PdfGenerationServiceTest.php (resolution chain), Modules/Core/Tests/Feature/InvoiceTemplateSelectionTest.php (picker + persistence)
Original planned implementation & test scenarios (2026-07-04 — superseded by as-built notes above)

Implementation

  • Module: Modules/Invoices/ and Modules/Quotes/
  • New migration: add_pdf_template_to_invoices_table — adds pdf_template varchar(100) nullable
  • New migration: add_pdf_template_to_quotes_table — adds pdf_template varchar(100) nullable
  • Modules/Invoices/Models/Invoice.php + Modules/Quotes/Models/Quote.php: add 'pdf_template' to $fillable
  • Modules/Invoices/Services/InvoiceService.php: resolveTemplate(Invoice $invoice): string$invoice->pdf_template if it exists on disk, else company default setting, else system/invoice/default; used by PDF generation
  • Modules/Quotes/Services/QuoteService.php: same resolveTemplate() pattern
  • Options source: ReportTemplateStorage::list($companyId, type: invoice|quote) mapped to ['slug' => 'Name'] (replaces the earlier blade-file glob() idea)
  • Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceSchema.php: Select::make('pdf_template')->options(...)->nullable()->placeholder('Use company default')
  • Same for Modules/Quotes/Filament/Company/Resources/Quotes/Schemas/QuoteSchema.php; admin panel schemas: same additions

Test Scenarios (PHPUnit)

// Extends AbstractCompanyPanelTestCase
#[Test]
public function it_saves_custom_pdf_template_on_invoice(): void
{
    $invoice = Invoice::factory()->for($this->company)->create();

    app(InvoiceService::class)->updateInvoice($invoice, ['pdf_template' => 'my-clone']);

    $this->assertDatabaseHas('invoices', ['id' => $invoice->id, 'pdf_template' => 'my-clone']);
}

#[Test]
public function it_resolves_per_invoice_template_when_set(): void
{
    // arrange: clone exists on the report_templates disk for this company
    $invoice = Invoice::factory()->for($this->company)->create(['pdf_template' => 'my-clone']);

    $this->assertEquals('my-clone', app(InvoiceService::class)->resolveTemplate($invoice));
}

#[Test]
public function it_falls_back_to_company_default_then_system_default(): void
{
    $invoice = Invoice::factory()->for($this->company)->create(['pdf_template' => null]);

    // no company default set → system default
    $this->assertEquals('system/invoice/default', app(InvoiceService::class)->resolveTemplate($invoice));
}

#[Test]
public function it_falls_back_when_selected_template_no_longer_exists(): void
{
    $invoice = Invoice::factory()->for($this->company)->create(['pdf_template' => 'deleted-clone']);

    $this->assertEquals('system/invoice/default', app(InvoiceService::class)->resolveTemplate($invoice));
}

#[Test]
public function it_renders_pdf_template_field_on_invoice_edit_form(): void
{
    $invoice = Invoice::factory()->for($this->company)->create(['pdf_template' => 'my-clone']);

    Livewire::actingAs($this->user)
        ->test(EditInvoice::class, [
            'record' => $invoice->getKey(),
            'tenant' => Str::lower($this->company->search_code),
        ])
        ->assertFormFieldHasValue('pdf_template', 'my-clone');
}

Equivalent QuoteService scenarios apply (per-quote override, fallback chain).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions