mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-08-04 15:12:12 +00:00
8ae82ae91e093788cc02b5377e4db77ff4a255bb
12 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
773670c18f |
feat(pdf): archival PDF/A output and document properties (#732)
Generated files carried no document properties at all, so an archive of them showed a column of blank titles and no author. Title, Subject, Author and Creator are now written from the document number and company, on both drivers: dompdf via addInfo(), Gotenberg via metadata(). dompdf needed more than the API call. It reads Title from the <title> element during render(), which happens after addInfo(), so metadata set through the API alone was silently overwritten by whatever the template put there and the two drivers disagreed about what the file was called. The title is written into the markup as well, escaped. Also adds an archival format setting for Gotenberg: off, PDF/A-1b, -2b or -3b. PDF/A-3 is what the EU e-invoicing formats expect. Verified against a stock gotenberg:8 -- LibreOffice inside the image does the conversion and the output carries the right pdfaid:part in its XMP -- so no extra components are needed. A fixed list rather than free text, because the SDK forwards whatever it is given and an unsupported value would surface only as an HTTP error from the service at render time. Empty is a real choice meaning an ordinary PDF, so it overrides an env default rather than falling through it. Gotenberg only: dompdf cannot produce PDF/A. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF |
||
|
|
713e0bc2e8 |
feat(pdf): repeating page headers and footers, and page numbers (#729)
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 |
||
|
|
a54a5ee007 |
feat(pdf): one page setup, honoured by both drivers (#728)
Paper size was a Gotenberg-only setting stored as a single "210mm 297mm"
string. dompdf had no page settings at all: size was pinned to config/dompdf.php's
fixed 'a4', its top-level `orientation` key was read by nothing (the installed
barryvdh v3 builds options only from `defines`), and margins were whatever
dompdf's own stylesheet said. So the two drivers disagreed about margins by
default -- dompdf 1.2cm, Gotenberg hardcoded to zero -- and selecting dompdf
silently discarded the paper size.
Replaces gotenberg_papersize with pdf_paper_width / pdf_paper_height /
pdf_orientation / pdf_margin_{top,right,bottom,left}, saved and applied for
either driver. Width and height are separate CSS lengths because that is the
only lossless shared notation: Gotenberg has no named sizes, and dompdf's named
table cannot express everything Gotenberg accepts. Named presets (A3/A4/A5/
Letter/Legal) are a convenience in the UI that resolve to a pair of lengths.
PdfPageSetup resolves it once and translates: a points array plus an orientation
argument for dompdf, CSS lengths plus landscape() for Gotenberg. Both are handed
the portrait pair, since each swaps the axes itself. Gotenberg's margins() takes
top, bottom, left, right, which is not the CSS order.
dompdf exposes no margin API, so DompdfDriver injects an @page rule -- at the
top of <head>, so a template declaring its own still wins. Doing it in the driver
rather than a Blade partial means custom templates get it without including
anything.
Margins default to 1.2cm, dompdf's existing default, so Gotenberg starts
matching it rather than rendering edge-to-edge. Verified against a live
gotenberg:8: A4 portrait, A4 landscape and Letter at zero margins all come out
with the same page box and the same ink offsets on both drivers.
A malformed length now throws rather than being ignored. Blank still falls back,
but a value that is set and wrong is an operator mistake, and the drivers would
otherwise fail differently: dompdf throws converting to points, Gotenberg would
forward the string and render at some other size.
Also here:
- Migration splits an existing gotenberg_papersize into the new pair. It earns
its place because that key ships in 2.x, not just a 3.x alpha, so a stable
install that chose Letter would otherwise come back up on A4. Drops
gotenberg_margins, which 2.x also stores and neither driver ever read.
- Removes EnvironmentManager::savePDFVariables/getPDFConfiguration, which had no
caller anywhere, and the unused EnvironmentManager injection in the controller.
- config/dompdf.php: drops the dead `orientation` key and defaults enable_remote
to false, matching .env.example, which sets it explicitly and explains why.
Installs predating that line were falling back to true.
- Retires the settings.pdf.footer_text and pdf_layout strings, which no component
referenced.
Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
|
||
|
|
9e496102d4 |
feat(updater): disable the in-app updater in containerized installs
The Docker image already injects CONTAINERIZED=true; consume it via config('invoiceshelf.containerized'), expose it on /app/version, block the update endpoints + console command, and show a 'docker compose pull' panel instead of the updater. Adds missing i18n keys.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
f3ab0f22fc |
chore(frontend): fix ESLint, add Pint+ESLint pre-commit hook, centralize v-html
Fix the broken ESLint setup: add vue-eslint-parser and @typescript-eslint/parser and wire the TS parser into eslint.config.mjs so .ts and <script lang=ts> parse (was failing outright). Clear the resulting backlog to a clean 0/0 baseline — fix genuine issues, relax two intentional-pattern rules (multi-word-component-names, no-required-prop-with-default). Add a committed .githooks/pre-commit (enabled via core.hooksPath, auto-set by the prepare script) that runs Pint on staged PHP and ESLint --max-warnings 0 on staged resources/scripts JS/TS/Vue, blocking on failure. Add composer/npm lint scripts and document the gate in CLAUDE.md. Replace every scattered v-html with a single audited BaseSanitizedHtml component that DOMPurify-sanitizes its input (new utils/markdown.ts sanitizeHtml), so server/registry-provided HTML is actually sanitized and vue/no-v-html stays enabled everywhere but one reviewed sink. |
||
|
|
c7fab5d52f |
feat(ai): Phase 1 — provider configuration, installer step, admin + company settings
Foundation for the AI chatbot + text generation feature. Phase 1 is infrastructure only: driver plumbing, configuration storage with encrypted API keys, global vs per-company resolution, admin + company UI pages, and an optional installer wizard step. The chat assistant and text-generation WYSIWYG integration come in later phases.
**Driver plumbing (app/Support/Ai/)** — AiDriver abstract, AiDriverFactory, AiException, AiChatResponse DTO, OpenRouterDriver concrete implementation. OpenRouter is the OpenAI-compatible aggregator that unlocks hundreds of models behind one API key and one request shape — ideal as the default v1 driver. Drivers are extensible the same way exchange rate drivers are: the module Registry's generic registerDriver('ai', ...) machinery plus a typed Registry::registerAiDriver() convenience wrapper (shipped in the upstream invoiceshelf/modules package in a paired commit).
**AiConfigurationService** — mirrors MailConfigurationService shape but with one deliberate deviation: API keys are encrypted at the service layer via Crypt::encryptString before persistence. OpenRouter bearer tokens have much bigger blast radius than SMTP passwords. Same settings / company_settings tables, same global-vs-per-company pattern, same use_custom_ai_config override toggle. Resolution order: global ai_enabled must be YES, then the company either overrides via use_custom_ai_config=YES (and can opt out with ai_enabled=NO inside the override) or inherits the global config.
**Controllers** — Admin/Settings/AiConfigurationController (global CRUD + driver list + test connection), Company/Settings/CompanyAiConfigurationController (per-company override + test), Setup/AiConfigurationController (installer wizard step, skippable with explicit ai_enabled=NO). API key is always masked as '********' in GET responses — the frontend submits the placeholder back on save and the backend preserves the stored value.
**Installer wizard** — new optional step 7 'AI' between Mail and Account. Default OFF with a Skip button. MailView.vue now routes to installation.ai instead of installation.account; installation.ai then routes to installation.account. Step order comment updated in routes.ts.
**Admin + Company settings pages** — AdminAiConfigView (no toggle, always global) and AiConfigView (with use_custom_ai_config BaseSwitchSection that auto-saves OFF). Both share AiConfigurationForm which renders the driver selector, API key input with show/hide, driver-specific config_fields (base_url for OpenRouter), and per-role enable toggles with free-text model inputs backed by a datalist of suggested models from driver metadata.
**Bootstrap endpoint** — adds an ai block to the response: { enabled, chat_enabled, text_generation_enabled }. All three are booleans resolved through AiConfigurationService::resolveForCompany(). Never leaks the API key. Frontend feature flags read from bootstrapData.ai to decide whether to show Phase 2/3 UI.
**Bouncer ability** 'manage ai config' added to SettingsPolicy, gated on isSuperAdmin() (same pattern as manage email config, manage pdf config).
**Tests** (22 new) — Unit: AiDriverFactory resolves built-in + Registry-contributed drivers, rejects unknown, merges availableDrivers. AiConfigurationService: encryption round-trip, resolution order (3 cases: global off, inherit global, override with company key, override with opt-out), makeDriver null/instance cases, listDrivers metadata. Feature: admin save + read with api key masking, preserve-on-placeholder behavior, company toggle ON/OFF semantics, bootstrap ai flags reflect resolution, company opt-out path.
372 tests pass (was 350, +22). Pint clean. npm run build clean. Phase 2 (chat assistant + tool calling) and Phase 3 (WYSIWYG text generation popup) are separate follow-up commits — this one is the foundation only.
|
||
|
|
7885bf9d11 |
feat(menu): priority-sorted menu groups, user-menu items, sidebar appearance toggle
Every main_menu entry moves from numeric group (1/2/3) to string-based group + group_label + priority. Groups now carry their own i18n label and child entries are sorted by an explicit priority field instead of config-array order, so module-contributed menu items can slot into any existing group at any position.
BootstrapController merges module-registered menu items into main_menu (previously they lived in a separate module_menu response key) and introduces a user_menu response key for items modules want to place in the avatar dropdown. The global store follows suit: moduleMenu becomes userMenu, menuGroups is a computed that sorts by priority, and hasActiveModules drops out.
New admin Appearance setting page with a single toggle for whether sidebar group labels render — so instances that prefer a compact sidebar can hide the Documents/Administration/Modules headings without losing the grouping itself. CompanyLayout watches route meta and re-bootstraps when the admin-mode flag flips so the sidebar repaints with the right menu on navigation across the admin boundary.
Test suites updated: module menu merging is asserted against main_menu (name: 'module-{slug}') rather than the old module_menu response; HelloWorldIntegrationTest verifies the schema translation path; CompanyModulesIndexTest covers the display_name attachment.
|
||
|
|
3d79fe1abc |
feat(modules): redesign admin marketplace cards and detail view
ModuleCard moves badges to the top-right, shows a cover placeholder when art is missing, and drops the rating/pricing chrome that was never populated by the marketplace. ModuleDetailView splits into a hero row (cover + module info, two-thirds width) plus a sticky action card on the right (one-third) so install/update/purchase buttons stay visible when scrolling long descriptions. ModuleIndexView promotes the marketplace API token form to a persistent card at the top of the page and adds an authenticated/premium status pill so super-admins can see whether the current token unlocks premium listings. The tabs and empty state were reorganized so 'installed' and 'marketplace' feel like peers. The admin modules store tracks marketplace auth status, adds checkApiToken() and setApiToken() methods, and unifies the install-request shape into ModuleInstallPayload so both the free and paid install buttons route through the same code path. |
||
|
|
9174254165 | Refactor install wizard and mail configuration | ||
|
|
84725b2dfa |
feat(modules): relocate marketplace browser to super-admin context
The module marketplace browser UI (ModuleIndexView, ModuleDetailView,
ModuleCard, the four-step installer store) was filed under
features/company/modules/ only by historical accident — it's authorized via
the manage modules ability (super-admin-only) and conceptually belongs in the
admin context, not the company context.
- Move features/company/modules/{store.ts, views/ModuleIndexView.vue,
views/ModuleDetailView.vue, components/ModuleCard.vue} to
features/admin/modules/.
- Update hardcoded /admin/modules/... paths in the moved files to
/admin/administration/modules/... so the breadcrumbs and ModuleCard
navigation target the new admin-context routes.
- Tighten the four-step installer's silent catch {} blocks in the moved
store.ts: errors were being swallowed, now they dispatch through the
global notification store instead.
- New features/admin/modules/routes.ts declares admin.modules.index +
admin.modules.view as children of /admin/administration with
meta.isSuperAdmin: true.
- features/admin/{index,routes}.ts re-export and mount the relocated routes.
- config/invoiceshelf.php gains a new AdminModules entry in admin_menu
pointing at /admin/administration/modules with super_admin_only: true.
- The dev-gated navigation.modules entry in main_menu is replaced (not
deleted) with a non-gated entry pointing at the new company-context
Active Modules index page that lands in the next commit. The
ability is set to manage modules so non-owners can't see it.
The new company-context Active Modules index, schema-driven settings page,
and dynamic sidebar group are introduced in subsequent commits.
|
||
|
|
71388ec6a5 |
Rename resources/scripts-v2 to resources/scripts and drop @v2 alias
Now that the legacy v1 frontend (commit
|