From 05e8acc3b164e42b8424adad8ee484fe932316b0 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski <5760249+gdarko@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:01:15 +0200 Subject: [PATCH] feat(pdf): let payment receipts and reports be overridden too (#731) Only invoices and estimates could be customised. Payment receipts and all five reports were hardcoded to app.pdf.*, so changing them meant editing files inside the image -- and losing the edit on the next upgrade. Those documents have no template picker and no design to choose between, so overriding one is not a selection: it is a same-named file in storage/app/templates/pdf/{type}/ winning over the built-in. PdfTemplateUtils:: resolveView() is that rule, and it needs no setting, no column and no UI. resolveView asks View::exists rather than checking the storage disk. The disk and the view namespace are registered separately and could disagree about where custom templates live; asking the thing that will actually render removes that possibility. make:template covers the new types. Their names are not free, since an override replaces one specific document, so it validates against the real list -- 'payment' for payments, and the five report names -- and reports what is available when the name is wrong. Neither type gets a preview image written, having no picker to show one in. The payment preview route also went through the built-in view directly rather than the service, so ?preview ignored an override and rendered with none of the shared data. It goes through the service now, like invoices and estimates. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF --- .../Commands/CreateTemplateCommand.php | 84 ++++++++++++----- .../Report/CustomerSalesReportController.php | 10 ++- .../Report/ExpensesReportController.php | 10 ++- .../Report/ItemSalesReportController.php | 10 ++- .../Report/ProfitLossReportController.php | 10 ++- .../Report/TaxSummaryReportController.php | 10 ++- .../Controllers/Pdf/DocumentPdfController.php | 8 +- app/Services/Document/PaymentService.php | 7 +- app/Support/Pdf/PdfTemplateUtils.php | 23 +++++ .../Feature/Pdf/CustomTemplateCommandTest.php | 31 ++++++- tests/Feature/Pdf/PdfOverrideTest.php | 90 +++++++++++++++++++ 11 files changed, 258 insertions(+), 35 deletions(-) create mode 100644 tests/Feature/Pdf/PdfOverrideTest.php diff --git a/app/Console/Commands/CreateTemplateCommand.php b/app/Console/Commands/CreateTemplateCommand.php index 81c25dea..84d4d3be 100644 --- a/app/Console/Commands/CreateTemplateCommand.php +++ b/app/Console/Commands/CreateTemplateCommand.php @@ -11,12 +11,31 @@ use Illuminate\Support\Str; class CreateTemplateCommand extends Command { /** - * Document types that can be cloned. The --type option is checked against - * this rather than only the interactive prompt: passing an unsupported one - * used to skip the prompt and die on an uncaught FileNotFoundException - * further down, with a stack trace instead of a message. + * Types you pick a design for. A custom template is a new, separately + * selectable entry in the picker, so the name is yours to choose and the + * clone source is the first built-in. + * + * The --type option is checked against these rather than only the + * interactive prompt: passing an unsupported one used to skip the prompt and + * die on an uncaught FileNotFoundException further down, with a stack trace + * instead of a message. */ - private const TYPES = ['invoice', 'estimate']; + private const SELECTABLE_TYPES = ['invoice', 'estimate']; + + /** + * Types with no picker. A custom template here replaces the built-in outright + * (see PdfTemplateUtils::resolveView), so the name is not free: it has to + * match the document you are overriding. + */ + private const OVERRIDE_TYPES = [ + 'payment' => ['payment'], + 'reports' => ['expenses', 'profit-loss', 'sales-customers', 'sales-items', 'tax-summary'], + ]; + + private static function types(): array + { + return array_merge(self::SELECTABLE_TYPES, array_keys(self::OVERRIDE_TYPES)); + } /** * The name and signature of the console command. @@ -41,14 +60,14 @@ class CreateTemplateCommand extends Command $templateType = $this->option('type'); if (! $templateType) { - $templateType = $this->choice('Create a template for?', self::TYPES); + $templateType = $this->choice('Create a template for?', self::types()); } - if (! in_array($templateType, self::TYPES, true)) { + if (! in_array($templateType, self::types(), true)) { $this->error(sprintf( 'Unsupported template type "%s". Supported types: %s.', $templateType, - implode(', ', self::TYPES) + implode(', ', self::types()) )); return self::INVALID; @@ -60,13 +79,30 @@ class CreateTemplateCommand extends Command return self::INVALID; } + $isOverride = array_key_exists($templateType, self::OVERRIDE_TYPES); + + if ($isOverride && ! in_array($templateName, self::OVERRIDE_TYPES[$templateType], true)) { + $this->error(sprintf( + '"%s" is not a %s document. An override replaces a specific one, so the name must be one of: %s.', + $templateName, + $templateType, + implode(', ', self::OVERRIDE_TYPES[$templateType]) + )); + + return self::INVALID; + } + if (PdfTemplateUtils::customTemplateFileExists($templateType, sprintf('%s.blade.php', $templateName))) { $this->info('Template with given name already exists.'); return self::INVALID; } - $source = Storage::disk('views')->get("/app/pdf/{$templateType}/{$templateType}1.blade.php"); + // An override clones the document it replaces; a selectable template + // clones the first built-in design. + $sourceName = $isOverride ? $templateName : "{$templateType}1"; + + $source = Storage::disk('views')->get("/app/pdf/{$templateType}/{$sourceName}.blade.php"); // Point this template at its own copy of the shared partial before the // blanket namespace rewrite below catches it. Previously every custom @@ -91,23 +127,31 @@ class CreateTemplateCommand extends Command return self::FAILURE; } - PdfTemplateUtils::toCustomTemplateImageFile( - File::get(resource_path("static/img/PDF/{$templateType}1.png")), - $templateType, - $templateName, - ); + // Only selectable types need a preview: an override replaces one + // document outright and never appears in a picker. + if (! $isOverride) { + PdfTemplateUtils::toCustomTemplateImageFile( + File::get(resource_path("static/img/PDF/{$templateType}1.png")), + $templateType, + $templateName, + ); + } - PdfTemplateUtils::toCustomTemplateFile( - Storage::disk('views')->get("/app/pdf/{$templateType}/partials/table.blade.php"), - $templateType, - sprintf('partials/%s/table.blade.php', $templateName), - ); + $partial = "/app/pdf/{$templateType}/partials/table.blade.php"; + + if (Storage::disk('views')->exists($partial)) { + PdfTemplateUtils::toCustomTemplateFile( + Storage::disk('views')->get($partial), + $templateType, + sprintf('partials/%s/table.blade.php', $templateName), + ); + } // Repeating page header/footer, if the source template has one. Named // with the {template}_header / {template}_footer suffix the Gotenberg // driver looks for. foreach (['_header', '_footer'] as $suffix) { - $companion = "/app/pdf/{$templateType}/{$templateType}1{$suffix}.blade.php"; + $companion = "/app/pdf/{$templateType}/{$sourceName}{$suffix}.blade.php"; if (Storage::disk('views')->exists($companion)) { PdfTemplateUtils::toCustomTemplateFile( diff --git a/app/Http/Controllers/Company/Report/CustomerSalesReportController.php b/app/Http/Controllers/Company/Report/CustomerSalesReportController.php index 079f5fbe..a3e15ece 100644 --- a/app/Http/Controllers/Company/Report/CustomerSalesReportController.php +++ b/app/Http/Controllers/Company/Report/CustomerSalesReportController.php @@ -8,6 +8,7 @@ use App\Models\Company; use App\Models\CompanySetting; use App\Models\Currency; use App\Models\Customer; +use App\Support\Pdf\PdfTemplateUtils; use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -93,10 +94,15 @@ class CustomerSalesReportController extends Controller 'currency' => $currency, ]); - $pdf = Pdf::loadView('app.pdf.reports.sales-customers'); + // Renders a same-named file from storage/app/templates/pdf/reports/ + // when one exists, so a report can be overridden without a + // template picker it has no concept of. + $templatePath = PdfTemplateUtils::resolveView('reports', 'sales-customers'); + + $pdf = Pdf::loadView($templatePath); if ($request->has('preview')) { - return view('app.pdf.reports.sales-customers'); + return view($templatePath); } if ($request->has('download')) { diff --git a/app/Http/Controllers/Company/Report/ExpensesReportController.php b/app/Http/Controllers/Company/Report/ExpensesReportController.php index 62bfd3d3..eabca0cd 100644 --- a/app/Http/Controllers/Company/Report/ExpensesReportController.php +++ b/app/Http/Controllers/Company/Report/ExpensesReportController.php @@ -8,6 +8,7 @@ use App\Models\Company; use App\Models\CompanySetting; use App\Models\Currency; use App\Models\Expense; +use App\Support\Pdf\PdfTemplateUtils; use Carbon\Carbon; use Illuminate\Contracts\View\View; use Illuminate\Http\Request; @@ -91,10 +92,15 @@ class ExpensesReportController extends Controller 'to_date' => $to_date, 'currency' => $currency, ]); - $pdf = Pdf::loadView('app.pdf.reports.expenses'); + // Renders a same-named file from storage/app/templates/pdf/reports/ + // when one exists, so a report can be overridden without a + // template picker it has no concept of. + $templatePath = PdfTemplateUtils::resolveView('reports', 'expenses'); + + $pdf = Pdf::loadView($templatePath); if ($request->has('preview')) { - return view('app.pdf.reports.expenses'); + return view($templatePath); } if ($request->has('download')) { diff --git a/app/Http/Controllers/Company/Report/ItemSalesReportController.php b/app/Http/Controllers/Company/Report/ItemSalesReportController.php index eb363f02..33f1063c 100644 --- a/app/Http/Controllers/Company/Report/ItemSalesReportController.php +++ b/app/Http/Controllers/Company/Report/ItemSalesReportController.php @@ -8,6 +8,7 @@ use App\Models\Company; use App\Models\CompanySetting; use App\Models\Currency; use App\Models\InvoiceItem; +use App\Support\Pdf\PdfTemplateUtils; use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -78,10 +79,15 @@ class ItemSalesReportController extends Controller 'to_date' => $to_date, 'currency' => $currency, ]); - $pdf = Pdf::loadView('app.pdf.reports.sales-items'); + // Renders a same-named file from storage/app/templates/pdf/reports/ + // when one exists, so a report can be overridden without a + // template picker it has no concept of. + $templatePath = PdfTemplateUtils::resolveView('reports', 'sales-items'); + + $pdf = Pdf::loadView($templatePath); if ($request->has('preview')) { - return view('app.pdf.reports.sales-items'); + return view($templatePath); } if ($request->has('download')) { diff --git a/app/Http/Controllers/Company/Report/ProfitLossReportController.php b/app/Http/Controllers/Company/Report/ProfitLossReportController.php index 6dfdfbe6..3302a334 100644 --- a/app/Http/Controllers/Company/Report/ProfitLossReportController.php +++ b/app/Http/Controllers/Company/Report/ProfitLossReportController.php @@ -9,6 +9,7 @@ use App\Models\CompanySetting; use App\Models\Currency; use App\Models\Expense; use App\Models\Payment; +use App\Support\Pdf\PdfTemplateUtils; use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -86,10 +87,15 @@ class ProfitLossReportController extends Controller 'to_date' => $to_date, 'currency' => $currency, ]); - $pdf = Pdf::loadView('app.pdf.reports.profit-loss'); + // Renders a same-named file from storage/app/templates/pdf/reports/ + // when one exists, so a report can be overridden without a + // template picker it has no concept of. + $templatePath = PdfTemplateUtils::resolveView('reports', 'profit-loss'); + + $pdf = Pdf::loadView($templatePath); if ($request->has('preview')) { - return view('app.pdf.reports.profit-loss'); + return view($templatePath); } if ($request->has('download')) { diff --git a/app/Http/Controllers/Company/Report/TaxSummaryReportController.php b/app/Http/Controllers/Company/Report/TaxSummaryReportController.php index e19f722a..3430f572 100644 --- a/app/Http/Controllers/Company/Report/TaxSummaryReportController.php +++ b/app/Http/Controllers/Company/Report/TaxSummaryReportController.php @@ -8,6 +8,7 @@ use App\Models\Company; use App\Models\CompanySetting; use App\Models\Currency; use App\Models\Tax; +use App\Support\Pdf\PdfTemplateUtils; use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -81,10 +82,15 @@ class TaxSummaryReportController extends Controller 'currency' => $currency, ]); - $pdf = Pdf::loadView('app.pdf.reports.tax-summary'); + // Renders a same-named file from storage/app/templates/pdf/reports/ + // when one exists, so a report can be overridden without a + // template picker it has no concept of. + $templatePath = PdfTemplateUtils::resolveView('reports', 'tax-summary'); + + $pdf = Pdf::loadView($templatePath); if ($request->has('preview')) { - return view('app.pdf.reports.tax-summary'); + return view($templatePath); } if ($request->has('download')) { diff --git a/app/Http/Controllers/Pdf/DocumentPdfController.php b/app/Http/Controllers/Pdf/DocumentPdfController.php index 4c97ab2d..6222c18c 100644 --- a/app/Http/Controllers/Pdf/DocumentPdfController.php +++ b/app/Http/Controllers/Pdf/DocumentPdfController.php @@ -8,6 +8,7 @@ use App\Models\Invoice; use App\Models\Payment; use App\Services\Document\EstimateService; use App\Services\Document\InvoiceService; +use App\Services\Document\PaymentService; use Illuminate\Http\Request; class DocumentPdfController extends Controller @@ -15,6 +16,7 @@ class DocumentPdfController extends Controller public function __construct( private readonly InvoiceService $invoiceService, private readonly EstimateService $estimateService, + private readonly PaymentService $paymentService, ) {} public function invoice(Request $request, Invoice $invoice) @@ -38,7 +40,11 @@ class DocumentPdfController extends Controller public function payment(Request $request, Payment $payment) { if ($request->has('preview')) { - return view('app.pdf.payment.payment'); + // Through the service, so the preview gets the same shared data and + // the same custom-override resolution as the rendered receipt. This + // used to name the built-in view directly, so a preview ignored an + // override and rendered with no data at all. + return $this->paymentService->getPdfData($payment); } return $payment->getGeneratedPDFOrStream('payment'); diff --git a/app/Services/Document/PaymentService.php b/app/Services/Document/PaymentService.php index 9cac268a..5cfe2799 100644 --- a/app/Services/Document/PaymentService.php +++ b/app/Services/Document/PaymentService.php @@ -11,6 +11,7 @@ use App\Models\ExchangeRateLog; use App\Models\Invoice; use App\Models\Payment; use App\Services\Mail\CompanyMailConfigService; +use App\Support\Pdf\PdfTemplateUtils; use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -181,11 +182,13 @@ class PaymentService 'logo' => $logo ?? null, ]); + $templatePath = PdfTemplateUtils::resolveView('payment', 'payment'); + if (request()->has('preview')) { - return view('app.pdf.payment.payment'); + return view($templatePath); } - return Pdf::loadView('app.pdf.payment.payment'); + return Pdf::loadView($templatePath); } public function generateFromTransaction($transaction): Payment diff --git a/app/Support/Pdf/PdfTemplateUtils.php b/app/Support/Pdf/PdfTemplateUtils.php index 6a6c4776..66d2ed29 100644 --- a/app/Support/Pdf/PdfTemplateUtils.php +++ b/app/Support/Pdf/PdfTemplateUtils.php @@ -4,6 +4,7 @@ namespace App\Support\Pdf; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\View; use Illuminate\Support\Str; class PdfTemplateUtils @@ -106,6 +107,28 @@ class PdfTemplateUtils return array_values($formatted); } + /** + * The view to render for a document, preferring a custom override. + * + * Invoices and estimates let you pick between several designs, so their + * template is chosen per document. Payment receipts and reports have no + * chooser and no design to pick, so overriding one means dropping a + * same-named file into storage/app/templates/pdf/{type}/ and having it win. + * That keeps the whole feature to "the file exists, so use it" and needs no + * setting, column or picker. + */ + public static function resolveView(string $templateType, string $templateName): string + { + $custom = sprintf('pdf_templates::%s.%s', $templateType, $templateName); + + // View::exists rather than a disk check: the namespace is what actually + // renders, so asking it directly means the two cannot disagree about + // where custom templates live. + return View::exists($custom) + ? $custom + : sprintf('app.pdf.%s.%s', $templateType, $templateName); + } + /** * Returns custom template path * diff --git a/tests/Feature/Pdf/CustomTemplateCommandTest.php b/tests/Feature/Pdf/CustomTemplateCommandTest.php index 8541684c..6fea6c1b 100644 --- a/tests/Feature/Pdf/CustomTemplateCommandTest.php +++ b/tests/Feature/Pdf/CustomTemplateCommandTest.php @@ -63,15 +63,42 @@ test('the new template shows up in the picker', function () { /** * --type was never checked against the supported list. An unsupported value * skipped the interactive prompt and then died on an uncaught - * FileNotFoundException looking for e.g. payment1.blade.php. + * FileNotFoundException looking for a file that does not exist. */ test('an unsupported type is refused with a message rather than a stack trace', function () { - $exit = Artisan::call('make:template', ['name' => 'receipt', '--type' => 'payment']); + $exit = Artisan::call('make:template', ['name' => 'thing', '--type' => 'purchase-order']); expect($exit)->toBe(Command::INVALID) ->and(Artisan::output())->toContain('Unsupported template type'); }); +/** + * Payments and reports have no picker: a custom template replaces one specific + * document, so its name is not free. + */ +test('an override must be named after the document it replaces', function () { + $exit = Artisan::call('make:template', ['name' => 'my-receipt', '--type' => 'payment']); + + expect($exit)->toBe(Command::INVALID) + ->and(Artisan::output())->toContain('is not a payment document'); +}); + +test('it clones a payment receipt for overriding', function () { + $exit = Artisan::call('make:template', ['name' => 'payment', '--type' => 'payment']); + + expect($exit)->toBe(Command::SUCCESS) + ->and(File::exists(customTemplatePath('payment', 'payment.blade.php')))->toBeTrue() + // No picker, so no preview is written. + ->and(File::exists(customTemplatePath('payment', 'payment.png')))->toBeFalse(); +}); + +test('it clones each report for overriding', function (string $report) { + $exit = Artisan::call('make:template', ['name' => $report, '--type' => 'reports']); + + expect($exit)->toBe(Command::SUCCESS) + ->and(File::exists(customTemplatePath('reports', "{$report}.blade.php")))->toBeTrue(); +})->with(['expenses', 'profit-loss', 'sales-customers', 'sales-items', 'tax-summary']); + test('a name that would escape the templates directory is refused', function (string $name) { $exit = Artisan::call('make:template', ['name' => $name, '--type' => 'invoice']); diff --git a/tests/Feature/Pdf/PdfOverrideTest.php b/tests/Feature/Pdf/PdfOverrideTest.php new file mode 100644 index 00000000..7c59c5fb --- /dev/null +++ b/tests/Feature/Pdf/PdfOverrideTest.php @@ -0,0 +1,90 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); + + $user = User::find(1); + $this->company = $user->companies()->first(); + $this->withHeaders(['company' => $this->company->id]); + Sanctum::actingAs($user, ['*']); + + Storage::fake('pdf_templates'); + Storage::fake('public'); + + // The disk and the view namespace are registered separately, and only the + // disk is faked. Point the namespace at the same place so a template written + // here is the one Blade resolves. + View::addNamespace('pdf_templates', Storage::disk('pdf_templates')->path('')); + View::getFinder()->flush(); + + config(['pdf.driver' => 'dompdf']); + + $this->override = function (string $type, string $name, string $markup) { + $dir = Storage::disk('pdf_templates')->path($type); + File::ensureDirectoryExists($dir); + File::put("{$dir}/{$name}.blade.php", $markup); + View::getFinder()->flush(); + }; +}); + +test('the built-in view is used when nothing overrides it', function () { + expect(PdfTemplateUtils::resolveView('payment', 'payment'))->toBe('app.pdf.payment.payment'); + expect(PdfTemplateUtils::resolveView('reports', 'expenses'))->toBe('app.pdf.reports.expenses'); +}); + +test('a custom file takes over', function () { + ($this->override)('payment', 'payment', ''); + + expect(PdfTemplateUtils::resolveView('payment', 'payment'))->toBe('pdf_templates::payment.payment'); +}); + +test('an overridden payment receipt is what actually renders', function () { + ($this->override)('payment', 'payment', 'OVERRIDDEN RECEIPT {{ $payment->payment_number }}'); + + $payment = Payment::factory()->create(['company_id' => $this->company->id]); + + get("/payments/pdf/{$payment->unique_hash}?preview=true") + ->assertOk() + ->assertSee('OVERRIDDEN RECEIPT') + ->assertSee($payment->payment_number); +}); + +test('an overridden report is what actually renders', function () { + ($this->override)('reports', 'expenses', 'OVERRIDDEN REPORT for {{ $company->name }}'); + + get("/reports/expenses/{$this->company->unique_hash}?from_date=2020-01-01&to_date=2030-12-31&preview=true") + ->assertOk() + ->assertSee('OVERRIDDEN REPORT') + ->assertSee($this->company->name); +}); + +/** + * The override receives the same shared data the built-in does, so a custom file + * can use every variable the original template used. + */ +test('an overridden report still renders as a pdf', function () { + ($this->override)('reports', 'expenses', '{{ $currency->name }} {{ $from_date }}'); + + $response = get("/reports/expenses/{$this->company->unique_hash}?from_date=2020-01-01&to_date=2030-12-31"); + + $response->assertOk(); + expect($response->getContent())->toStartWith('%PDF-'); +});