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 00000000..a60415f7 Binary files /dev/null and b/database/seeders/assets/acme-inc-logo.png differ diff --git a/resources/scripts/features/admin/components/settings/pdfPageSetup.ts b/resources/scripts/features/admin/components/settings/pdfPageSetup.ts index ef31e98b..21fed0d7 100644 --- a/resources/scripts/features/admin/components/settings/pdfPageSetup.ts +++ b/resources/scripts/features/admin/components/settings/pdfPageSetup.ts @@ -17,7 +17,7 @@ export const PAGE_SETUP_KEYS = [ ] as const /** Mirrors App\Rules\CssLength, so a bad value is caught before the round trip. */ -const CSS_LENGTH = /^\d+(\.\d+)?(pt|px|pc|mm|cm|in)$/ +const CSS_LENGTH = /^(0|\d+(\.\d+)?(pt|px|pc|mm|cm|in))$/ export function cssLength(t: (key: string) => 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 @@