Commit Graph

13 Commits

Author SHA1 Message Date
Darko Gjorgjijoski
00c9c4268e fix(pdf): give the reports their margin back, and one shared chrome (#738)
* fix(pdf): put the page margin back on the report PDFs

The report templates carry no inset of their own. They set
`.sub-container { padding: 0px 20px }` and nothing else, and relied entirely on
dompdf's built-in 1.2cm page margin. #727 made DompdfDriver always inject an
`@page` rule from PdfPageSetup, and #735 defaulted those margins to zero so
invoice2 and estimate2 could bleed their header band to the paper edge. The
document templates were fine, they carry their own 30px/50px insets. The reports
were not: every one of them now renders flush against the paper, with the
company name's glyph box actually clipped 1.2pt above the top edge.

The zero default has to stay for documents, so reports get a margin of their
own: `pdf.page.report_margin`, PDF_REPORT_MARGIN, defaulting to the 1.2cm they
were drawn against. It is a separate key on purpose, so an operator tuning the
document margins for their invoice template does not silently reflow every
report as a side effect.

A page margin rather than padding on the templates because reports run to
several pages and padding only insets the first one. Sales by customer already
spans two on the demo data, and page two moves with the rest.

Plumbed as an optional PdfPageSetup on the driver contract, defaulting to the
configured page, so every existing call site renders exactly as before and only
the five report controllers ask for anything different.

Measured on the five reports, page 595.28 x 841.89pt, 1.2cm = 34.02pt: first
page ink moves from xMin 15.0-15.8 / yMin -1.2 to xMin 49.0-49.8 / yMin 32.8,
every axis shifting by exactly the margin.

Also fixes three untranslated keys this exposed: the expenses report printed its
column headings as the literal strings "expenses.date", "expenses.note" and
"expenses.amount", which have never existed in lang/en.json. They are now
pdf_expense_{date,note,amount}_label, and a test pins that every translation key
a report template uses resolves in English.

* fix(pdf): put the minus sign in front of the currency symbol

format_money_pdf() formatted the signed value and then concatenated the symbol,
so a negative amount came out as "$-24,738.00". Credit notes made that common:
every line on a credit note PDF reads negative, and one credit note in a period
is enough to make the customer sales report show a negative total.

The magnitude is formatted first now and a single minus is prefixed to the whole
assembled string, so the sign leads and the symbol stays glued to the digits.

Only the symbol-first branch changes. number_format() already put the sign in
front of the digits, so a trailing-symbol currency read "-24,738.00$" before and
is byte-identical after.

The sign is decided on the formatted digits rather than on the raw input, so an
amount that rounds away at the currency's precision renders as zero rather than
as "-0". A stray cent on a zero-precision currency is the case that needs it.

* refactor(pdf): one shared chrome for the report PDFs

The five report templates were five drifted copies of one 2018 stylesheet, and
the insets had stopped agreeing with each other. profit-loss alone put its
header and income row at +20px, its "Expenses" heading at +23px, its category
rows at +30px, and its total rule and NET PROFIT band at +0, because that markup
sat outside the container everything above it was in. Four left edges on one
page. Every report also carried the same self-cancelling total rule, where
`padding: 0px` follows the two longhands it silently overrides, and expenses
carried six rule blocks nothing referenced at all, including the only horizontal
rule in the file.

There is now one layout partial and one stylesheet, and each report is content
only: 236 lines down to 47 for profit-loss, and about 1200 lines deleted across
the five. One content edge, measured: every band starts at 34.016pt and every
amount ends at 561.260pt, on every page of every report.

What changed on the page:

- Real tables with a thead, so column headings repeat across page breaks. Only
  expenses had headings before and none of them used thead. sales-items emitted
  a separate table per item, which is why its rows never lined up.
- The company logo in the header, the same fallback-to-name pattern the document
  templates use.
- An empty period renders a "no records" row. profit-loss, sales-items and
  tax-summary rendered their total row and rule unconditionally, so a month with
  no data showed a heading, a gap, a rule and a lone $0.00.
- Sections stay whole across a page break where they fit, and a section heading
  never sits at the foot of a page with its rows overleaf.
- Credit notes stay in the sales totals, since a reversal netting the sale out is
  correct, but the line is tagged so a CN- number is not read as a sale. It
  reuses the document's own label, which is already in the shipped locales.
- Labels stopped carrying their own presentation: "TOTAL EXPENSE" (also
  singular) is "Total expenses" and the stylesheet does the uppercasing.

The five controllers drop the dead colour-settings block: nine *_color settings
were queried and shared by every report, no template ever read them, and no
migration, seeder or UI ever wrote them, so the query always returned an empty
collection. Every other shared variable name is untouched, because a custom
report template is a copy that references them by name.

make:template had to learn the same lesson: it only ever copied
partials/table.blade.php, so a cloned report would extend a layout that does not
exist in its namespace and die on render. It now copies every partial a type
ships and rewrites references by view name, including partial-to-partial ones,
so each custom template still gets its own copies.
2026-08-02 14:34:02 +02:00
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
Darko Gjorgjijoski
35a248b48b fix(pdf): send the installed fonts to Gotenberg (#733)
FontService writes absolute host paths into the @font-face rules:

    src: url("/var/www/html/storage/fonts/NotoSansSC-Regular.ttf")

dompdf shares that filesystem so they resolve. Chromium runs inside the Gotenberg
container and cannot see any of it, so every installed font package silently
failed to load and documents fell back to whatever fonts that image happens to
ship. The docs recommend Gotenberg specifically for mixed-script documents, which
made this exactly the wrong way round -- it worked only by accident, because
Chromium's own font set covers more than dompdf's single-font behaviour.

The font files now travel with the document as Gotenberg assets, and the rules
are rewritten to name them. Gotenberg unpacks assets next to index.html, so a
bare filename resolves.

Only fonts the markup actually references are sent. A CJK package is several
megabytes and has no business riding along on a request that never mentions it.

Confirmed against a stock gotenberg:8, reading the fonts back out of the
rendered PDF:

    before   AAAAAA+LiberationSerif      (Gotenberg's fallback)
    after    AAAAAA+NotoSans-Regular     (the app's own font)

Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
2026-08-01 14:41:35 +02:00
Darko Gjorgjijoski
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
2026-08-01 14:37:22 +02:00
Darko Gjorgjijoski
05e8acc3b1 feat(pdf): let payment receipts and reports be overridden too (#731)
Only invoices and estimates could be customised. Payment receipts and all five
reports were hardcoded to app.pdf.*, so changing them meant editing files inside
the image -- and losing the edit on the next upgrade.

Those documents have no template picker and no design to choose between, so
overriding one is not a selection: it is a same-named file in
storage/app/templates/pdf/{type}/ winning over the built-in. PdfTemplateUtils::
resolveView() is that rule, and it needs no setting, no column and no UI.

resolveView asks View::exists rather than checking the storage disk. The disk and
the view namespace are registered separately and could disagree about where
custom templates live; asking the thing that will actually render removes that
possibility.

make:template covers the new types. Their names are not free, since an override
replaces one specific document, so it validates against the real list -- 'payment'
for payments, and the five report names -- and reports what is available when the
name is wrong. Neither type gets a preview image written, having no picker to
show one in.

The payment preview route also went through the built-in view directly rather
than the service, so ?preview ignored an override and rendered with none of the
shared data. It goes through the service now, like invoices and estimates.

Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
2026-08-01 13:01:15 +02:00
Darko Gjorgjijoski
8ab860a1ae fix(pdf): make custom templates behave the way the docs describe (#730)
Custom templates are a real feature with no test coverage at all, and several
rough edges that only show up once someone actually uses one.

make:template validated nothing. --type was checked only by the interactive
prompt, so `--type=payment` skipped the prompt and died on an uncaught
FileNotFoundException looking for payment1.blade.php: a stack trace instead of a
message. The name was not checked either, so `../escaped` wrote outside the
templates directory. Both are refused now.

Every custom template of a type shared one partials/table.blade.php. It was
written on first use and reused thereafter, so editing the items table for one
custom template silently changed it for all of them -- a file that looks
per-template and behaves globally. Each template now gets its own copy under
partials/{name}/, and its include is rewritten to match. Existing templates keep
including the old shared path, which still resolves.

A custom template sharing a built-in's name appeared twice in the picker with
the same label, and findFormattedTemplate() array_reverses and takes the first
match, so the custom one silently won. The listing is keyed by name now, so it
appears once, as the entry that will actually be used.

A custom template with no same-named .png rendered <img src=""> in the picker: a
blank tile, no error, no hint anything was missing. It falls back to the preview
of the template it was cloned from.

template_name was validated as `required` and nothing else, so any string was
accepted and stored. findFormattedTemplate() returns null for an unknown name,
the null reads as "not custom", and rendering falls through to
app.pdf.{type}.{name} -- a raw "view not found" 500 at PDF time, long after the
save that caused it. New PdfTemplateExists rule, scoped per document type.

make:template also copies a _header/_footer companion when the source template
has one, so a scaffolded template keeps the repeating page furniture.

Both getEstimateTemplateName/getInvoiceTemplateName asked for the template list
with the default image format, base64-encoding a preview of every template just
to read the names back.

Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
2026-08-01 12:56:39 +02:00
Darko Gjorgjijoski
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
2026-08-01 12:52:47 +02:00
Darko Gjorgjijoski
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
2026-08-01 12:48:51 +02:00
Darko Gjorgjijoski
6cb754da60 fix(pdf): give both drivers one contract, and fix what that was hiding (#727)
PdfDriver and ResponseStream existed but nothing implemented them. The factory
returned the vendor dompdf wrapper for one driver and a bespoke class for the
other, so the two were never held to the same shape. Three things had slipped
through that gap.

Report PDFs answered 403 for everyone. The five report routes carry no company
header, so ScopeBouncer is not in their middleware stack and the ability scope
was never set; 'view-financial-reports' is stored scoped to a company, so the
check could not pass. They now scope to the company named in the URL. The policy
still checks membership, so this grants nothing new. Also firstOrFail() on the
hash lookup, so an unknown company is a 404 rather than a 500 on a null.

Report downloads were fatal on Gotenberg. GotenbergPdfResponse had no download(),
and the report controllers are its only callers. Added, alongside stream() and
output(), with the whole set now on the interface.

Streamed documents carried an HTTP preamble. GeneratesPdfTrait wrapped
$pdf->stream() -- already a Response -- in another response()->make(), which
stringified it and prepended "HTTP/1.0 200 OK" plus headers to the file. Readers
scan the first kilobyte for %PDF so nobody noticed, but the bytes were malformed.
Passing ->output() fixes it, and the render test now asserts the position.

Two driver-parity settings, both checked against a real gotenberg:8 rather than
inferred: emulateScreenMediaType(), because Chromium defaults to print media
while config/dompdf.php renders as screen, so a @media print rule applied on one
driver and not the other; and printBackground(), which turns out to affect only
the root background, since Chromium paints element backgrounds either way. No
stock template sets a body background, so that one changes nothing today and is
here to keep custom templates consistent across drivers.

Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
2026-08-01 12:43:31 +02:00
csoscd
f8cfb6cd33 fix: allow Gotenberg to reach private/Docker-internal hosts (#691)
* fix: allow Gotenberg to reach private/Docker-internal hosts (Issue #688)

The SSRF guard introduced in #664/#671 correctly blocks arbitrary
private URLs, but also prevents legitimate use-cases where Gotenberg
runs alongside InvoiceShelf in a Docker Compose network (e.g. the
default http://pdf:3000 service name resolves to a private IP).

Add a `gotenberg_allow_private_host` setting (env:
GOTENBERG_ALLOW_PRIVATE_HOST, default false) that:
- skips PrivateNetworkGuard in GotenbergPdfDriver
- skips PublicHttpUrl validation in PDFConfigurationRequest
- exposes a clearly-warned toggle in the admin PDF settings UI
- is persisted to the settings table and loaded via AppConfigProvider

A disabled guard is safe for controlled private networks (Docker
Compose, LAN); it must never be enabled for untrusted hosts. The UI
surfaces a prominent warning to communicate this constraint.

Closes #688

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(gotenberg): scope the private-host exemption to a declared host

Reshapes the escape hatch from a boolean admin setting into an
environment-declared host allowlist.

The driver streams the upstream response body back as the PDF, so a
mis-set Gotenberg host is full-response SSRF — pointed at a link-local
metadata endpoint it returns cloud credentials. A blanket "allow
private" switch left that reachable: gotenberg_host stays editable from
the admin UI, so any install that enabled the switch to run a sidecar
could have the host repointed at an internal service. The population the
flag existed to serve was exactly the population it failed to protect.

GOTENBERG_ALLOWED_PRIVATE_HOST now names the single host that may skip
the guard. Only that exact value is exempt; every other private target
stays blocked. GotenbergHostPolicy owns the comparison so the save-time
rule and the runtime driver guard cannot drift, and normalises case,
trailing slash and surrounding whitespace on both sides.

Being env-only also drops the settings-table key, the AppConfigProvider
branch and the whole admin UI surface — the toggle there could not be
switched on in any case, since BaseSwitchSection has no slot and was
passed no v-model, so the child BaseSwitch was discarded and the value
never changed from false.

Restores the gotenberg_margins validation rule, which the previous
revision replaced rather than added alongside.

Tests cover both directions, including that declaring one private host
does not exempt another; sabotaging the policy to always exempt fails 16
of the 22.

Co-authored-by: csoscd <csoscd@users.noreply.github.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Darko Gjorgjijoski <dg@darkog.com>
Co-authored-by: csoscd <csoscd@users.noreply.github.com>
2026-07-29 10:57:52 +02:00
Darko Gjorgjijoski
99ea898e88 fix(security): block SSRF via the Gotenberg host setting (GHSA-mfxg) (#671)
v3 port. The Gotenberg PDF driver was missed when the SSRF guards were added
to the AI, exchange-rate and file-disk drivers: gotenberg_host was validated
only with 'url', and the driver POSTs the rendered HTML to it.

Reuses the existing infrastructure (consistency with the other drivers):
- Wires App\Rules\PublicHttpUrl into the gotenberg_host validation rule.
- Adds PrivateNetworkGuard::assertAllowed() in GotenbergPdfDriver before the
  outbound call (covers env/seed/stale config + DNS rebinding).

Adds a unit test asserting the gotenberg_host rule rejects private/loopback/
link-local addresses and allows a public one.
2026-06-12 11:02:19 +02:00
Darko Gjorgjijoski
f657b53215 refactor(services): split driver infrastructure out of Services into Support
Services/Integrations/ExchangeRate/ and Services/Pdf/ were both mostly Support-shaped: interfaces, abstract classes, static factories, concrete adapter drivers, DTOs, and exceptions — infrastructure that doesn't carry business logic. They only each had one real DI-injected service mixed in.

This commit applies the same Services=DI-business-logic / Support=stateless-plumbing rule we've been using throughout the reorg:

**Moved to Support/Integrations/ExchangeRate/** (7 files): ExchangeRateDriver (abstract), ExchangeRateDriverFactory (static), ExchangeRateException, and the four concrete drivers (CurrencyConverter, CurrencyFreak, CurrencyLayer, OpenExchangeRate). These are HTTP adapters over third-party currency APIs — same shape as the Hashids library wrapper classes already in Support.

**Moved to Support/Pdf/** (6 files, merging with existing Pdf utilities): PdfDriver (interface), PdfDriverFactory (static), PdfService (static facade), GotenbergPdfDriver, GotenbergPdfResponse (DTO), ResponseStream (interface). The Support/Pdf/ dir now contains the full PDF rendering subsystem — drivers + sanitizer + template/image utilities.

**Promoted to Services/ root** (the real DI services): ExchangeRateProviderService (CRUD for ExchangeRateProvider model) and FontService (font package install/download orchestration). Both are proper DI services — instance methods, model writes, HTTP side effects.

Services/Integrations/ and Services/Pdf/ are now empty and deleted. Services/ holds only DI-injected classes; Support/ holds all the plumbing.

17 files renamed (git detects 90-99% similarity), 4 consumer files updated (DriverRegistryProvider, PdfServiceProvider, ExchangeRateProviderController, FontController, GeneratesPdfTrait, test). 350 tests pass, Pint clean.
2026-04-11 16:30:00 +02:00
Darko Gjorgjijoski
6d1816bd1b refactor: reorganize app/Services and app/Support by domain
The app/Services/ directory had grown into 22 flat files at the root plus 7 uneven subdirectories — finding anything required scrolling through an alphabetical mix of small CRUD services, infrastructure drivers, and install-time utilities. This commit groups services by domain, folds Backup into a new Storage namespace, and moves framework-infrastructure and install-time helpers out of Services and into Support where they belong.

New Services layout: Documents/ (Invoice, Estimate, RecurringInvoice, Payment, Expense, Transaction, DocumentItem, SerialNumber, Currency — matches the 'Documents' navigation group); Company/ (Company, Member, Invitation); Mail/ (MailConfiguration, CompanyMailConfig); Storage/ (FileDisk, plus Backup folded in). ExchangeRateProviderService moves next to its drivers in ExchangeRate/; FontService moves into Pdf/ where it belongs. CustomerService, ItemService, CustomFieldService stay at the Services root as standalone single-file domains.

Moves to Support/: Hashids/ (library wrapper — not business logic); Setup/ (one-shot install-time utilities — stateless helpers); Pdf/ (ImageUtils, PdfTemplateUtils, plus the existing PdfHtmlSanitizer consolidated into the same subdir). These are all framework infrastructure and stateless utilities — the 'service' label never really fit them.

Namespace declarations in 29 moved files updated to match new paths. 62 consumer files (controllers, other services, tests, database factories, seeders, routes, bootstrap/providers.php) have their use statements rewritten via a literal-string replacement script — no regex meant no risk of half-matching. Three Documents services needed an explicit 'use App\Services\Mail\CompanyMailConfigService' added because the same-namespace short reference they relied on no longer resolves after the split.

Verified: composer dump-autoload, 350 tests pass (850 assertions), vendor/bin/pint clean, npm run build succeeds.
2026-04-11 10:00:00 +02:00