Files
InvoiceShelf/AGENTS.md
Darko Gjorgjijoski bd9602b130 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 <br>-joined text emitted by
getFormattedString() with an <h3> 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 <h3> 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 <br>-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
2026-08-01 14:45:58 +02:00

16 KiB

AGENTS.md

Canonical guide for AI coding agents working in this repository. The tool-specific files (CLAUDE.md, GEMINI.md, .github/copilot-instructions.md) are gitignored symlinks to this file — run composer run ai-docs to (re)create them.

Project Overview

InvoiceShelf is an open-source invoicing and expense tracking application built with Laravel 13 (PHP 8.4) and Vue 3. It supports multi-company tenancy, customer portals, recurring invoices, and PDF generation.

Common Commands

Development

composer run dev          # Starts PHP server, queue listener, log tail, and Vite dev server concurrently
pnpm dev               # Vite dev server only
pnpm build             # Production frontend build

Demo data

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

php artisan test --compact                        # Run all tests
php artisan test --compact --filter=testName       # Run specific test
./vendor/bin/pest --stop-on-failure                # Run via Pest directly
make test                                          # Makefile shortcut

Tests use SQLite in-memory DB, configured in phpunit.xml. Tests seed via DatabaseSeeder + DemoSeeder in beforeEach. Authenticate with Sanctum::actingAs() and set the company header.

Code Style

vendor/bin/pint --dirty --format agent    # Fix style on modified PHP files
vendor/bin/pint --test                    # Check style without fixing (CI uses this)
composer lint        # = pint --test   ;  composer lint:fix = pint
pnpm lint         # eslint (--max-warnings 0)  ;  pnpm lint:fix = eslint --fix

Code Quality Gate (pre-commit hook)

A committed Git hook (.githooks/pre-commit) runs Pint on staged .php and ESLint on staged resources/scripts/** .{js,cjs,mjs,ts,vue} files, and blocks the commit on any failure (ESLint runs with --max-warnings 0). It lints staged files only, and soft-skips if PHP/Pint or node_modules is unavailable (CI is the backstop). The hook is enabled via core.hooksPath, set automatically by the prepare script on pnpm install; to enable it manually run:

git config core.hooksPath .githooks

Bypass intentionally (discouraged): git commit --no-verify. Intentional v-html is allowed via an inline <!-- eslint-disable-next-line vue/no-v-html --> with a reason.

Artisan Generators

Always use php artisan make:* with --no-interaction to create new files (models, controllers, migrations, tests, etc.).

Architecture

Multi-Tenancy

Every major model has a company_id foreign key. The CompanyMiddleware sets the active company from the company request header. Bouncer authorization is scoped to the company level via DefaultScope (app/Bouncer/Scopes/DefaultScope.php).

Roles

  • super admin — global platform admin (unscoped, manages all companies).
  • owner — company-level admin (scoped to a company via Bouncer, full access to that company).

Authentication

Three guards: web (session), api (Sanctum tokens for /api/v1/), customer (session for customer portal). API routes use auth:sanctum middleware; customer portal uses auth:customer.

Routing

  • API: All endpoints under /api/v1/ in routes/api.php, grouped with auth:sanctum, company, and bouncer middleware
  • Web: routes/web.php serves PDF endpoints, auth pages, and catch-all SPA routes (/admin/{vue?}, /{company:slug}/customer/{vue?})

Frontend

  • Vue 3 + TypeScript + Pinia + vue-router + Tailwind v4 (@tailwindcss/vite)
  • Entry point: resources/scripts/main.ts (single Vite input)
  • Feature-folder layout under resources/scripts/features/{admin,auth,company,customer-portal,...} — each feature owns its own routes.ts, views/, components/
  • Shared layers: resources/scripts/{api,stores,components,composables,layouts,plugins,utils,types,config}
  • Path aliases: @resources/ (so most imports look like @/scripts/api/client, @/scripts/stores/global.store); $fontsresources/static/fonts; $imagesresources/static/img. There is no @v2 alias — that was retired when the legacy v1 SPA was deleted.
  • i18n: lang/*.json are dynamically imported by resources/scripts/plugins/i18n.ts. Locale-code → filename mismatches (e.g. pt_BRpt-br.json) live in LOCALE_FILE_MAP. English is statically bundled; other locales lazy-load. Only edit lang/en.json directly — other locales are Crowdin-sourced.
  • Vite dev server expects the invoiceshelf.test hostname (configured in vite.config.js)

CSS Theme Tokens

The styling system uses Tailwind v4 with CSS custom properties as the source of truth — colors are not configured in JS, they live in CSS and are exposed to Tailwind via the @theme directive. Two files own this:

  1. resources/css/themes.css — defines every color as a CSS custom property on :root (light) and [data-theme="dark"] (dark). This is where you change actual values.
  2. resources/css/invoiceshelf.css — has an @theme inline { ... } block that registers each custom property as a Tailwind theme token (e.g. --color-heading: var(--color-heading);), making it available as utility classes (bg-heading, text-heading, border-heading, etc.). The block also uses the legacy @theme { --spacing-88: 22rem; --font-base: Poppins, sans-serif; } for non-color tokens.

Token categories defined today:

  • primary-{50…950} — brand color scale
  • surface, surface-secondary, surface-tertiary, surface-muted — background depth tiers
  • heading, body, muted, subtle — text emphasis tiers
  • line-{light,default,strong} — borders
  • hover, hover-strong — hover backgrounds
  • header-from, header-to — fixed header gradient stops (not dark-mode-aware)
  • btn-primary, btn-primary-hover — button colors (fixed, always bold)
  • status-{yellow,green,blue,red,purple} — status badge text colors
  • alert-{warning,error,success}-{bg,text} — alert variants

Dark mode is toggled via the [data-theme="dark"] attribute on the <html> element. The same custom-property names get redefined under that selector — components do not need dark: variants or conditional logic, they just reference the semantic tokens and the right value is picked up automatically.

Adding a new color token is a two-step ritual:

  1. Add the custom property to both :root and [data-theme="dark"] in themes.css
  2. Add a matching --color-X: var(--color-X); line inside the @theme inline block in invoiceshelf.css

After that the token is usable as bg-X / text-X / border-X in Vue templates and as var(--color-X) in raw CSS. Skip step 2 and the value exists at the CSS level but Tailwind utility classes won't be generated.

Convention — never hardcode hex/rgb values in components. Use the semantic tokens: text-heading not text-gray-900, bg-surface not bg-white, border-line-default not border-gray-300. Hardcoded values won't follow dark-mode flips and will diverge from the rest of the app over time. There are no exceptions in the project — even the auth pages (which sit outside the admin chrome) use the same bg-surface / text-heading / border-line-default vocabulary as BaseCard, just composed differently.

Backend Patterns

  • Authorization: Silber/Bouncer with policies in app/Policies/. Controllers use $this->authorize().
  • Validation: Form Request classes, never inline validation
  • API responses: Eloquent API Resources in app/Http/Resources/
  • PDF generation: Pluggable driver — dompdf (default, via GeneratesPdfTrait) or gotenberg (headless Chromium). Driver chosen per company through the PDF Generation admin settings page.
  • Email: Mailable classes with EmailLog tracking. Mail driver is configurable globally and may be overridden per-company.
  • File storage: Spatie MediaLibrary backed by the FileDisk model — admins create named disk entries (local / S3 / Dropbox / DigitalOcean Spaces) and assign them to purposes (media_storage, pdf_storage, backup_storage) in Admin → File Disks → Disk Assignments. New uploads go to the assigned disk; existing files stay where they were and require php artisan media:secure to migrate.
  • Serial numbers: SerialNumberService
  • Company settings: CompanySetting model (key-value per company)
  • User settings: User-level preferences (notably language) stored as JSON via setSettings(). The sentinel value 'default' means "inherit the company-level setting" — used for the per-user language preference so promoting/inviting members doesn't freeze a copy of the inviter's language.

PDF Font System

PDFs ship with bundled Noto Sans (Latin / Greek / Cyrillic) as the default face. Non-Latin scripts come from on-demand Font Packages managed in Admin → Font Packages and defined in FontService::FONT_PACKAGES (app/Services/FontService.php). Currently shipped packages: noto-sans (bundled, marker only), noto-sans-{sc,tc,jp,kr} (CJK), noto-sans-hebrew, noto-naskh-arabic (covers ar/fa/ur), noto-sans-devanagari (hi), sarabun (Thai). GeneratesPdfTrait::ensureFontsForLocale() synchronously installs the matching package on the first PDF render for a given company language.

Two non-obvious constraints when extending the font system:

  1. dompdf's PHP-Font-Lib does not parse variable fonts (fvar/gvar tables). Any new package must source static TTF files — Google Fonts' main repo ships variable fonts and produces empty boxes. Reliable static-TTF sources used today: openmaptiles/fonts for non-CJK Noto scripts, life888888/cjk-fonts-ttf for the CJK packages, google/fonts/ofl/sarabun for Thai.
  2. dompdf does not glyph-fall-back through the font-family chain — it uses the first font for ALL characters. So locale-specific packages must be the primary font for that locale, not a fallback. Selection happens in FontService::getFontFamilyForLocale(). This is also why a Latin-locale company with a Hebrew customer name will still render boxes for the Hebrew text — solving that needs Gotenberg or a custom mid-render font-switching pass.

The bundled NotoSans is also surfaced as a bundled: true package entry (no download URL, files served from resources/static/fonts/ instead of storage/fonts/) so it appears alongside the on-demand packages in the admin UI with a "Bundled" pill instead of an Install button.

Database

Supports MySQL, PostgreSQL, and SQLite — every migration and query must work on all three (the test suite runs on SQLite :memory:); no vendor-specific SQL. Prefer Eloquent over raw queries. Use Model::query() instead of DB::. Use eager loading to prevent N+1 queries.

Migrations — foreign keys are unsignedInteger, never foreignId(). Parent tables (users, companies, currencies, …) key on INT UNSIGNED, so every reference column must match that width: declare FKs as $table->unsignedInteger('company_id') plus an index. Don't use foreignId() — it's BIGINT, and a foreignId()->constrained() against an INT PK fails on MySQL 8 with error 3780 (type mismatch), which is exactly what breaks the v2→v3 upgrade.

  • No DB-level FK constraints for these refs — plain unsignedInteger columns + indexes; relationships and cascades are handled in app code, not via ->constrained() / cascadeOnDelete().
  • This is the codebase-wide convention (~27 migrations use unsignedInteger, only 2 use foreignId()). The one deliberate exception is ai_messages.conversation_id, which references the BIGINT ai_conversations.id and keeps ->constrained()->cascadeOnDelete() — that cascade is intentional and covered by AiChatFlowTest.

See PRs #618 / #683.

Service Pattern

All business logic must live in Service classes (app/Services/), not in Models or Controllers. Controllers are thin — they authorize, call the service, and return a response. Models only contain relationships, scopes, accessors, mutators, and constants. Services are injected via constructor injection.

Testing (TDD)

InvoiceShelf follows TDD development style:

  • Feature tests (tests/Feature/) — test API routes end-to-end (HTTP requests, responses, database assertions)
  • Unit tests (tests/Unit/) — test service classes and business logic in isolation
  • Write tests before or alongside implementation. Every new feature or bug fix must have tests.

Code Conventions

  • PHP: snake_case, constructor property promotion, explicit return types, PHPDoc blocks over inline comments
  • TS / Vue: camelCase, <script setup lang="ts">, prefer Composition API + Pinia stores over component-local state for anything cross-cutting
  • Always check sibling files for patterns before creating new ones
  • Use config() helper, never env() outside config files
  • Every change must have tests
  • Run vendor/bin/pint --dirty --format agent after modifying PHP files
  • After editing lang/en.json or any file under resources/scripts/, rebuild via pnpm build — the bundled chunks (including locale chunks) are content-hashed by Vite, so the browser will pick them up on hard refresh

Releasing

Releases are cut by pushing a tag. Nothing is typed into GitHub by hand.

# 1. Add a "## <version> — <date>" section to CHANGELOG.md and bump version.md,
#    in a PR like any other change — the notes are reviewed with the code.
# 2. Once merged, tag the merge commit:
git tag 3.0.0-alpha.2 && git push origin 3.0.0-alpha.2

release.yaml then runs the tests, reads the CHANGELOG.md section for that tag, builds the package with make clean dist, and creates a draft release with the zip attached. It stops there and prints the draft URL in the run summary.

You publish the draft yourself. That is deliberate, not an omission: GitHub does not start workflow runs from events created with GITHUB_TOKEN, so a release published by the workflow reaches nothing downstream. Pressing Publish fires release: published under your own identity, which triggers publish.yaml to register the release on the updater and build the Docker images. Until you publish, no install is offered anything.

Notes on the mechanics:

  • A tag with no CHANGELOG.md section fails the run before anything is built, so a release can never go out with empty notes.
  • prerelease and "mark as latest" are set on the draft, derived from the tag: a - suffix (3.0.0-alpha.2) means pre-release, which routes it to the insider channel so ordinary installs are not offered it. "Latest" is gated on LATEST_MAJOR in the workflows — bump it there when 3.x becomes the stable line.
  • If registration fails, re-run it without cutting a new release: run the Publish Release workflow manually with register_tag set to the version. That path is idempotent and does not rebuild the Docker images.
  • .github/scripts/changelog-section.php <version> prints what the updater will be sent, so you can check the notes locally before tagging.

CI Pipeline

GitHub Actions (check.yaml): runs Pint style check, then runs Pest tests in parallel (php artisan test --parallel) on PHP 8.4 with Xdebug disabled (coverage: none). The test job does not build the frontend — the suite is API/JSON only and never renders the Vite blade, so no Node/Vite step is needed (release/docker workflows still build assets in their own jobs).