* 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
Takes over #690 by csoscd. The companion-view idea is theirs; this reworks it
onto the shared page setup and fills in the gaps that stopped it landing.
A `{template}_header` or `{template}_footer` view next to a template is rendered
alongside it and repeated by Chromium on every page. The suffix resolves through
the pdf_templates:: namespace too, so custom templates get it with no extra
wiring.
Two things had to change for that to be useful.
Companion views are now hidden from the template picker. getFormattedTemplates()
lists every .blade.php it finds, so an invoice1_footer would otherwise appear as
a separately selectable template with no preview image -- the feature would have
introduced that the moment anyone used it.
And it does something out of the box. #690 shipped no companion views, so both
of its margin settings were visible no-ops until someone hand-wrote a Blade file.
Instead there is a pdf_page_numbers setting, off by default, that supplies a
footer when a template has none. A template's own companion still wins, so
turning page numbers on cannot overwrite a designed footer.
The setting sits under Gotenberg because only Chromium can repeat a footer;
dompdf has no equivalent. Its value is still carried by the dompdf form so
saving from there cannot clear the choice -- the field is absent from that
payload, and the controller only writes it when present.
Margins come from the page setup rather than #690's separate header_margin and
footer_margin. Chromium draws header and footer inside the page margin, so the
existing margins are the space they occupy; two more settings for the same
distance would have been a second way to say the same thing.
Verified against a live gotenberg:8 on a two-page document: off produces no
footer, on produces "1/2" and "2/2" on the respective pages, and a companion
footer replaces both.
#690's own test is not carried over. It asserted nothing: a bare
View::shouldReceive('exists') is an allowance rather than an expectation,
andReturn(false) never entered the companion branch, and the call sat inside
try { } catch (Throwable) { }, so it passed with the feature deleted.
Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
The ImageUtils class was moved from App\Services\Pdf to App\Support\Pdf
but the blade templates were not updated to reflect this change.
This caused 'Class App\Services\Pdf\ImageUtils not found' errors when
generating PDFs for invoices, estimates, and payments.
Updated namespace in 7 blade template files:
- invoice1.blade.php
- invoice2.blade.php
- invoice3.blade.php
- estimate1.blade.php
- estimate2.blade.php
- estimate3.blade.php
- payment.blade.php
- MainLogo.vue: new 'receipt + check' mark + InvoiceShelf wordmark lockup
(monochrome, inherits the existing light/dark colour props — works in the
header, auth, installation, sidebar, loader, and customer portal).
- Favicons + PWA icons regenerated from the new mark (16/32/180/192/512/150 +
favicon.ico); safari-pinned-tab.svg redrawn to the new mark; mask-icon colour
#5851d8 -> #4a3dff.
- New brand SVG assets (logo-mark + variants + lockup) added under static/img;
logo-gray.png (public-invoice footer) regenerated as the mono mark.
The vestigial App\Services\Module\Module static class — with its unused
\$scripts / \$styles / \$settings registries — never had any of its helpers
wired up. The new InvoiceShelf\Modules\Registry shipped from the
invoiceshelf/modules package supersedes it cleanly: same static-array surface
(\$menu, \$settings, \$scripts, \$styles), but lives outside the host app so
third-party modules can depend on it without importing v3-app internals.
Three consumers in the host app are migrated to the new namespace:
- ScriptController and StyleController (the HTTP endpoints that serve
module-registered JS/CSS assets at /modules/scripts/{name} and
/modules/styles/{name}) now look up paths via Registry::scriptFor() and
Registry::styleFor() instead of Arr::get(ModuleFacade::all*(), \$name).
Also tightens type hints — Request import + Response return type.
- resources/views/app.blade.php iterates Registry::allStyles() /
Registry::allScripts() to inject module-supplied <link>/<script> tags into
the main layout. Same Akaunting-style asset injection mechanism, just
reading from the new namespace.
Both Module and ModuleFacade are deleted — they had no remaining callers
after this migration.
Ports the net behaviour from three master commits into v3.0 as a single change, because v3.0 has already diverged structurally (controller moved from V1/Admin/Report to Company/Report, blade has its own CSS rework using the bundled fonts partial, and v3.0's App\Facades\Pdf replaces Barryvdh\DomPDF\Facade\Pdf). The three source commits are: 834b53ea (grouped itemized expenses), e22050bc (DomPDF facade + Pint — adapted to v3.0's App\Facades\Pdf), 0e9f18d4 (expenses.uncategorized + pdf_expense_group_total_label i18n keys + View|Response return type).
Controller: replaces the expenseCategories aggregate fetch with an itemized Expense query ordered by date, groups by category name with expenses.uncategorized fallback, and shares an expenseGroups collection of {name, expenses, total} plus the overall totalExpense. Adds expense_category_id to applyFilters. Updates the docblock return type from JsonResponse to View|Response. Keeps v3.0's App\Facades\Pdf.
Blade: replaces the single expenseCategories aggregate table with a per-group itemized table (date / note / amount columns + per-group total line using the new pdf_expense_group_total_label i18n key). Adds the item-table-* CSS classes and removes the old expense-total-table bottom block.
lang/en.json: adds expenses.uncategorized = "Uncategorized" and pdf_expense_group_total_label = "Group total:".
Now that the legacy v1 frontend (commit 064bdf53) is gone, the v2 directory is the only frontend and the v2 suffix is just noise. Renames resources/scripts-v2 to resources/scripts via git mv (so git records the move as renames, preserving blame and log --follow), then bulk-rewrites the 152 files that imported via @v2/... to use @/scripts/... instead. The existing @ alias (resources/) covers the new path with no extra config needed.
Drops the now-unused @v2 alias from vite.config.js and points the laravel-vite-plugin entry at resources/scripts/main.ts. Updates the only blade reference (resources/views/app.blade.php) to match. The package.json test script (eslint ./resources/scripts) automatically targets the right place after the rename without any edit.
Verified: npm run build exits clean and the Vite warning lines now reference resources/scripts/plugins/i18n.ts, confirming every import resolved through the new path. git log --follow on any moved file walks back through its scripts-v2 history.
Closes the audit gaps from the original font system commit. The bundled NotoSans only covered Latin/Greek/Cyrillic but the descriptions claimed Arabic, Thai and Hindi too — that was false. DejaVu Sans, the prior dompdf default, did cover Hebrew, Arabic, Armenian and Georgian, so swapping it for NotoSans had silently regressed those scripts. The Thai conditional include was also dropped from every PDF template in that commit, leaving th locales rendering boxes despite THSarabunNew still sitting in resources/static/fonts/.
Adds four on-demand Font Packages — Noto Sans Hebrew, Noto Naskh Arabic (covering Arabic, Persian, Urdu, Sorani Kurdish), Noto Sans Devanagari (Hindi, Marathi, Sanskrit, Nepali) and Sarabun (Thai) — sourced from openmaptiles/fonts and google/fonts as static TTF. Static is mandatory because dompdf's PHP-Font-Lib does not parse variable fonts. Sarabun replaces THSarabunNew as the Thai face: same designer, OFL-licensed, maintained on a stable upstream URL, and surfaces through the same install flow as every other non-Latin script. The bundled THSarabunNew TTF files and the dead app/pdf/locale/th.blade.php legacy partial are removed as part of the migration.
Unifies the bundled Noto Sans into FONT_PACKAGES as a noto-sans entry with bundled => true and files served from resources/static/fonts/ instead of storage/fonts/. FontService::isInstalled, downloadPackage, getInstalledFontFaces and getPackageStatuses honor the flag through a new packageDir() helper. The hardcoded @font-face block in the PDF partial is gone — fonts.blade.php collapses to a single getInstalledFontFaces() call so the package array is the only source of truth for every face, bundled or on-demand. Admin → Font Packages now lists Noto Sans at the top with a primary-colored Bundled pill (new settings.fonts.bundled string) alongside the existing Installed badge / Install button states.
Also fixes the misleading settings.fonts.description and settings.fonts.bundled_info copy to actually describe what ships out of the box vs. what's optional, and rebuilds the en locale chunk.
Bundle Noto Sans (Regular/Bold/Italic/BoldItalic) under resources/static/fonts/ as the default PDF face — it covers Latin, Cyrillic, Greek, Arabic, Thai and Hindi out of the box, replacing the limited DejaVu Sans fallback. Move all @font-face declarations into app.pdf.partials.fonts and include it from every invoice/estimate/payment/report template, dropping per-template font-family hardcodes and the conditional Thai locale include.
Introduce FontService + FontController to download static Noto Sans CJK packages (zh, zh_CN, ja, ko) from life888888/cjk-fonts-ttf on demand. GeneratesPdfTrait::ensureFontsForLocale primes the family before rendering and the partial emits @font-face rules for installed packages so dompdf resolves them through standard CSS — no separate registerFont() instance required. Static TTFs are mandatory because dompdf's PHP-Font-Lib does not parse variable fonts (fvar/gvar tables), which is why Google Fonts' NotoSansTC[wght].ttf rendered empty boxes.
Expose status/install via /api/v1/fonts/status and /api/v1/fonts/{package}/install with matching FONTS_STATUS / FONTS_INSTALL constants in scripts-v2/api/endpoints.ts. Flip DOMPDF_ENABLE_REMOTE default to true for remote asset loading.
The @font-face URLs in resources/views/app/pdf/locale/th.blade.php pointed to resource_path('static/static/fonts/THSarabunNew*.ttf'), which does not exist on disk, so dompdf fell back to a default face when rendering Thai PDFs. Drop the duplicated segment so the bundled THSarabunNew TTFs resolve correctly.
Apply glassmorphism to sidebar, cards, tables, modals, dropdowns,
and dialogs with semi-transparent backgrounds, backdrop-blur, and
white/15 borders. Add subtle gradient body background for the blur
to work against. Add dedicated btn-primary color tokens so primary
buttons stay bold in dark mode instead of using the brightened text
palette. Use shadow-sm to avoid heavy halos in light mode.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Define 13 semantic color tokens (surface, text, border, hover) with
light/dark values in themes.css. Register with Tailwind via @theme inline.
Migrate all 335 Vue files from hardcoded gray/white classes to semantic
tokens. Add theme toggle (sun/moon/system) in user avatar dropdown.
Replace @tailwindcss/forms with custom form reset using theme vars.
Add status badge and alert tokens for dark mode. Theme-aware chart
grid/labels, skeleton placeholders, and editor. Inline script in
<head> prevents flash of wrong theme on load.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When inviting an email without an InvoiceShelf account, the email now
links to a registration page (/register?invitation={token}) instead of
login. After registering, the invitation is auto-accepted.
Backend:
- InvitationRegistrationController: public details() and register()
endpoints. Registration validates token + email match, creates account,
auto-accepts invitation, returns Sanctum token.
- AuthController: login now accepts optional invitation_token param to
auto-accept invitation for existing users clicking the email link.
- CompanyInvitationMail: conditional URL based on user existence.
- Web route for /invitations/{token}/decline (email decline link).
Frontend:
- RegisterWithInvitation.vue: fetches invitation details, shows company
name + role, registration form with pre-filled email.
- Router: /register route added.
Tests: 3 new tests (invitation details, register + accept, email mismatch).
Relocate all 14 files from the catch-all app/Space namespace into proper
locations: data providers to Support/Formatters, installation utilities to
Services/Installation, PDF utils to Services/Pdf, module/update classes to
Services/Module and Services/Update, SiteApi trait to Traits, and helpers
to Support.
Extract ~1,400 lines of business logic from 8 fat models (Invoice, Payment,
Estimate, RecurringInvoice, Company, Customer, Expense, User) into 9 new
service classes with constructor injection. Controllers now depend on
services instead of calling static model methods. Shared item/tax creation
logic consolidated into DocumentItemService.
* feat: Tax included
* Added a toggle switch in tax settings to enable the feature.
* Database migration adding tax_included field into estimates, invoices
and recurring invoices table.
* Toggle switch to enable and store the tax_included by estimates,
invoices and recurring invoices.
* In case of tax included enabled, total taxes will be recalculated and
the invoices, estimates and recurring invoices total won't be sum with
taxes.
* Apply tax included when discount_per_item/tax_per_item item is enabled.
* Custom component to show the net total when tax included is enabled.
* Update invoice and estimates pdfs with net total.
* chore: Tax included by default
A switch button inside the tax settings to enable the tax included by
default in invoices, estimates and recurring invoices.
* Possibility to set a fixed amount on tax types settings
* Pint and manage flat taxes on items
* Fix display errors and handle global taxes
* Tests
* Pint with PHP 8.2 cause with PHP 8.3 version it cause workflow error
* Merging percent and fixed amount into one column
* Now display the currency on SelectTaxPopup on fixed taxes
* Convert string references to `::class`
PHP 5.5.9 adds the new static `class` property which provides the fully qualified class name. This is preferred over using strings for class names since the `class` property references are checked by PHP.
* Use Faker methods
Accessing Faker properties was deprecated in Faker 1.14.
* Convert route options to fluent methods
Laravel 8 adopts the tuple syntax for controller actions. Since the old options array is incompatible with this syntax, Shift converted them to use modern, fluent methods.
* Adopt class based routes
* Remove default `app` files
* Shift core files
* Streamline config files
* Set new `ENV` variables
* Default new `bootstrap/app.php`
* Re-register HTTP middleware
* Consolidate service providers
* Re-register service providers
* Re-register routes
* Re-register scheduled commands
* Bump Composer dependencies
* Use `<env>` tags for configuration
`<env>` tags have a lower precedence than system environment variables making it easier to overwrite PHPUnit configuration values in additional environments, such a CI.
Review this blog post for more details on configuration precedence when testing Laravel: https://jasonmccreary.me/articles/laravel-testing-configuration-precedence/
* Adopt anonymous migrations
* Rename `password_resets` table
* Convert `$casts` property to method
* Adopt Laravel type hints
* Mark base controller as `abstract`
* Remove `CreatesApplication` testing trait
* Shift cleanup
* Fix shift first issues
* Updating Rules for laravel 11, sanctum config and pint
* Fix Carbon issue on dashboard
* Temporary fix for tests while migration is issue fixed on laravel side
* Carbon needs numerical values, not strings
* Minimum php version
* Fix domain installation step not fetching the correct company_id
* Fix Role Policy wasn't properly registered
---------
* add thai language config
* update thai translation
* update thai translation
* add THSarabunNew fonts to using in pdf
* update: pdf file to support thai language by checking isLocale('th') and include thai font-family
* update: thai translation
* remove the index.php file (not used)
* move THSarabunNew fonts to resoureces/static/fonts
* Add .gitkeep to storage/fonts to pre-build the fonts directory
Co-authored-by: Ritthikrai (Champ) Wiengchai <ritthikrai.w@aware.co.th>