dompdf does not use a declared line-height directly. It scales it by the font's
own height:
rendered = declared x (ascent + descent) / unitsPerEm x font_height_ratio
The bundled Noto Sans reports 1.362 for that middle term, so at dompdf's stock
font_height_ratio of 1.1 every line-height in every document came out 1.4985x
what the CSS asked for. Chromium honours the declared value exactly. That one
factor was the whole vertical disagreement between the two drivers.
Setting the ratio to 1/1.362 cancels the font term. Measured on a declared 15px
(11.25pt): 16.86pt at the stock 1.1, 15.32pt at 1.0, and 11.25pt at this value --
identical to Chromium.
Across the seven document templates the worst-edge ink difference falls from
roughly 70-150pt to under 30pt on six of them, and to 3.4pt on invoice1. The
exception is estimate1, which moves the other way: with the line-height noise
gone, a float and padding difference in its address block is now the dominant
term there. That is a separate problem this exposes rather than causes.
Worth recording that an earlier compensation shim had arrived at 1.5 empirically
and was right: 1.1 x 1.362 = 1.4985. I argued against it on the strength of a
test that used font-family: sans-serif, which resolves to a built-in core font
and so never exercised the embedded Noto Sans path where the scaling happens.
The measurement was wrong, not the constant.
PdfLineHeightTest pins the invariant -- the font's reported height equals the
font size, so a declared length renders at that length -- and needs no Gotenberg,
so CI holds it. Swapping the default face or taking a dompdf upgrade that changes
the computation now fails a test rather than quietly reintroducing the drift.
Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
* 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
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
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
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
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
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
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
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
:next is meant to track the next major, but any pre-release claimed it —
so 2.4.3-beta.1 took it from 3.0.0-alpha.1, and anyone pulling :next for
a 3.0 alpha was silently moved back onto a 2.x image. A downgrade across
a major line, from a rehearsal beta that had no business touching it.
:beta had the mirror of the same flaw, claimable by a pre-release on any
line.
Both are now gated on LATEST_MAJOR, exactly as :latest already was:
:beta is the stable line's pre-release, :next the newer major's. With
LATEST_MAJOR=2, 2.4.3-beta.x takes :beta only and 3.0.0-alpha.x takes
:next only.
:next has been repointed to 3.0.0-alpha.1 on Docker Hub, by copying the
manifest list so both architectures were preserved.
Most of docker.yaml was doing work that was duplicated or hazardous now
that releases are cut from a tag.
The tests were duplicated exactly: release.yaml and docker.yaml called
the same reusable tests.yaml, on the same commit — once before drafting,
again after publishing. No new information, and the image build queued
behind it. Pint likewise: check.yaml already style-checked the commit
when it landed. Both jobs go; coverage is unchanged.
manual_docker_build goes too, and it was worse than redundant. Its
checkout took no ref, so it built the dispatched branch while tagging
with whatever string was typed — an image could be labelled 2.4.2 while
containing 2.x HEAD — and `tag` defaulted to "latest", so a careless
dispatch republished the moving stable tag from a branch. #710 had to add
a guard purely to stop the registration dispatch doing that by accident.
It was last used in September 2025 to push the legacy and alpha tags
during the Docker distribution work; that is finished, and releases
produce images now. A patched base image is better served by a patch
release than by silently changing what a pinned tag contains.
That cascade removes the tag input and the #710 guard as well, leaving
register_tag as the only dispatch input and two jobs in the file.
Losing the test gate would leave the image build ungated, so the release
build now refuses a release with no InvoiceShelf.zip. A release either
came from the tested pipeline or it gets no images — the updater is
already protected this way, since registration downloads that same asset.
GitHub offers no way to forbid hand-made releases; this is the closest
thing, which is to make them inert.
"Docker" no longer describes a workflow that registers on the updater and
publishes images, so it becomes publish.yaml — matching its trigger and
pairing with release.yaml, which prepares what this distributes. The
recovery instructions in AGENTS.md name this workflow and would have
broken silently, so they move with it.
2.4.3-beta.2 was cut by tag and came out correctly — published,
pre-release, zip attached, notes from CHANGELOG.md — and then nothing
else happened. No updater registration, no Docker images.
GitHub does not start workflow runs from events created with
GITHUB_TOKEN. The publish step authenticated as github-actions[bot], so
`release: published` fired and triggered nothing. Every earlier
docker.yaml run was event=release from a release a human published,
which is why beta.1 worked and beta.2 did not. 3.x carries the same
design and would have failed identically on the first RC.
The workflow now stops at the draft. Everything else is unchanged: the
tag still runs the tests, reads the notes, builds the package and
attaches it. Pressing Publish fires the event under a real identity and
the proven downstream runs as it always has — and it puts a deliberate
gate in front of a release going public.
prerelease and make_latest move onto the draft rather than being applied
at publish time, so the flags are already right when the button is
pressed; GitHub's publish dialog otherwise defaults "Set as the latest
release" to checked, which would let a 3.0.0 alpha displace 2.4.x.
The run now ends by writing the draft URL and the resolved flags to the
job summary, since a draft nobody knows about is no use.
Documents the whole flow in AGENTS.md, including why publishing is
manual — the reasoning is not guessable from the workflow alone.
The CHANGELOG.md section was read after composer, pnpm and the frontend
build had all run, so tagging without notes spent a full build before
failing on something knowable in the first seconds. It only needs PHP, so
it now runs directly after the PHP setup.
Noticed while dry-running a tag with no section against #715: the run
correctly refused to publish, but took a whole build to say so.
release.yaml has never run. It listens for "v*" tags while every tag ever
cut is bare — 3.0.0-alpha.1, 2.4.2, 2.4.3-beta.1 — so tagging by the
established convention produced silence, and whoever cuts 3.0.0 would
have hit that first. It now accepts both spellings.
It also hand-copied the release file list, which docker.yaml then rebuilt
via `make clean dist` and uploaded with overwrite: true. The two lists
were identical, so nothing had broken yet, but which zip users received
was decided by job ordering. `make dist` owns the artifact now and the
duplicate is gone.
The release is created as a draft with the package already attached and
published in a separate step, so `release: published` fires only once the
tests have passed and the asset is in place. A failed run leaves no
release at all, rather than a published one nobody can download — which
is what 2.4.2 left behind. Registration can no longer race the upload
either, so docker.yaml drops its build job, and register_release loses
both that dependency and the always() dance it needed to survive the job
being skipped on a manual dispatch.
GitHub's "Latest release" pointer is gated on LATEST_MAJOR, exactly as
docker.yaml gates its moving image tags: a 3.0.0 alpha can no longer
displace 2.4.x as the release users are shown first. Release notes come
from CHANGELOG.md instead of being generated from commits, matching where
the updater already reads them, and a tag with no section fails before
anything is published.
The test job moves to a reusable workflow rather than being written out
in both places — the duplicated file list above is the argument.
Port of the 2.x change to 3.x, with the same extractor script.
Registration sent the GitHub release body to the updater, so the notes
every install sees were written at publish time — after review, outside
the repo, with nothing checking they existed or matched what shipped.
CHANGELOG.md becomes the source. It is written and reviewed alongside the
change itself, so what installs are offered cannot drift from what was
merged, and the release body can simply point at it.
A release with no section for its tag fails the job rather than
registering an empty changelog. A manual dispatch falls back to the
release body, since re-registering a release older than this file is
legitimate — which covers 3.0.0-alpha.1.
Backfilled with 3.0.0-alpha.1.
manual_docker_build fires on any workflow_dispatch, and pushes
invoiceshelf/invoiceshelf:${{ inputs.tag }} where tag is required and
defaults to "latest". Dispatching to re-register a release therefore also
rebuilt from the dispatched branch and overwrote the published :latest
image — from 3.x that would hand every :latest user a 3.0.0-alpha build.
The registration path added in #709 is meant to be a safe recovery route,
so triggering it must not have that side effect. A dispatch carrying
register_tag now runs registration only.
Port of #708 to 3.x. This branch carries the identical bug: the changelog
is written to /tmp/changelog.txt while curl is told to read
`changelog.txt`, a relative path resolved against the checkout. The next
3.x release would fail exactly as 2.4.2 did — published, zip uploaded,
images built, and never registered, so no install offered it.
It has not bitten here only because no 3.x release has been cut since the
automation landed; 3.0.0-alpha.1 predates it, the same reason 2.4.2 was
the first to hit it on 2.x.
Registration moves into its own job that downloads the published asset
rather than reusing the build job's working directory, so it can also be
re-run on demand for an existing tag instead of needing production shell
access to repair. A missing WEBSITE_RELEASE_TOKEN is now fatal on a real
release rather than a warning, and the registration is verified against
the download endpoint afterwards, which fails unless the row exists and
its zip is retrievable.
Both blocks are byte-identical to what merged on 2.x, so the two trees do
not drift and the next change ports cleanly.
Checked for this branch specifically: the verify probe returns 200 for
3.0.0-alpha.1, so it holds on the insider channel; that release ships the
same InvoiceShelf.zip asset name; and the "-" suffix already derives
CHANNEL=insider without change. LATEST_MAJOR stays "2" — 2.x owns the
moving :latest tags until 3.0.0 GA.
FixesInvoiceShelf/docker#79 — a fresh install using the shipped
docker-compose.mysql.yml cannot get past the database step, because that
compose file sets DB_CONNECTION=mariadb.
getDatabaseEnvironment() switched on sqlite, pgsql and mysql with no arm
for mariadb and no default, so it answered {"config":[]}. The wizard
chooses which form to render from database_connection in that response,
so step 4 rendered blank with no way forward — and nothing reached the
log, because the app never errored, it just replied with nothing.
Adds the mariadb arm, and a default so an unrecognised driver can never
again produce an unrenderable response: it is echoed back with the server
defaults, leaving the fields editable rather than the step empty.
MariaDB is now offered in the driver dropdown too. It was already a valid
DB_CONNECTION with its own connection in config/database.php, and the
form fields are identical to MySQL's.
Tested against the original code, where three of the new cases fail with
"Failed asserting that null is identical to 'mariadb'".
FixesInvoiceShelf/docker#64 — recurring invoices and scheduled tasks
running on the wrong timezone.
Two bugs compounded so that no supported value had any effect:
config/app.php had no timezone key at all, so Laravel's own fallback won
— and that fallback is a literal 'UTC' string, not an env() lookup
(vendor/laravel/framework/config/app.php). APP_TIMEZONE has therefore
been inert since the config was slimmed, despite being shipped in
.env.example.
inject.sh wrote a bare TIMEZONE key into .env, which nothing reads, so
setting the documented container variable also did nothing. It now
writes APP_TIMEZONE, and accepts either name so existing compose files
keep working without edits.
Verified against a built image: TIMEZONE=Europe/Berlin now yields
APP_TIMEZONE=Europe/Berlin in .env and config('app.timezone') ==
Europe/Berlin, where before both spellings left it on UTC.
Fixes InvoiceShelf/docker#75 and #69, and gives InvoiceShelf/docker#77
and #63 an actionable error instead of a cryptic one.
storage/framework/{cache,sessions,views}, storage/logs and storage/app
hold no tracked content — only .gitignore stubs — so nothing guarantees
they exist inside a mounted volume. Docker seeds a named volume from the
image exactly once, when the volume is empty, and never again: a volume
created by an older image keeps whatever it had through every subsequent
upgrade. When those directories are absent Laravel dies at boot with
"Please provide a valid cache path", because config/view.php resolves its
compiled path with realpath(), which returns false for a missing
directory. The sqlite branch also cannot place its database.
Reproduced against a locally built image: deleting storage/framework from
a named volume fails the container with exactly that message, and passes
with this change.
The chown is guarded on being root. The image runs as www-data (uid 82),
where chown of a foreign-owned file is EPERM and, under `set -e`, would
stop the container from starting at all — which is the likely reason it
was dropped from this tree previously. Guarding it keeps the benefit for
anyone running as root without that failure mode.
A mount the container genuinely cannot write to is not something the
entrypoint can fix, so it now says so and names the remedy, rather than
letting the failure surface later as a Laravel stack trace.
Choosing Gotenberg in ./devenv started the `pdf` sidecar and configured
nothing else, so the app still defaulted to dompdf. Pointing it at the
sidecar by hand then hit the SSRF guard, because `pdf` resolves to a
private address on the compose network — the failure #691 fixed, hit
from inside our own dev environment.
The three gotenberg compose files now set PDF_DRIVER, GOTENBERG_HOST and
GOTENBERG_ALLOWED_PRIVATE_HOST on php-fpm, so the stack renders through
the sidecar with no .env editing at all. The serversideup pool config
already sets `clear_env = no`, so these reach the workers; verified by
generating a real invoice PDF end to end (24967 bytes, %PDF-1.4).
Setting the environment in compose rather than writing to .env keeps the
devenv script from mutating a developer's own file — it does not touch
.env today, and the values belong to the compose file the developer
selected. Non-Docker setups have the same keys documented in .env.example.
devenv now prints what it configured, including that the compose file
exempts that one host from the SSRF guard, since a security control being
relaxed should not be silent.
* 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>
RealisticDemoSeeder builds invoices, payments and estimates with
Model::create(), which bypasses both paths that normally set unique_hash
— the factories set it directly, and InvoiceService and friends encode it
from the id after insert. Nothing assigned it here, so every seeded
document had it NULL.
The PDF routes bind on that column, so the frontend built
`/invoices/pdf/` with an empty segment. That 404s, and the only symptom
is "Unable to load document preview" in the UI with nothing written to
the log, which makes it a genuinely slow thing to track down. Anyone who
seeds realistic demo data and opens a document hits it.
Production is unaffected: documents created through the app go through
the service layer, which assigns the hash. Existing seeded databases
need a backfill, encoding each id the same way the services do.
Uses Hashids, as the services do, rather than the factories' str_random,
so demo data matches what the app itself would have produced.
crypto.randomUUID() and navigator.clipboard are both [SecureContext]-
gated, so neither exists on a plain-HTTP origin that isn't localhost.
That covers the dev host (http://invoiceshelf.test) and any self-hosted
install reached over a hostname or LAN IP — a large share of them.
generateClientId() called crypto.randomUUID() unguarded. It runs during
Pinia store construction via the invoice, estimate and recurring-invoice
stub factories, so on those origins it threw a TypeError before the
store existed and took the document screens down with it. The value is
only a placeholder identity for a row that has no server id yet — the
server assigns the real one on save, which is why DocumentItem and
DocumentTax type it `number | string`. It never needed randomness, so
it is now a session counter: no crypto, no fallback branch, works
everywhere.
PaymentDropdown.copyPdfUrl() had a textarea fallback attached with
.catch(), which cannot fire — on a non-secure origin navigator.clipboard
is undefined, so `.writeText` throws on property access before any
promise exists. Test up front instead, matching the guard the invoice
and estimate dropdowns already use.
* test(pdf): render every stock template through the real pdf routes
Blade templates reference PHP classes as plain strings, so a namespace
move leaves them dangling without Pint, the IDE, or CI noticing — which
is how #695 shipped a fatal ImageUtils reference in all seven stock
templates and left it there for three months.
Renders each invoice, estimate and payment template end-to-end through
the pdf routes with a company logo attached, since every template guards
the logo behind `@if ($logo)` and the fallback branch never reaches
ImageUtils. One extra assertion checks the rendered markup actually
carries the base64 data URI, so a template that silently drops the logo
fails too rather than emitting a valid but logo-less PDF.
Template names are globbed off disk rather than hardcoded, so a new
stock template is covered as soon as it lands.
* fix(config): resolve the mysql SSL CA attribute per PHP version
PHP 8.5 deprecated PDO::MYSQL_ATTR_SSL_CA in favour of
Pdo\Mysql::ATTR_SSL_CA, so every test in the suite was reported as
deprecated rather than passed — noise that would hide a real one.
Pdo\Mysql does not exist before 8.5 and this package supports ^8.4, so
the constant is resolved at runtime; the untaken ternary branch is never
looked up, which keeps 8.4 working. The lookup stays behind the
extension_loaded() check because neither name is defined when pdo_mysql
is missing.
Verified against both runtimes: with MYSQL_ATTR_SSL_CA set, 8.4 resolves
to attribute 1009 and 8.5 to 1008 — each version's own value, matching
what the previous code produced there.
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
Append a step to the release_artifact_build job that POSTs the freshly built
InvoiceShelf.zip + metadata to the website updater's /api/releases endpoint
(Bearer WEBSITE_RELEASE_TOKEN) right after the asset upload, so deployed installs
are offered the release automatically instead of a manual kubectl+tinker import.
- Runs only on release events; skips with a warning if WEBSITE_RELEASE_TOKEN is unset
- Channel derived from the prerelease flag / "-" tag suffix (GA->stable, pre->insider)
- min_php + extensions read from config/installer.php; release fields passed via env
to avoid shell injection from the release body
- Idempotent (the endpoint upserts per version)
Claude-Session: https://claude.ai/code/session_012tpgisKcrC4D4mCbGTeTKz
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Multi-arch builds run composer (incl. the merge-plugin's update) twice and exhausted GitHub's unauthenticated API rate limit, failing with 'Could not authenticate against github.com'. Pass the Actions token as a build secret and feed it to composer via COMPOSER_AUTH (build-time only, never in the image).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Render a single shared ItemModal (was one per row, so stacked dialogs closed each other), validate the modal's own local form (vuelidate did not track the shared store object in the persistent modal), surface save errors, and carry the typed item name into the new-item form.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Remove the scheduled nightly/alpha builds; gate :latest on a single LATEST_MAJOR; publish :beta/:next for pre-releases; keep a transitional :nightly alias on stable. Also fix the production Dockerfile so a stale host public/build can no longer clobber the freshly built frontend (reorder COPY, ignore public/build).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Member view/update bound the target user by global id and authorized only that the requester owns their active company, not that the target belonged to it. Bind the route model under the members param and require shared company membership in UserPolicy so an owner of one company can no longer read or modify users of another.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Auto-generate an OpenAPI 3.1 spec from the v1 API's FormRequests and Resources
(no annotations) for publishing at api-docs.invoiceshelf.com as a static
Swagger UI site.
- config/scramble.php: scope to api/v1, version from version.md, clean
placeholder server, export to public/openapi.json
- ScrambleServiceProvider: advertise Bearer (Sanctum) auth; add the required
`company` tenancy header only to routes using the `company` middleware
- OpenApiDocumentationTest: assert spec shape, auth scheme, company-header gating
- .github/workflows/openapi.yml: export + commit spec on release, notify the
api-docs site to rebuild
- public/openapi.json: generated seed spec (184 paths)
- dedoc/scramble added as a dev-only dependency
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Remove laravel/boost (+ its deps laravel/mcp, laravel/roster) and Boost's
.cursor/ skills + MCP registration. No version sweep (composer remove).
- AGENTS.md is now the hand-maintained source of truth (was Boost-generated):
folds in the CLAUDE.md content + a Conventions section codifying the migration
FK rule (unsignedInteger for INT-PK refs, not foreignId — MySQL error 3780)
and the MySQL/PostgreSQL/SQLite cross-DB requirement.
- CLAUDE.md / GEMINI.md / .github/copilot-instructions.md are now gitignored
symlinks to AGENTS.md, created by bin/ai-docs.php (composer run ai-docs, also
wired into post-autoload-dump).
Follow-up to #618. The ai_conversations table (added after #618) used
foreignId() (BIGINT) for company_id/user_id, which mismatches the INT
UNSIGNED users.id / companies.id and breaks MySQL FK creation (error 3780)
on the v2->v3 upgrade. Use plain unsignedInteger, matching the codebase's
no-DB-FK convention for these columns. conversation_id stays foreignId — it
references the BIGINT ai_conversations.id and its delete cascade is intentional
(and covered by AiChatFlowTest).
Ports #639 to 3.x — the original targets the now feature-frozen 2.x line.
Address::country_name now resolves the localized country name for the
current app locale via Symfony\Component\Intl\Countries, falling back to
the stored name on lookup failure. Adds symfony/intl + a unit test.
Co-authored-by: Lukas Selch <selchlukas@icloud.com>
Laravel foreignId() creates BIGINT UNSIGNED columns, but users.id and
companies.id use increments() (INT UNSIGNED). MySQL 8 rejects foreign keys
when referencing and referenced column types differ (error 3780).
Use unsignedInteger for impersonation_logs admin_id/user_id and for
company_invitations company_id, user_id, and invited_by. Keep foreignId
for role_id since roles.id is bigIncrements.
This fixes upgrades from v2 on MySQL when running v3.0 migrations.
Made-with: Cursor
3.x is now the default branch, so Dependabot re-surfaced the symfony/guzzle
advisories (composer.lock was never bumped on v3). Same fix as v2's #674:
- laravel/framework -> 13.15.0 (CVE-2026-48019, CRLF in the email rule)
- symfony/{mime,http-kernel,mailer,routing,yaml,polyfill-intl-idn} -> patched
- guzzlehttp/psr7 -> 2.11.0 (host-confusion + CRLF advisories)
composer audit clean.
* build: migrate frontend tooling to pnpm (v3)
Rebuilds the stale #673 on current 3.x so it doesn't revert #657's test
split, the Node-24 action bumps, or composer-install@4.0.0.
- package.json: packageManager pnpm@11.6.0; drop dead 'resolutions'
- pnpm-workspace.yaml: nodeLinker hoisted, allowBuilds vue-demi,
overrides brace-expansion (replaces resolutions)
- pnpm-lock.yaml generated via 'pnpm import' from yarn.lock (keeps the
resolved versions, incl. vite 8.0.3 / rolldown rc.12); yarn.lock removed
- docker.yaml + release.yaml: pnpm/action-setup@v6 + cache pnpm + pnpm
install/build (action versions and the #657 split left intact; check.yaml
needs no change — its test job is PHP-only after #657)
- 3 Dockerfiles: node:24 + corepack + pnpm install --frozen-lockfile && pnpm build
- Makefile, composer 'dev' script, CLAUDE.md, .gitignore -> pnpm
* fix(deps): pin vite to 8.0.5 (security)
Now that 3.x is the default branch, Dependabot flags vite <8.0.5. Pin to
8.0.5 (the patched version), which keeps rolldown 1.0.0-rc.12 — still
below 8.0.15 where the broken rolldown 1.0.3 (the init_runtime_dom_esm_bundler
chunk regression) starts, so the build stays clean. Mirrors v2's #674.
* ci: speed up the test job (disable Xdebug, drop frontend build, run parallel)
The `tests` job in check.yaml carried three sources of wasted wall-clock,
none of which it actually used:
- `coverage: xdebug` loaded Xdebug into every PHP process, but no step ever
passes `--coverage` — so it was pure tax (~2-3x slower execution). Switch
to `coverage: none`. If coverage is wanted later, use pcov + `--coverage`.
- The job ran `npm install` + `npm run build` before the PHP tests. The
feature suite is API/JSON only (49/56 feature files use getJson/assertJson)
and nothing renders the Vite blade, so the built assets are never needed.
Drop the Node/Vite steps; release & docker workflows still build assets.
- Tests ran single-process. brianium/paratest is already installed and the
runner has 4 cores, so run `php artisan test --parallel`.
Validated locally: full suite passes in parallel (exit 0), including
repeated runs of the two filesystem-writing module tests — no races.
docker.yaml carries the same pattern but only runs on release/nightly cron,
so it is left for a follow-up.
* ci: apply the same test-job speedups to docker.yaml
The release/nightly `tests` job in docker.yaml carried the identical waste
that check.yaml had: Xdebug loaded but never used for coverage, an
unnecessary frontend build before the PHP tests, and serial execution.
Mirror the check.yaml fix: coverage: none, drop the Node/Vite steps
(the suite is API/JSON and the separate release_artifact_build job builds
its own assets), and run php artisan test --parallel.
* ci: run module-scaffolding tests serially under --parallel
The Modules/* tests (module:make ScaffoldProbe + modules_statuses.json
toggles) mutate shared on-disk module state. paratest isolates the DB
per worker but NOT the filesystem, so concurrent workers boot with
ScaffoldProbe enabled and fatal on the un-autoloaded ServiceProvider
(31 failures). Tag them 'modules' (Pest group on Feature/Company/Modules)
and split CI: parallel --exclude-group=modules, then serial --group=modules.
* ci: stub Vite in tests + bump all actions to Node 24 versions
Part A (fixes#657): the customer-portal entrypoint test renders the SPA
shell (app.blade.php → @vite). With the frontend build dropped from CI
there's no manifest, so it 500'd (ViteManifestNotFoundException). Call
$this->withoutVite() in TestCase::setUp() so SPA-shell renders work
without a built manifest; the build stays dropped.
Part B: bump every Node-20 action to its node24 release — checkout v4->v6,
setup-node v4->v6, paths-filter v3->v4, cancel-workflow-action 0.12.1->0.13.1,
softprops/action-gh-release v2->v3, docker/{setup-buildx v3->v4, login v3->v4,
metadata v5->v6, build-push v5->v7}. setup-php@v2, ramsey/composer-install@v2
(composite) and svenstaro/upload-release-action@v2 are already node24.
* ci: bump ramsey/composer-install v2 -> 4.0.0 (node24 internal cache)
composer-install@v2 is composite but internally calls actions/cache@v3
(Node 20), which still trips the deprecation. 4.0.0 uses actions/cache
v5.0.3 (Node 24) and keeps the composer-options input we use.
The per-line item description typed into the row's textarea was never
saved. BaseItemSelect's description field pushes edits out only via an
`update:description` emit, but DocumentItemRow (the single shared row used
by invoices, estimates, and recurring invoices) wired only `@search` and
`@select` — so the emit was dropped and `form.items[index].description`
never updated. On submit the field was lost; a catalog item's description
also reverted on the next re-render.
Capture the event and route it through the existing `updateItemAttribute`
store updater, mirroring how name/quantity/price/discount already sync.
One fix covers all three document types since they share the row.
Also add the missing `items.*.description` nullable rule to
RecurringInvoiceRequest for parity with the invoice/estimate requests.
The backend already persisted and returned description correctly; this was
purely a dropped frontend event.
- 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.
v3 port. Invoice/estimate/recurring creation and update accepted total,
sub_total, tax and due_amount straight from the request with no recalculation,
letting a client persist financial totals that don't match the line items
(and corrupt the invoice update due-amount/paid-amount logic which keyed off
the client total).
- Adds App\Support\DocumentTotals (mirrors the front-end calc) trusting only
price/quantity/discounts/tax-line amounts.
- getInvoicePayload/getEstimatePayload/getRecurringInvoicePayload override the
client totals; the shared DocumentItemService::createItems recomputes each
item total; InvoiceService::update keys its due-amount logic off the
recomputed total.
Adds DocumentTotals unit tests + a feature test proving a tampered invoice
total is ignored; existing create/update tests no longer assert the now
server-authoritative derived totals.
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.
v3 port. orderByField/orderBy were passed straight into Eloquent's orderBy()
in every model's scopeWhereOrder (and Invoice::scopeApplyFilters), allowing
arbitrary SQL in the ORDER BY clause.
Adds App\Support\SafeOrderBy::apply() (plain/table-qualified column identifier
only, asc/desc clamp) and routes all 10 model sort sinks through it. Aliased
sorts (e.g. estimates by customers.name) stay valid.
Adds unit tests for injection rejection, plain + aliased columns, direction clamp.
v3 port. Customer PDF controllers resolved the target document by raw
mailable_id ignoring mailable_type, and skipped expiry on the JSON endpoints.
- Resolve via $emailLog->mailable + assert the expected type (404) to close
cross-type disclosure.
- Enforce isExpired() (403) on every public path incl. the JSON endpoints.
- Harden EmailLog::isExpired() against a null/unresolvable mailable.
Adds tests for cross-type 404, JSON-path expiry 403, and the valid path.