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).

Activity

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

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions