From bd9602b1300d2ac440a46459abcc14e539af7e50 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski <5760249+gdarko@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:45:58 +0200 Subject: [PATCH] fix(pdf): stop an unresolvable template taking the PDF route down (#735) * fix(pdf): stop an unresolvable template taking the PDF route down RealisticDemoSeeder::seedEstimate() never set template_name, while seedInvoice() has always set invoice1. Every demo estimate therefore had '', so findFormattedTemplate() returned null and EstimateService did $template['custom'] on it -- a 500 on the estimate PDF route, on either driver, since the exception is thrown before a driver is reached. That is the "Unable to load document preview" people were seeing. The seeder now sets estimate1, but seeding was only how this surfaced. The stored name is validated when a document is saved through the UI and nowhere else: seeders, imports, recurring-invoice copies and rows predating that validation all bypass it, and a template can also be deleted from disk after the fact. A name that cannot be resolved should fall back to the default design, not take the route down. PdfTemplateUtils::resolveView() -- already the resolver for payment receipts and reports -- gains an optional fallback and tries each candidate as custom then built-in. Both document services collapse to a single call and can no longer index null. The fallback logs a warning, so a bad name stays visible rather than being silently swapped. Also casts two nulls in GeneratesPdfTrait: an address line or custom field that was never filled in reaches htmlspecialchars() and strtr() as null, which every PDF render was emitting a deprecation for on PHP 8.4 and would be an error on 9. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF * feat(pdf): a command that measures the two drivers against each other "The PDF looks different" has been diagnosed by eye every time, because nothing compares the renderers. Asserting on PDF bytes is useless and rendering through Gotenberg needs a live service, so the suite has never covered it. pdf:compare renders each stock template through both drivers and reports the page box, page count and the bounding box of the text on page one, then flags any template whose ink lands more than --tolerance points apart. It goes through the real document services, so it exercises the same shared view data and template resolution a request would. Two things it has to get right to be honest: Comparing designs means persisting the template choice -- InvoiceService reads it back with Invoice::find($id)->template_name, so assigning in memory silently compares the same design every row. The run happens inside a transaction that is always rolled back. Page numbers are turned off for the duration. They are a Chromium capability with no dompdf equivalent, so leaving them on puts ink at the foot of every Gotenberg page and drowns out every difference worth seeing -- which is exactly what the first run of this command did. Word positions come from poppler's pdftotext, which is on most dev machines but not in the app container; without it the command still compares page geometry and says what it could not check. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF * fix(pdf): let the two renderers agree on the items table, and drop the shim Two parts: the stock templates stop doing their own page margins, and the items table stops relying on a property that does not apply to it. Page margins. The templates carried their own via `html { margin-top: 50px }`, which predates page setup owning them. dompdf largely collapses that margin; Chromium honours it and adds it to the page box, so the same template came out 38px from the top on one renderer and 77px on the other. The html rule is gone and body is reset instead, which is what makes the page box agree. Headers that were positioned absolutely at a negative offset -- only possible because of that margin -- are back in flow. The items table. Every stock template sets `table { border-collapse: collapse }`, and CSS says padding does not apply to a table in that mode. dompdf applies it anyway; Chromium follows the spec and drops it, so the table's `padding: 0 30px` inset the content on one renderer and not the other. Measured in isolation: with border-collapse, content starts at x=24.0 on dompdf and x=1.5 on Chromium -- the full 30px. All of the table's spacing moves to .items-table-wrapper, a plain block both engines treat the same, using padding so nothing collapses through it either. Measured across the seven document templates, that closes the horizontal gap outright: xMin was 57 on dompdf against 37 on Chromium for five of them, and is now within 3pt on all seven. GotenbergStockTemplateCompatibility is removed. Its premise was that dompdf inflates declared line heights by 1.5x, and that does not hold: rendering the same text at 12px, 18px, 36px and unitless 1.0/1.5 through both engines gives line spacing within 0.5pt every time. It also applied its multiplier to the reports, where line-height 21px pairs with font sizes of 14, 16 and 20px -- so .report-footer-value at a 1.05 ratio was being blown out to 31.5px, half again taller than dompdf renders it. A residual vertical difference remains and is localised, not guessed at: it accumulates only in the address blocks, which are
-joined text emitted by getFormattedString() with an

in front. Reduced to that construct alone, Chromium steps 11.25pt per line -- exactly the declared line-height: 15px -- while dompdf steps 14.4pt. That needs deciding on its own terms rather than a global multiplier, so it is left visible and measurable via pdf:compare. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF * fix(pdf): restore the stock template design Two regressions from the page-setup work, both visible on the page. The coloured header band stopped bleeding to the paper edges. invoice2 and estimate2 are built around a full-width band, and it now sits in normal flow at the top of body, so it only reaches the edge when the page margin is nothing. #728 defaulted margins to 1.2cm on the reasoning that it matched dompdf's built-in default and so kept existing output unchanged. That was the wrong reference: the templates are drawn for a zero margin and carry their own 30px insets, and Gotenberg rendered them at zero before #728, which is the intended look. Margins now default to nothing. Setting one still works and is honoured by both drivers, at the cost of the band no longer reaching the edge. A bare `0` is valid CSS and the only length needing no unit, so CssLength and PdfPageSetup accept it -- without that the new default would have thrown on every render. The totals block was pushed in from the items table's right edge. Fixing the border-collapse padding problem moved the table's 30px inset onto a wrapper that contains the whole partial, so it stacked on the insets the hr (25px) and the totals container (25px) already had. Those two were always honoured by both renderers; only the table's own padding was not. The inset now lives on a div wrapping just the table, and the wrapper keeps vertical spacing only, which restores the original 30px/25px relationship rather than inventing a new one. Also drops the negative margin-bottom that pulled the addresses up into the band and hid "Bill to,", and removes a stray `bottom: 0px` on invoice1's .header-bottom-divider that combined with `top: 90px` to stretch the rule down the page. Checked by rendering, not only by measurement: invoice2 and invoice1 on both drivers now match the intended design. pdf:compare puts the two renderers within a few points horizontally on all seven documents, xMin 21-22 and xMax 564-575. The remaining vertical difference is the address-block line spacing documented earlier and is unchanged by this. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF * feat(demo): make the demo data actually demo the product The demo company had no address row, and Invoice::getCompanyAddress() returns false outright in that case, so every seeded document rendered with an empty company block -- the name only appeared because the header falls back to it when there is no logo. Several headline features had no demo data at all: zero tax types, zero notes, zero recurring invoices, zero custom fields. DemoSeeder, which the test suite and reset:app both run, now creates Acme Inc with a postal address, tax ids and a country -- the fields the default address format actually renders. The address is created through the relation, as CompaniesController does, so company_id is set and type/user_id/customer_id stay null: Company::address() is an unscoped hasOne, so anything else carrying that company_id would be picked up as the company's own. It also stops trusting currency id 1. Migration 2025_08_18 inserts Algerian Dinar via firstOrCreate() before any seeder runs, so on a fresh migrate+seed the demo priced everything in "DA". RealisticDemoSeeder already worked around this for itself; resolving USD by code fixes it at source for reset:app and the tests too. RealisticDemoSeeder gains a logo, two tax types, a notes library, custom fields and an active recurring invoice. Notes are seeded twice over on purpose: the library and a document's notes column are unrelated in this application -- there is no foreign key, and is_default only drives a badge in the settings list, so nothing pre-fills a document with one. Tax is applied at document level to most but not all documents, so the demo has a zero-rated example in it. The arithmetic is the caller's: the service layer trusts whatever amount it is handed rather than recomputing it, so tax is rounded once off the subtotal and carried through total, due_amount and every base_* twin -- miss due_amount and a paid invoice renders as part-paid. Custom fields are on Customer, the only model_type with a create/edit UI end to end. The PDF renders only model_type 'Item', which would add a column to the items table and disturb a layout that was just squared up across both drivers. The logo is a generated Acme mark rather than one of InvoiceShelf's own, which would read as InvoiceShelf billing the customer. Also documents both seeders in AGENTS.md. RealisticDemoSeeder was referenced nowhere outside database/seeders/, which is a poor place to keep the thing that makes the app look real. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF * fix(pdf): tighten the spacing the old absolute header left behind invoice2 and estimate2 carried three stacked top offsets -- content-wrapper's 60px margin plus address-container's 18px margin and 20px padding -- 98px of dead white between the coloured band and the first line of content. They existed because the band used to be position: absolute and out of flow, so everything below had to be pushed clear of where it visually sat. The band takes its own height now, so the compensation is just a gap. Collapsed to a single 32px. Only those two templates had it, which is the tell: they are exactly the two whose headers were absolutely positioned. Also pins the margins on the

the address formats emit. Left to the user-agent default it pushed the company column out of line with the Bill to / Ship to columns beside it, so the three column headings started at three different heights. They line up now. That h3 is also where the two renderers were measured drifting apart, and pinning it narrows invoice2 from 85.8pt to 72.0pt and estimate2 from 84.4 to 76.8. The templates without a coloured band barely move, which places the rest of the difference in the per-line spacing of the
-joined address lines rather than in the heading -- consistent with the isolated measurement earlier (dompdf 14.4pt per line against Chromium's 11.25pt) and still open. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF --- .env.example | 18 +- AGENTS.md | 8 + .../Commands/ComparePdfDriversCommand.php | 295 +++++++++++++++++ app/Rules/CssLength.php | 5 +- app/Services/Document/EstimateService.php | 3 +- app/Services/Document/InvoiceService.php | 3 +- app/Support/Pdf/PdfPageSetup.php | 24 +- app/Support/Pdf/PdfTemplateUtils.php | 45 ++- app/Traits/GeneratesPdfTrait.php | 7 +- config/pdf.php | 24 +- database/seeders/DemoSeeder.php | 50 ++- database/seeders/RealisticDemoSeeder.php | 303 +++++++++++++++++- database/seeders/assets/acme-inc-logo.png | Bin 0 -> 5976 bytes .../admin/components/settings/pdfPageSetup.ts | 16 +- .../app/pdf/estimate/estimate1.blade.php | 44 ++- .../app/pdf/estimate/estimate2.blade.php | 52 ++- .../app/pdf/estimate/estimate3.blade.php | 45 ++- .../app/pdf/estimate/partials/table.blade.php | 6 + .../views/app/pdf/invoice/invoice1.blade.php | 45 ++- .../views/app/pdf/invoice/invoice2.blade.php | 54 +++- .../views/app/pdf/invoice/invoice3.blade.php | 46 ++- .../app/pdf/invoice/partials/table.blade.php | 6 + .../views/app/pdf/payment/payment.blade.php | 22 +- .../views/app/pdf/reports/expenses.blade.php | 3 +- .../app/pdf/reports/profit-loss.blade.php | 3 +- .../app/pdf/reports/sales-customers.blade.php | 3 +- .../app/pdf/reports/sales-items.blade.php | 3 +- .../app/pdf/reports/tax-summary.blade.php | 3 +- .../Pdf/ComparePdfDriversCommandTest.php | 39 +++ tests/Feature/Pdf/DemoCompanyProfileTest.php | 83 +++++ tests/Feature/Pdf/PdfTemplateFallbackTest.php | 108 +++++++ tests/Unit/PdfPageSetupTest.php | 18 +- tests/Unit/PdfStockTemplatePageSetupTest.php | 113 +++++++ 33 files changed, 1347 insertions(+), 150 deletions(-) create mode 100644 app/Console/Commands/ComparePdfDriversCommand.php create mode 100644 database/seeders/assets/acme-inc-logo.png create mode 100644 tests/Feature/Pdf/ComparePdfDriversCommandTest.php create mode 100644 tests/Feature/Pdf/DemoCompanyProfileTest.php create mode 100644 tests/Feature/Pdf/PdfTemplateFallbackTest.php create mode 100644 tests/Unit/PdfStockTemplatePageSetupTest.php diff --git a/.env.example b/.env.example index f35eabc2..c0b7e662 100644 --- a/.env.example +++ b/.env.example @@ -22,17 +22,19 @@ TRUSTED_PROXIES="*" # Set true only if you fully trust all PDF HTML and need remote images/CSS. DOMPDF_ENABLE_REMOTE=false -# Page setup, applied by whichever driver renders. Sizes and margins are a -# number plus a unit (pt, px, pc, mm, cm, in). The 1.2cm margin default is -# dompdf's own, so both drivers lay out identically out of the box. Normally set -# under Settings -> PDF Generation; these are the defaults before anything is saved. +# Page setup, applied by whichever driver renders. Sizes are a number plus a +# unit (pt, px, pc, mm, cm, in); margins may also be a bare 0. Margins default to +# nothing because the stock templates carry their own insets and the header band +# only reaches the paper edge at zero. Page numbers need a bottom margin to draw +# in. Normally set under Settings -> PDF Generation; these are the defaults +# before anything is saved. # PDF_PAPER_WIDTH=210mm # PDF_PAPER_HEIGHT=297mm # PDF_ORIENTATION=portrait -# PDF_MARGIN_TOP=1.2cm -# PDF_MARGIN_RIGHT=1.2cm -# PDF_MARGIN_BOTTOM=1.2cm -# PDF_MARGIN_LEFT=1.2cm +# PDF_MARGIN_TOP=0 +# PDF_MARGIN_RIGHT=0 +# PDF_MARGIN_BOTTOM=0 +# PDF_MARGIN_LEFT=0 # Gotenberg (optional alternative PDF driver; the default is dompdf). # PDF_DRIVER=gotenberg diff --git a/AGENTS.md b/AGENTS.md index bc2c59b0..1321e5a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,14 @@ pnpm dev # Vite dev server only pnpm build # Production frontend build ``` +### Demo data +```bash +php artisan db:seed --class=DemoSeeder --force # demo user + Acme Inc, its address and settings +php artisan db:seed --class=RealisticDemoSeeder --force # ~100 records: customers, invoices with tax, estimates, payments, expenses, notes, a recurring invoice +``` + +`DemoSeeder` is what the test suite and `php artisan reset:app` run — keep it cheap. `RealisticDemoSeeder` is development-only and never runs in tests; it is what to seed when you want the app to look like a real install (a company with a logo and postal address, taxed documents, a populated notes library). + > **Local environment (preferred):** the repo ships a `./devenv` script — a Docker Compose wrapper for the full local stack. Run `./devenv` once for interactive setup (pick MySQL/PostgreSQL/SQLite, optional Gotenberg; it adds the `invoiceshelf.test` host entry), then drive it with `./devenv start | stop | shell | logs | rebuild | test | format`. App at http://invoiceshelf.test, Adminer at `:8080`, Mailpit at `:8025`; the compose files live in `docker/development/` and your choice is remembered in `.devenvconfig`. (`composer run dev` / `pnpm dev` above are the native, non-Docker alternative.) ### Testing diff --git a/app/Console/Commands/ComparePdfDriversCommand.php b/app/Console/Commands/ComparePdfDriversCommand.php new file mode 100644 index 00000000..a24b1165 --- /dev/null +++ b/app/Console/Commands/ComparePdfDriversCommand.php @@ -0,0 +1,295 @@ +gotenbergConfigured()) { + $this->components->error( + 'Gotenberg is not reachable at '.config('pdf.connections.gotenberg.host'). + '. Set GOTENBERG_HOST (and GOTENBERG_ALLOWED_PRIVATE_HOST) and try again.' + ); + + return self::FAILURE; + } + + $documents = $this->documents(); + + if ($documents === []) { + $this->components->error('No seeded documents to render. Run the demo seeder first.'); + + return self::FAILURE; + } + + $tolerance = (float) $this->option('tolerance'); + $only = $this->option('template'); + $rows = []; + $worst = 0.0; + + // Switching a document between designs has to be persisted to be seen by + // the services, so the whole run happens inside a transaction that is + // always rolled back. Nothing here should change the operator's data. + DB::beginTransaction(); + + try { + $this->compare($documents, $only, $tolerance, $rows, $worst); + } finally { + DB::rollBack(); + } + + $this->table(['template', 'dompdf', 'gotenberg', 'ink delta', ''], $rows); + + if (! $this->hasPdfToText()) { + $this->components->warn('pdftotext not found, so only page geometry was compared. Install poppler-utils for ink positions.'); + } + + return $worst <= $tolerance ? self::SUCCESS : self::FAILURE; + } + + private function compare(array $documents, array $only, float $tolerance, array &$rows, float &$worst): void + { + foreach ($documents as [$label, $render]) { + if ($only && ! in_array($label, $only, true)) { + continue; + } + + try { + $a = $this->measure($render('dompdf')); + $b = $this->measure($render('gotenberg')); + } catch (\Throwable $e) { + $rows[] = [$label, 'ERROR', substr($e->getMessage(), 0, 60), '', '']; + + continue; + } + + $delta = $this->delta($a, $b); + $worst = max($worst, $delta ?? 0.0); + + $rows[] = [ + $label, + $this->describe($a), + $this->describe($b), + $delta === null ? 'n/a' : sprintf('%.1fpt', $delta), + $delta === null ? '?' : ($delta <= $tolerance ? 'ok' : 'DIFFERS'), + ]; + } + } + + /** + * One closure per template, rendering it through whichever driver is named. + * Goes through the real services so the comparison exercises the same shared + * view data and template resolution a request would. + * + * @return list + */ + private function documents(): array + { + $documents = []; + + if ($invoice = Invoice::first()) { + foreach (['invoice1', 'invoice2', 'invoice3'] as $template) { + $documents[] = [$template, function (string $driver) use ($invoice, $template) { + // Persisted, not just assigned: InvoiceService re-reads the + // template with Invoice::find($id)->template_name, so an + // in-memory change is ignored and every row would compare the + // same design. Rolled back in handle(). + $invoice->forceFill(['template_name' => $template])->saveQuietly(); + + return $this->withDriver($driver, fn () => app(InvoiceService::class)->getPdfData($invoice)->output()); + }]; + } + } + + if ($estimate = Estimate::first()) { + foreach (['estimate1', 'estimate2', 'estimate3'] as $template) { + $documents[] = [$template, function (string $driver) use ($estimate, $template) { + $estimate->forceFill(['template_name' => $template])->saveQuietly(); + + return $this->withDriver($driver, fn () => app(EstimateService::class)->getPdfData($estimate)->output()); + }]; + } + } + + if ($payment = Payment::first()) { + $documents[] = ['payment', fn (string $driver) => $this->withDriver( + $driver, + fn () => app(PaymentService::class)->getPdfData($payment)->output() + )]; + } + + return $documents; + } + + private function withDriver(string $driver, callable $render): string + { + $previous = config('pdf.driver'); + $pageNumbers = config('pdf.page.page_numbers'); + + Config::set('pdf.driver', $driver); + + // Page numbers are a Chromium capability with no dompdf equivalent, so + // leaving them on guarantees a difference at the foot of every page and + // drowns out the ones worth seeing. Turned off for the comparison. + Config::set('pdf.page.page_numbers', false); + + try { + return $render(); + } finally { + Config::set('pdf.driver', $previous); + Config::set('pdf.page.page_numbers', $pageNumbers); + } + } + + /** + * Page box, page count, and the bounding box of all text on page one. + * + * @return array{width: float, height: float, pages: int, ink: ?array{0: float, 1: float, 2: float, 3: float}} + */ + private function measure(string $pdf): array + { + $file = tempnam(sys_get_temp_dir(), 'pdfcmp').'.pdf'; + file_put_contents($file, $pdf); + + try { + return [ + 'width' => $this->pageDimension($pdf, 0), + 'height' => $this->pageDimension($pdf, 1), + 'pages' => max(1, preg_match_all('/\/Type\s*\/Page[^s]/', $pdf)), + 'ink' => $this->hasPdfToText() ? $this->inkBox($file) : null, + ]; + } finally { + @unlink($file); + } + } + + /** + * Reads the first MediaBox out of the raw PDF, so page size needs no tooling. + */ + private function pageDimension(string $pdf, int $index): float + { + if (preg_match('/MediaBox\s*\[\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/', $pdf, $m)) { + return round((float) $m[3 + $index] - (float) $m[1 + $index], 1); + } + + return 0.0; + } + + /** + * @return ?array{0: float, 1: float, 2: float, 3: float} + */ + private function inkBox(string $file): ?array + { + $process = new Process(['pdftotext', '-bbox', '-f', '1', '-l', '1', $file, '-']); + $process->run(); + + if (! $process->isSuccessful()) { + return null; + } + + preg_match_all( + '/getOutput(), + $words, + PREG_SET_ORDER + ); + + if ($words === []) { + return null; + } + + return [ + min(array_map(fn ($w) => (float) $w[1], $words)), + min(array_map(fn ($w) => (float) $w[2], $words)), + max(array_map(fn ($w) => (float) $w[3], $words)), + max(array_map(fn ($w) => (float) $w[4], $words)), + ]; + } + + /** + * The largest edge-to-edge disagreement between the two ink boxes. Null when + * either side could not be measured. + */ + private function delta(array $a, array $b): ?float + { + if ($a['ink'] === null || $b['ink'] === null) { + return null; + } + + return round(max(array_map( + fn ($x, $y) => abs($x - $y), + $a['ink'], + $b['ink'] + )), 2); + } + + private function describe(array $m): string + { + $size = sprintf('%.0fx%.0f', $m['width'], $m['height']); + $pages = $m['pages'].'p'; + + if ($m['ink'] === null) { + return "{$size} {$pages}"; + } + + return sprintf('%s %s ink %.0f,%.0f-%.0f,%.0f', $size, $pages, ...$m['ink']); + } + + private function hasPdfToText(): bool + { + static $available = null; + + if ($available === null) { + $process = new Process(['which', 'pdftotext']); + $process->run(); + $available = $process->isSuccessful(); + } + + return $available; + } + + private function gotenbergConfigured(): bool + { + $host = rtrim((string) config('pdf.connections.gotenberg.host'), '/'); + + if ($host === '') { + return false; + } + + $context = stream_context_create(['http' => ['timeout' => 3, 'ignore_errors' => true]]); + + return @file_get_contents($host.'/health', false, $context) !== false; + } +} diff --git a/app/Rules/CssLength.php b/app/Rules/CssLength.php index 412c5475..ef41beef 100644 --- a/app/Rules/CssLength.php +++ b/app/Rules/CssLength.php @@ -14,7 +14,8 @@ use Illuminate\Contracts\Validation\ValidationRule; */ class CssLength implements ValidationRule { - public const PATTERN = '/^\d+(\.\d+)?(pt|px|pc|mm|cm|in)$/'; + /** A bare `0` is valid CSS and the only length that needs no unit. */ + public const PATTERN = '/^(0|\d+(\.\d+)?(pt|px|pc|mm|cm|in))$/'; public function validate(string $attribute, mixed $value, Closure $fail): void { @@ -23,7 +24,7 @@ class CssLength implements ValidationRule } if (! preg_match(self::PATTERN, trim($value))) { - $fail('The :attribute must be a number followed by pt, px, pc, mm, cm or in (e.g. "210mm").'); + $fail('The :attribute must be 0, or a number followed by pt, px, pc, mm, cm or in (e.g. "210mm").'); } } } diff --git a/app/Services/Document/EstimateService.php b/app/Services/Document/EstimateService.php index 2a9cc9d9..879f9069 100644 --- a/app/Services/Document/EstimateService.php +++ b/app/Services/Document/EstimateService.php @@ -195,8 +195,7 @@ class EstimateService 'taxes' => $taxes, ]); - $template = PdfTemplateUtils::findFormattedTemplate('estimate', $estimateTemplate, ''); - $templatePath = $template['custom'] ? sprintf('pdf_templates::estimate.%s', $estimateTemplate) : sprintf('app.pdf.estimate.%s', $estimateTemplate); + $templatePath = PdfTemplateUtils::resolveView('estimate', $estimateTemplate, 'estimate1'); if (request()->has('preview')) { return view($templatePath); diff --git a/app/Services/Document/InvoiceService.php b/app/Services/Document/InvoiceService.php index a302d3f9..d9d6051a 100644 --- a/app/Services/Document/InvoiceService.php +++ b/app/Services/Document/InvoiceService.php @@ -259,8 +259,7 @@ class InvoiceService 'taxes' => $taxes, ]); - $template = PdfTemplateUtils::findFormattedTemplate('invoice', $invoiceTemplate, ''); - $templatePath = $template['custom'] ? sprintf('pdf_templates::invoice.%s', $invoiceTemplate) : sprintf('app.pdf.invoice.%s', $invoiceTemplate); + $templatePath = PdfTemplateUtils::resolveView('invoice', $invoiceTemplate, 'invoice1'); if (request()->has('preview')) { return view($templatePath); diff --git a/app/Support/Pdf/PdfPageSetup.php b/app/Support/Pdf/PdfPageSetup.php index f706b9ac..571983a1 100644 --- a/app/Support/Pdf/PdfPageSetup.php +++ b/app/Support/Pdf/PdfPageSetup.php @@ -9,7 +9,9 @@ namespace App\Support\Pdf; * dompdf was pinned to whatever `config/dompdf.php` said and had no admin control * at all. The two also disagreed about margins: dompdf falls back to its own * stylesheet default of 1.2cm, Gotenberg was hardcoded to zero, so the same - * template came out differently depending on the driver. + * template came out differently depending on the driver. Both now default to + * nothing: the stock templates carry their own insets, and invoice2/estimate2 + * are built around a header band that only reaches the paper edge at margin 0. * * Dimensions are stored as CSS lengths because that is the only representation * both drivers take without loss. Gotenberg has no notion of named sizes, only @@ -45,10 +47,10 @@ final class PdfPageSetup width: self::length('pdf.page.paper_width', '210mm'), height: self::length('pdf.page.paper_height', '297mm'), orientation: config('pdf.page.orientation') === 'landscape' ? 'landscape' : 'portrait', - marginTop: self::length('pdf.page.margin_top', '1.2cm'), - marginRight: self::length('pdf.page.margin_right', '1.2cm'), - marginBottom: self::length('pdf.page.margin_bottom', '1.2cm'), - marginLeft: self::length('pdf.page.margin_left', '1.2cm'), + marginTop: self::length('pdf.page.margin_top', '0'), + marginRight: self::length('pdf.page.margin_right', '0'), + marginBottom: self::length('pdf.page.margin_bottom', '0'), + marginLeft: self::length('pdf.page.margin_left', '0'), ); } @@ -102,7 +104,13 @@ final class PdfPageSetup public static function toPoints(string $length): float { - if (! preg_match('/^(\d+(?:\.\d+)?)(pt|px|pc|mm|cm|in)$/', trim($length), $m)) { + $length = trim($length); + + if ($length === '0') { + return 0.0; + } + + if (! preg_match('/^(\d+(?:\.\d+)?)(pt|px|pc|mm|cm|in)$/', $length, $m)) { throw new \InvalidArgumentException("Invalid PDF page length: {$length}"); } @@ -129,9 +137,9 @@ final class PdfPageSetup $value = trim($value); - if (! preg_match('/^\d+(\.\d+)?(pt|px|pc|mm|cm|in)$/', $value)) { + if (! preg_match('/^(0|\d+(\.\d+)?(pt|px|pc|mm|cm|in))$/', $value)) { throw new \InvalidArgumentException( - "Invalid PDF page length for {$key}: \"{$value}\". Expected a number and a unit, e.g. \"210mm\"." + "Invalid PDF page length for {$key}: \"{$value}\". Expected 0, or a number and a unit, e.g. \"210mm\"." ); } diff --git a/app/Support/Pdf/PdfTemplateUtils.php b/app/Support/Pdf/PdfTemplateUtils.php index 66d2ed29..26a4d011 100644 --- a/app/Support/Pdf/PdfTemplateUtils.php +++ b/app/Support/Pdf/PdfTemplateUtils.php @@ -3,6 +3,7 @@ namespace App\Support\Pdf; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\View; use Illuminate\Support\Str; @@ -111,22 +112,44 @@ class PdfTemplateUtils * 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 + * template is chosen per document and $fallback is the design to use when + * that choice cannot be honoured. 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. + * + * The fallback matters because the stored name is not trustworthy. It is + * validated when a document is saved through the UI, but seeders, imports, + * recurring-invoice copies and rows predating that validation all bypass it + * — and an unresolvable name used to reach `$template['custom']` on null and + * take the whole PDF route down with a 500. */ - public static function resolveView(string $templateType, string $templateName): string + public static function resolveView(string $templateType, ?string $templateName, ?string $fallback = null): string { - $custom = sprintf('pdf_templates::%s.%s', $templateType, $templateName); + foreach (array_filter([$templateName, $fallback]) as $candidate) { + // 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. + foreach ([ + sprintf('pdf_templates::%s.%s', $templateType, $candidate), + sprintf('app.pdf.%s.%s', $templateType, $candidate), + ] as $view) { + if (View::exists($view)) { + if ($candidate !== $templateName) { + Log::warning('PDF template not found, falling back.', [ + 'type' => $templateType, + 'requested' => $templateName, + 'used' => $candidate, + ]); + } - // 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); + return $view; + } + } + } + + // Nothing resolved. Name the built-in path so the failure points at + // something real rather than at whatever was stored. + return sprintf('app.pdf.%s.%s', $templateType, $fallback ?? $templateName); } /** diff --git a/app/Traits/GeneratesPdfTrait.php b/app/Traits/GeneratesPdfTrait.php index ccbc9d32..f6ce684d 100644 --- a/app/Traits/GeneratesPdfTrait.php +++ b/app/Traits/GeneratesPdfTrait.php @@ -172,7 +172,10 @@ trait GeneratesPdfTrait } foreach ($fields as $key => $field) { - $fields[$key] = htmlspecialchars($field, ENT_QUOTES, 'UTF-8'); + // Cast: an address line, custom field or tax id that was never filled + // in arrives as null, and passing null here is deprecated in PHP 8.4 + // and an error in 9. Every PDF render was emitting these. + $fields[$key] = htmlspecialchars((string) $field, ENT_QUOTES, 'UTF-8'); } return $fields; @@ -182,7 +185,7 @@ trait GeneratesPdfTrait { $values = array_merge($this->getFieldsArray(), $this->getExtraFields()); - $str = nl2br(strtr($format, $values)); + $str = nl2br(strtr((string) $format, $values)); $str = preg_replace('/{(.*?)}/', '', $str); diff --git a/config/pdf.php b/config/pdf.php index 31308154..53d2b9d7 100644 --- a/config/pdf.php +++ b/config/pdf.php @@ -22,10 +22,18 @@ return [ | notation both drivers accept without loss — Gotenberg has no named sizes, | and dompdf's points array can express anything a name can. | - | The 1.2cm margin default is dompdf's own, from its user-agent stylesheet. - | Gotenberg used to be hardcoded to zero, so the same template came out - | edge-to-edge on one driver and inset on the other; matching dompdf keeps - | existing documents looking as they always have. + | Margins default to zero because the stock templates own their own spacing: + | they carry their own 30px insets, and invoice2/estimate2 are built around a + | header band that runs to the paper edge, which only bleeds when the page + | margin is nothing. A custom template owns its insets the same way. + | + | Note dompdf's user-agent stylesheet applies 1.2cm of its own unless a @page + | rule says otherwise, which DompdfDriver now always injects -- so zero here + | really is zero on both drivers. + | + | Setting a margin still works and is honoured by both, at the cost of the + | band no longer reaching the edge. Page numbers need a bottom margin to draw + | in, since Chromium renders the footer inside it. | */ @@ -33,10 +41,10 @@ return [ 'paper_width' => env('PDF_PAPER_WIDTH', '210mm'), 'paper_height' => env('PDF_PAPER_HEIGHT', '297mm'), 'orientation' => env('PDF_ORIENTATION', 'portrait'), - 'margin_top' => env('PDF_MARGIN_TOP', '1.2cm'), - 'margin_right' => env('PDF_MARGIN_RIGHT', '1.2cm'), - 'margin_bottom' => env('PDF_MARGIN_BOTTOM', '1.2cm'), - 'margin_left' => env('PDF_MARGIN_LEFT', '1.2cm'), + 'margin_top' => env('PDF_MARGIN_TOP', '0'), + 'margin_right' => env('PDF_MARGIN_RIGHT', '0'), + 'margin_bottom' => env('PDF_MARGIN_BOTTOM', '0'), + 'margin_left' => env('PDF_MARGIN_LEFT', '0'), /* * Repeat "page / total" at the foot of every page. Gotenberg only: diff --git a/database/seeders/DemoSeeder.php b/database/seeders/DemoSeeder.php index e80c95a1..dba53a1b 100644 --- a/database/seeders/DemoSeeder.php +++ b/database/seeders/DemoSeeder.php @@ -5,6 +5,8 @@ namespace Database\Seeders; use App\Facades\Hashids; use App\Models\Company; use App\Models\CompanySetting; +use App\Models\Country; +use App\Models\Currency; use App\Models\Customer; use App\Models\Setting; use App\Models\User; @@ -29,30 +31,40 @@ class DemoSeeder extends Seeder // Create demo company $company = Company::factory()->create([ - 'name' => 'Demo Company', + 'name' => 'Acme Inc', 'owner_id' => $user->id, - 'slug' => 'demo-company', + 'slug' => 'acme-inc', + 'vat_id' => 'US123456789', + 'tax_id' => '84-1234567', ]); $company->unique_hash = Hashids::connection(Company::class)->encode($company->id); $company->save(); app(CompanyService::class)->setupDefaults($company); + + $this->createCompanyAddress($company); $user->companies()->attach($company->id); BouncerFacade::scope()->to($company->id); $user->assign('owner'); + // Resolve USD by code rather than trusting an id. Migration + // 2025_08_18_101343 inserts Algerian Dinar via firstOrCreate() before any + // seeder runs, so on a fresh migrate+seed currency id 1 is DZD and the + // demo prices everything in "DA". + $currencyId = Currency::where('code', 'USD')->value('id') ?? 1; + // Set default user settings $user->setSettings([ 'language' => 'en', 'timezone' => 'UTC', 'date_format' => 'DD-MM-YYYY', - 'currency_id' => 1, // USD + 'currency_id' => $currencyId, ]); // Set company settings CompanySetting::setSettings([ - 'currency' => 1, + 'currency' => $currencyId, 'date_format' => 'DD-MM-YYYY', 'language' => 'en', 'timezone' => 'UTC', @@ -72,4 +84,34 @@ class DemoSeeder extends Seeder // Mark profile setup as complete Setting::setSetting('profile_complete', 'COMPLETED'); } + + /** + * Give the demo company a real postal address. + * + * Without one, Invoice::getCompanyAddress() returns false outright and the + * company block is omitted from every document — the name only appears + * because the template falls back to it when there is no logo. + * + * Created through the relation, as CompaniesController does, so company_id + * is set and type/user_id/customer_id stay null. That matters: Company's + * address() is an unscoped hasOne, so any address carrying this company_id + * would be picked up as the company's own. Customer addresses deliberately + * leave company_id null for the same reason. + * + * The fields chosen are the ones the default address format actually + * renders (CompanyService::setupDefaultSettings) — country comes from + * country_id via the relation, not a string. + */ + private function createCompanyAddress(Company $company): void + { + $company->address()->create([ + 'address_street_1' => '1180 Market Street', + 'address_street_2' => 'Suite 400', + 'city' => 'San Francisco', + 'state' => 'CA', + 'zip' => '94102', + 'phone' => '+1 415 555 0142', + 'country_id' => Country::where('code', 'US')->value('id'), + ]); + } } diff --git a/database/seeders/RealisticDemoSeeder.php b/database/seeders/RealisticDemoSeeder.php index e51837d7..1aeea2b1 100644 --- a/database/seeders/RealisticDemoSeeder.php +++ b/database/seeders/RealisticDemoSeeder.php @@ -5,10 +5,12 @@ namespace Database\Seeders; use App\Facades\Hashids; use App\Models\Address; use App\Models\AiConversation; +use App\Models\Company; use App\Models\CompanySetting; use App\Models\Country; use App\Models\Currency; use App\Models\Customer; +use App\Models\CustomField; use App\Models\Estimate; use App\Models\EstimateItem; use App\Models\Expense; @@ -16,8 +18,12 @@ use App\Models\ExpenseCategory; use App\Models\Invoice; use App\Models\InvoiceItem; use App\Models\Item; +use App\Models\Note; use App\Models\Payment; use App\Models\PaymentMethod; +use App\Models\RecurringInvoice; +use App\Models\Tax; +use App\Models\TaxType; use App\Models\Unit; use App\Models\User; use Carbon\Carbon; @@ -30,8 +36,9 @@ use RuntimeException; * * Populates the demo company with ~100 realistic records (8 customers, 12 * catalog items, 6 expense categories, 35 invoices, ~20 payments, 8 estimates, - * 15 expenses) so the AI chat assistant has meaningful data to query during - * local development. + * 15 expenses, 2 tax types, a notes library and a recurring invoice) so the app + * looks like a real install during local development, and so the AI chat + * assistant has meaningful data to query. * * This seeder is intentionally NOT wired into DatabaseSeeder and is NOT used * by the test suite (the minimal DemoSeeder remains in the test path to keep @@ -59,8 +66,12 @@ use RuntimeException; * AI tool queries like `get_company_stats(period=this_month)` vs * `get_company_stats(period=last_month)` return different numbers. * - * - Invoice totals are computed from line items, not random. No per-item - * tax or discount in v1 — the math is `total = sum(price * quantity)`. + * - Invoice totals are computed from line items, not random. Tax is applied + * at document level (tax_per_item = 'NO') to most but not all documents, + * computed once off the subtotal and carried through total, due_amount and + * every base_* twin — the service layer trusts the amount it is given + * rather than recomputing it, so the arithmetic is the caller's to get + * right. No per-item tax or discount. */ class RealisticDemoSeeder extends Seeder { @@ -91,21 +102,32 @@ class RealisticDemoSeeder extends Seeder private int $paymentSequence = 1; + /** @var array */ + private array $taxTypes = []; + + /** @var array */ + private array $invoiceNotes = []; + public function run(): void { $this->ensureReferenceData(); $this->resolveDemoContext(); $this->cleanupExistingDemoData(); + $this->seedCompanyLogo(); + $this->seedTaxTypes(); + $this->seedNotes(); + $this->seedCustomFields(); $this->seedCustomers(); $this->seedCatalogItems(); $this->seedExpenseCategories(); $this->seedInvoicesWithPayments(); $this->seedEstimates(); + $this->seedRecurringInvoice(); $this->seedExpenses(); $this->info(sprintf( - 'RealisticDemoSeeder done: %d customers, %d items, %d invoices (%d overdue, %d paid, %d partially_paid), %d payments, %d estimates, %d expenses.', + 'RealisticDemoSeeder done: %d customers, %d items, %d invoices (%d overdue, %d paid, %d partially_paid), %d payments, %d estimates, %d expenses, %d tax types, %d notes, %d recurring.', Customer::where('company_id', $this->companyId)->count(), Item::where('company_id', $this->companyId)->count(), Invoice::where('company_id', $this->companyId)->count(), @@ -115,6 +137,9 @@ class RealisticDemoSeeder extends Seeder Payment::where('company_id', $this->companyId)->count(), Estimate::where('company_id', $this->companyId)->count(), Expense::where('company_id', $this->companyId)->count(), + TaxType::where('company_id', $this->companyId)->count(), + Note::where('company_id', $this->companyId)->count(), + RecurringInvoice::where('company_id', $this->companyId)->count(), )); } @@ -226,6 +251,14 @@ class RealisticDemoSeeder extends Seeder Customer::whereIn('id', $customerIds)->delete(); Item::where('company_id', $this->companyId)->delete(); + + // Taxes cascade from their documents, but the reusable definitions and + // the standalone rows do not. + Tax::where('company_id', $this->companyId)->delete(); + RecurringInvoice::where('company_id', $this->companyId)->delete(); + TaxType::where('company_id', $this->companyId)->delete(); + Note::where('company_id', $this->companyId)->delete(); + CustomField::where('company_id', $this->companyId)->delete(); } private function seedCustomers(): void @@ -406,7 +439,13 @@ class RealisticDemoSeeder extends Seeder ]; } - $total = $subTotal; + // Tax is computed once off the subtotal, not per line, and then has to be + // carried through total, due_amount and every base_* twin. Miss due_amount + // and a fully paid invoice renders as part-paid. + $taxType = $this->taxTypeForDocument($this->invoiceSequence); + $taxAmount = $taxType ? (int) round($subTotal * $taxType->percent / 100) : 0; + + $total = $subTotal + $taxAmount; $dueAmount = match ($paidStatus) { Invoice::STATUS_PAID => 0, Invoice::STATUS_PARTIALLY_PAID => (int) round($total * 0.6), // 40% paid, 60% still due @@ -433,13 +472,13 @@ class RealisticDemoSeeder extends Seeder 'discount_val' => 0, 'sub_total' => $subTotal, 'total' => $total, - 'tax' => 0, + 'tax' => $taxAmount, 'due_amount' => $dueAmount, 'exchange_rate' => 1, 'base_discount_val' => 0, 'base_sub_total' => $subTotal, 'base_total' => $total, - 'base_tax' => 0, + 'base_tax' => $taxAmount, 'base_due_amount' => $dueAmount, 'currency_id' => $this->currencyId, 'customer_id' => $customer->id, @@ -448,7 +487,7 @@ class RealisticDemoSeeder extends Seeder 'creator_id' => $this->user->id, 'sent' => $status !== Invoice::STATUS_DRAFT, 'viewed' => in_array($status, [Invoice::STATUS_VIEWED, Invoice::STATUS_COMPLETED], true), - 'notes' => null, + 'notes' => $this->invoiceNotes === [] ? null : $this->invoiceNotes[$this->invoiceSequence % count($this->invoiceNotes)], ]); // Touch timestamps to match the invoice_date so tool queries like @@ -483,6 +522,10 @@ class RealisticDemoSeeder extends Seeder ]); } + if ($taxType !== null) { + $this->applyDocumentTax($invoice, $taxType, $taxAmount, 'invoice_id'); + } + // Back-fill payments for PAID and PARTIALLY_PAID invoices. if ($paidStatus === Invoice::STATUS_PAID) { $this->createPayment($invoice, $total, $invoiceDate->copy()->addDays(random_int(3, 25))); @@ -562,6 +605,12 @@ class RealisticDemoSeeder extends Seeder $lines[] = ['item' => $item, 'quantity' => $quantity, 'line_total' => $lineTotal]; } + // Same arithmetic as createInvoice(): one rounding off the subtotal, + // then carried through total and the base_* twins. + $taxType = $this->taxTypeForDocument($this->estimateSequence); + $taxAmount = $taxType ? (int) round($subTotal * $taxType->percent / 100) : 0; + $total = $subTotal + $taxAmount; + $estimateNumber = 'EST-'.str_pad((string) $this->estimateSequence, 6, '0', STR_PAD_LEFT); $this->estimateSequence++; @@ -569,6 +618,9 @@ class RealisticDemoSeeder extends Seeder 'estimate_date' => $estimateDate->toDateString(), 'expiry_date' => $expiryDate->toDateString(), 'estimate_number' => $estimateNumber, + // Without this the estimate has no template and its PDF route 500s. + // seedInvoice() has always set it; the estimate side never did. + 'template_name' => 'estimate1', 'status' => $status, 'tax_per_item' => 'NO', 'tax_included' => false, @@ -577,13 +629,13 @@ class RealisticDemoSeeder extends Seeder 'discount' => 0, 'discount_val' => 0, 'sub_total' => $subTotal, - 'total' => $subTotal, - 'tax' => 0, + 'total' => $total, + 'tax' => $taxAmount, 'exchange_rate' => 1, 'base_discount_val' => 0, 'base_sub_total' => $subTotal, - 'base_total' => $subTotal, - 'base_tax' => 0, + 'base_total' => $total, + 'base_tax' => $taxAmount, 'currency_id' => $this->currencyId, 'customer_id' => $customer->id, 'company_id' => $this->companyId, @@ -592,6 +644,10 @@ class RealisticDemoSeeder extends Seeder 'notes' => null, ]); + if ($taxType !== null) { + $this->applyDocumentTax($estimate, $taxType, $taxAmount, 'estimate_id'); + } + // See seedInvoice(): the PDF routes bind on unique_hash. $estimate->unique_hash = Hashids::connection(Estimate::class)->encode($estimate->id); $estimate->created_at = $estimateDate; @@ -621,6 +677,227 @@ class RealisticDemoSeeder extends Seeder } } + /** + * Attach the demo company's logo. + * + * Without one the templates fall back to plain company-name text, which is + * fine but reads as an unfinished install. The asset is a generated Acme Inc + * mark rather than one of InvoiceShelf's own logos, which would make the + * demo look as though InvoiceShelf were billing the customer. + */ + private function seedCompanyLogo(): void + { + $company = Company::find($this->companyId); + $logo = database_path('seeders/assets/acme-inc-logo.png'); + + if (! file_exists($logo)) { + return; + } + + $company->clearMediaCollection('logo'); + $company->addMedia($logo)->preservingOriginal()->toMediaCollection('logo'); + } + + /** + * Reusable tax definitions. + * + * type must be GENERAL: TaxTypesController::index() filters on it, so a + * MODULE row would be invisible in the admin UI. + */ + private function seedTaxTypes(): void + { + $definitions = [ + ['name' => 'Sales Tax', 'percent' => 8.5, 'description' => 'State and local sales tax'], + ['name' => 'Zero Rated', 'percent' => 0, 'description' => 'Exempt supplies'], + ]; + + foreach ($definitions as $definition) { + $this->taxTypes[] = TaxType::create([ + 'name' => $definition['name'], + 'percent' => $definition['percent'], + 'calculation_type' => 'percentage', + 'compound_tax' => false, + 'collective_tax' => false, + 'description' => $definition['description'], + 'type' => TaxType::TYPE_GENERAL, + 'company_id' => $this->companyId, + ]); + } + } + + /** + * The reusable notes library, plus the text those notes put on documents. + * + * These are two unrelated things in this application: a Note row is a + * snippet an operator inserts by hand, and a document's `notes` column is a + * plain string copied at that moment. There is no foreign key between them, + * and is_default only controls a badge in the settings list -- nothing + * pre-fills a new document with it. So the library is seeded for the + * settings screen, and the same text is put on documents separately. + */ + private function seedNotes(): void + { + $notes = [ + ['type' => 'Invoice', 'name' => 'Payment terms', 'notes' => 'Payment is due within 14 days. Late payments may incur a 1.5% monthly charge.', 'is_default' => true], + ['type' => 'Invoice', 'name' => 'Thank you', 'notes' => 'Thank you for your business. We appreciate the opportunity to work with you.', 'is_default' => false], + ['type' => 'Estimate', 'name' => 'Estimate validity', 'notes' => 'This estimate is valid for 30 days from the date of issue.', 'is_default' => true], + ['type' => 'Payment', 'name' => 'Receipt confirmation', 'notes' => 'Payment received with thanks. This receipt confirms the amount applied to your account.', 'is_default' => true], + ]; + + foreach ($notes as $note) { + Note::create($note + ['company_id' => $this->companyId]); + } + + $this->invoiceNotes = array_column( + array_filter($notes, fn ($note) => $note['type'] === 'Invoice'), + 'notes' + ); + } + + /** + * Custom fields on customers. + * + * Customer is the only model_type with a create/edit UI end to end, so a + * seeded field is both visible and editable. The PDF renders only + * model_type 'Item' fields, which would add a column to the items table and + * change a layout that was just squared up across both drivers -- left + * alone deliberately. + */ + private function seedCustomFields(): void + { + $fields = [ + ['name' => 'Account Manager', 'type' => 'Input', 'string_answer' => 'Dana Whitfield'], + ['name' => 'Contract Renewal', 'type' => 'Date', 'date_answer' => Carbon::now()->addMonths(8)->toDateString()], + ]; + + foreach ($fields as $order => $field) { + CustomField::create($field + [ + 'label' => $field['name'], + 'model_type' => 'Customer', + 'slug' => clean_slug('Customer', $field['name']), + 'is_required' => false, + 'order' => $order + 1, + 'company_id' => $this->companyId, + ]); + } + } + + /** + * One active recurring invoice, so the feature is not an empty screen. + * + * frequency is a five-field cron expression, not a keyword, and + * next_invoice_at has to be derived from it -- nothing computes that at read + * time. Line items hang off recurring_invoice_id, leaving invoice_id null + * until an invoice is actually generated. + */ + private function seedRecurringInvoice(): void + { + $customer = $this->customers[0]; + $items = collect($this->items)->random(2)->all(); + + $subTotal = 0; + foreach ($items as $item) { + $subTotal += $item->price; + } + + $taxType = $this->taxTypes[0]; + $tax = (int) round($subTotal * $taxType->percent / 100); + $total = $subTotal + $tax; + + $startsAt = Carbon::now()->startOfMonth()->addMonth(); + $frequency = '0 0 1 * *'; // monthly, on the first + + $recurring = RecurringInvoice::create([ + 'starts_at' => $startsAt, + 'send_automatically' => false, + 'customer_id' => $customer->id, + 'company_id' => $this->companyId, + 'creator_id' => $this->user->id, + 'status' => RecurringInvoice::ACTIVE, + 'next_invoice_at' => RecurringInvoice::getNextInvoiceDate($frequency, $startsAt), + 'frequency' => $frequency, + 'limit_by' => RecurringInvoice::NONE, + 'currency_id' => $this->currencyId, + 'exchange_rate' => 1, + 'tax_per_item' => 'NO', + 'discount_per_item' => 'NO', + 'tax_included' => false, + 'discount_type' => 'fixed', + 'discount' => 0, + 'discount_val' => 0, + 'sub_total' => $subTotal, + 'tax' => $tax, + 'total' => $total, + 'due_amount' => $total, + 'template_name' => 'invoice1', + 'notes' => $this->invoiceNotes[0] ?? null, + ]); + + foreach ($items as $item) { + InvoiceItem::create([ + 'item_id' => $item->id, + 'name' => $item->name, + 'description' => $item->description, + 'price' => $item->price, + 'quantity' => 1, + 'total' => $item->price, + 'discount_type' => 'fixed', + 'discount' => 0, + 'discount_val' => 0, + 'tax' => 0, + 'recurring_invoice_id' => $recurring->id, + 'company_id' => $this->companyId, + 'exchange_rate' => 1, + 'base_price' => $item->price, + 'base_discount_val' => 0, + 'base_tax' => 0, + 'base_total' => $item->price, + ]); + } + + $this->applyDocumentTax($recurring, $taxType, $tax, 'recurring_invoice_id'); + } + + /** + * Attach a document-level tax row. + * + * The service layer trusts whatever `amount` it is given rather than + * recomputing it, so the caller owns the arithmetic and has to keep the + * document's own tax/total columns in step. Every row must point at a real + * TaxType: TaxResource dereferences it without a null check. + */ + private function applyDocumentTax(object $document, TaxType $taxType, int $amount, string $foreignKey): void + { + Tax::create([ + 'tax_type_id' => $taxType->id, + $foreignKey => $document->id, + 'company_id' => $this->companyId, + 'name' => $taxType->name, + 'calculation_type' => 'percentage', + 'percent' => $taxType->percent, + 'amount' => $amount, + 'compound_tax' => false, + 'exchange_rate' => 1, + 'base_amount' => $amount, + 'currency_id' => $this->currencyId, + ]); + } + + /** + * The tax type to apply to a document, or null for an untaxed one. + * + * Most documents are taxed, but not all: a demo where every row looks the + * same shows less than one with a zero-rated example in it. + */ + private function taxTypeForDocument(int $sequence): ?TaxType + { + if ($this->taxTypes === [] || $sequence % 5 === 0) { + return null; + } + + return $this->taxTypes[0]; + } + private function seedExpenses(): void { // 15 expenses spread across categories and months diff --git a/database/seeders/assets/acme-inc-logo.png b/database/seeders/assets/acme-inc-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a60415f71da83c68293fe445649d523263bfa31d GIT binary patch literal 5976 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 OIY2{2SGiWvHvB)& string) { return { @@ -27,16 +27,20 @@ export function cssLength(t: (key: string) => string) { } } -/** Matches the defaults in config/pdf.php, including dompdf's own 1.2cm margin. */ +/** + * Matches the defaults in config/pdf.php. Margins are zero because the stock + * templates own their own spacing, and a full-bleed header only reaches the + * paper edge when the page margin is nothing. + */ export function pageSetupDefaults(): PdfPageSetup { return { pdf_paper_width: '210mm', pdf_paper_height: '297mm', pdf_orientation: 'portrait', - pdf_margin_top: '1.2cm', - pdf_margin_right: '1.2cm', - pdf_margin_bottom: '1.2cm', - pdf_margin_left: '1.2cm', + pdf_margin_top: '0', + pdf_margin_right: '0', + pdf_margin_bottom: '0', + pdf_margin_left: '0', pdf_page_numbers: false, } } diff --git a/resources/views/app/pdf/estimate/estimate1.blade.php b/resources/views/app/pdf/estimate/estimate1.blade.php index 3ad2a7cb..83a545d3 100644 --- a/resources/views/app/pdf/estimate/estimate1.blade.php +++ b/resources/views/app/pdf/estimate/estimate1.blade.php @@ -10,12 +10,7 @@ @@ -455,7 +479,7 @@
-
+
@include('app.pdf.estimate.partials.table')
diff --git a/resources/views/app/pdf/estimate/estimate2.blade.php b/resources/views/app/pdf/estimate/estimate2.blade.php index 1dc3b203..1f5074e0 100644 --- a/resources/views/app/pdf/estimate/estimate2.blade.php +++ b/resources/views/app/pdf/estimate/estimate2.blade.php @@ -9,12 +9,7 @@ @@ -459,7 +481,9 @@
- @include('app.pdf.estimate.partials.table') +
+ @include('app.pdf.estimate.partials.table') +
@if ($notes) diff --git a/resources/views/app/pdf/estimate/estimate3.blade.php b/resources/views/app/pdf/estimate/estimate3.blade.php index 7a81eca8..eea2b580 100644 --- a/resources/views/app/pdf/estimate/estimate3.blade.php +++ b/resources/views/app/pdf/estimate/estimate3.blade.php @@ -10,12 +10,7 @@ @@ -408,7 +433,9 @@
- @include('app.pdf.estimate.partials.table') +
+ @include('app.pdf.estimate.partials.table') +
@if ($notes) diff --git a/resources/views/app/pdf/estimate/partials/table.blade.php b/resources/views/app/pdf/estimate/partials/table.blade.php index 30d3dd13..06e3fc38 100644 --- a/resources/views/app/pdf/estimate/partials/table.blade.php +++ b/resources/views/app/pdf/estimate/partials/table.blade.php @@ -1,3 +1,8 @@ +{{-- Inset lives on this div, not on the table: the table sets + border-collapse: collapse, and padding does not apply to a table in + that mode. dompdf applies it anyway, Chromium follows the spec. The hr + and the totals block below carry their own insets already. --}} +
@@ -69,6 +74,7 @@ @endphp @endforeach
#
+

diff --git a/resources/views/app/pdf/invoice/invoice1.blade.php b/resources/views/app/pdf/invoice/invoice1.blade.php index 803f6aa1..c1e1e18f 100644 --- a/resources/views/app/pdf/invoice/invoice1.blade.php +++ b/resources/views/app/pdf/invoice/invoice1.blade.php @@ -10,12 +10,7 @@ @@ -393,7 +418,7 @@ @endif
-
+
@include('app.pdf.invoice.partials.table')
diff --git a/resources/views/app/pdf/invoice/invoice2.blade.php b/resources/views/app/pdf/invoice/invoice2.blade.php index 3d4c467f..2ff4937e 100644 --- a/resources/views/app/pdf/invoice/invoice2.blade.php +++ b/resources/views/app/pdf/invoice/invoice2.blade.php @@ -9,13 +9,7 @@ @@ -433,7 +453,9 @@
- @include('app.pdf.invoice.partials.table') +
+ @include('app.pdf.invoice.partials.table') +
@if ($notes) diff --git a/resources/views/app/pdf/invoice/invoice3.blade.php b/resources/views/app/pdf/invoice/invoice3.blade.php index 059fff35..bfd12ba1 100644 --- a/resources/views/app/pdf/invoice/invoice3.blade.php +++ b/resources/views/app/pdf/invoice/invoice3.blade.php @@ -11,12 +11,7 @@ /* -- Base -- */ body { - } - - html { margin: 0px; - padding: 0px; - margin-top: 50px; } table { @@ -31,9 +26,10 @@ /* -- Header -- */ .header-container { - margin-top: -30px; + position: relative; + margin-top: 0px; width: 100%; - padding: 0px 30px; + padding: 20px 30px 0px; } .header-logo { @@ -132,9 +128,23 @@ /* -- Items Table -- */ + /* The items table sets border-collapse: collapse, and padding does not + apply to a table in that mode. dompdf applies it anyway, Chromium + follows the spec and drops it, which put the two renderers 22.5pt + apart on each side. All of the table's spacing lives on this wrapper + instead -- a plain block, honoured identically by both. Padding rather + than margin so nothing collapses through it either. */ + .items-table-wrapper { + padding-top: 35px; + padding-bottom: 10px; + } + + .items-table-inset { + padding-left: 30px; + padding-right: 30px; + } + .items-table { - margin-top: 35px; - padding: 0px 30px 10px 30px; page-break-before: avoid; page-break-after: auto; } @@ -307,6 +317,20 @@ padding-left: 0; } + /* The address formats emit

{NAME}

ahead of the
-joined + lines. Left to the user-agent default its margins differ between + dompdf and Chromium -- the construct where the two renderers drift + apart vertically -- and the extra top margin also pushes the company + column out of line with the Bill to / Ship to columns beside it. + Pinning both margins fixes the alignment and removes the divergence. */ + .company-address h3, + .customer-address-container h3, + .billing-address h3, + .shipping-address h3 { + margin-top: 0; + margin-bottom: 6px; + } + @@ -369,7 +393,9 @@
- @include('app.pdf.invoice.partials.table') +
+ @include('app.pdf.invoice.partials.table') +
@if ($notes) diff --git a/resources/views/app/pdf/invoice/partials/table.blade.php b/resources/views/app/pdf/invoice/partials/table.blade.php index 80ae5db6..85704087 100644 --- a/resources/views/app/pdf/invoice/partials/table.blade.php +++ b/resources/views/app/pdf/invoice/partials/table.blade.php @@ -1,3 +1,8 @@ +{{-- Inset lives on this div, not on the table: the table sets + border-collapse: collapse, and padding does not apply to a table in + that mode. dompdf applies it anyway, Chromium follows the spec. The hr + and the totals block below carry their own insets already. --}} +
@@ -86,6 +91,7 @@ @endphp @endforeach
#
+

diff --git a/resources/views/app/pdf/payment/payment.blade.php b/resources/views/app/pdf/payment/payment.blade.php index 1155d00b..35de3b75 100644 --- a/resources/views/app/pdf/payment/payment.blade.php +++ b/resources/views/app/pdf/payment/payment.blade.php @@ -10,13 +10,7 @@ diff --git a/resources/views/app/pdf/reports/expenses.blade.php b/resources/views/app/pdf/reports/expenses.blade.php index c8f4d0fb..cd5ae356 100644 --- a/resources/views/app/pdf/reports/expenses.blade.php +++ b/resources/views/app/pdf/reports/expenses.blade.php @@ -7,6 +7,7 @@