Skip to content

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

Description

@nielsdrost7

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

  • invoices table has a nullable pdf_template varchar(100) column (stores the template slug, e.g. system/invoice/default or my-clone).
  • quotes table has a nullable pdf_template varchar(100) column.
  • Invoice and Quote edit forms have an optional PDF Template select field.
  • The dropdown lists templates from ReportTemplateStorage::list() — system templates + the current company's clones, invoice-type templates for invoices, quote-type for quotes.
  • When saved, the selected template slug is persisted on the invoice/quote record.
  • PDF generation resolution chain: document's pdf_template → company default (Setting) → system/<type>/default; blade template fallback until builder parity.
  • The template selection is visible and editable after saving the record.
  • Both Invoice and Quote models have pdf_template in $fillable.
  • 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

  • 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