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
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).
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 understorage/app/report_templates/, and users clone + customize them in the builder. There is no templates table — the dropdown lists templates by disk scan viaReportTemplateStorage(#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
invoicestable has a nullablepdf_templatevarchar(100) column (stores the template slug, e.g.system/invoice/defaultormy-clone).quotestable has a nullablepdf_templatevarchar(100) column.ReportTemplateStorage::list()— system templates + the current company's clones, invoice-type templates for invoices, quote-type for quotes.pdf_template→ company default (Setting) →system/<type>/default; blade template fallback until builder parity.InvoiceandQuotemodels havepdf_templatein$fillable.Implementation
Modules/Invoices/andModules/Quotes/add_pdf_template_to_invoices_table— addspdf_templatevarchar(100) nullableadd_pdf_template_to_quotes_table— addspdf_templatevarchar(100) nullableModules/Invoices/Models/Invoice.php+Modules/Quotes/Models/Quote.php: add'pdf_template'to$fillableModules/Invoices/Services/InvoiceService.php:resolveTemplate(Invoice $invoice): string—$invoice->pdf_templateif it exists on disk, else company default setting, elsesystem/invoice/default; used by PDF generationModules/Quotes/Services/QuoteService.php: sameresolveTemplate()patternReportTemplateStorage::list($companyId, type: invoice|quote)mapped to['slug' => 'Name'](replaces the earlier blade-fileglob()idea)Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceSchema.php:Select::make('pdf_template')->options(...)->nullable()->placeholder('Use company default')Modules/Quotes/Filament/Company/Resources/Quotes/Schemas/QuoteSchema.php; admin panel schemas: same additionsTest Scenarios (PHPUnit)
Equivalent
QuoteServicescenarios apply (per-quote override, fallback chain).