* 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
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
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'".
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.
* 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 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>
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>
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. 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.
v3 port of the v2 authorization fixes.
- Notes IDOR (GHSA-85wc): NotePolicy checks the note's company_id and
NotesController passes the bound model to authorize() on show/update/destroy.
- Estimate<->Invoice convert IDOR (GHSA-j2vg): EstimatesController::convertToInvoice
and InvoicesController::convertToEstimate authorize 'view' on the source
document before creating the target.
- Member bulk-delete (GHSA-wxrv): MembersController scopes ids via
User::whereCompany() before MemberService::delete.
Adds feature tests for cross-company 403s + same-company happy paths.
The AI chat assistant scoped tool queries by company but ignored the
per-user Bouncer abilities the rest of the app enforces, so any `use ai`
holder could read customers, invoices, payments, and company financials
their role couldn't otherwise see. Each AiTool now declares a required
ability (entity-aligned); the registry hides unauthorized tools from the
model and refuses to execute them as a backstop.
Separately, admin/owner-supplied URLs were fetched server-side with no
guard against private/reserved targets (SSRF): the AI base URL, the
CurrencyConverter "DEDICATED" exchange-rate URL, and S3/Spaces file-disk
endpoints. A shared PrivateNetworkGuard now backs a PublicHttpUrl
validation rule (save-time) and runtime guards in each driver.
- AiTool::requiredAbility() + mapping across all 12 tools
- AiToolRegistry filters schemas() by ability and re-checks in execute()
- PrivateNetworkGuard / BlockedUrlException / PublicHttpUrl rule (new)
- Rule wired into AI config (service + 3 controllers), exchange-rate,
and file-disk endpoints; runtime guards in OpenRouterDriver,
CurrencyConverterDriver, and FileDiskService
- Tests for ability filtering, the guard, the rule, and 422 rejections
Adds three new read-only tools the chat LLM can call to answer
"who/what did the most X" questions that previously fell through
the cracks:
- rank_top_customers — ranks customers by invoiced_total, paid_total,
invoice_count, or outstanding_balance over a named time period
- rank_top_items — ranks catalog items by quantity_sold or revenue
- rank_expense_categories — ranks expense categories by total spend
All three share a new ResolvesPeriod trait that centralizes the
period-name → [start, end] logic. GetCompanyStatsTool is refactored
onto the same trait (identical public schema — the 'all_time' option
is only exposed on the new ranking tools, where an unbounded window
makes sense; stats over "all time" collapses every record into one
giant bucket and is rarely useful).
Each tool follows the existing pattern: snake_case name, one-sentence
description tuned for LLM tool selection, JSON-schema parameters
with injected company scoping (never trusting LLM-supplied company
IDs), and JSON-encodable output. outstanding_balance on the customer
tool explicitly ignores the period param since it's a current-state
snapshot.
Multi-company scoping tests lock down the session-authoritative
boundary on every new tool. Per-metric ordering tests verify the
aggregate queries actually rank correctly, and an ad-hoc-item
exclusion test verifies rank_top_items skips invoice lines where
item_id is null (free-typed entries that have no catalog row to
rank by id).
15 new tests added (tests/Feature/Ai/Tools/); test suite grows from
398 to 413 passing. LLM tool count goes from 9 to 12 — the model
will discover the new tools automatically via the function-calling
schema with no prompt changes required.
Third and final phase of the AI feature. A SparklesIcon button is added to every Tiptap WYSIWYG editor (invoice notes, email body compose, note templates — ~6 places where RichEditor is used) that opens a modal with a prompt input, optional 'use current content as context' toggle, preview area, and Insert / Replace / Regenerate actions.
**Backend (thin)** — AiTextGenerationService is stateless: resolve config → check text_generation_enabled → instantiate driver → call textCompletion() with a system-prompt-wrapped user instruction. The system prompt is terse and opinionated: 'Return only the requested text. No preamble, no explanation, no markdown code fences.' When context is provided, it's included as a separate framed block ('Context (current content the user is working with):') so the model knows it's operating on existing copy.
**GenerationController** — POST /api/v1/ai/generate with {prompt, context?}. Validates prompt required (max 4000 chars) and context optional (max 20000 chars). Rate-limited via the same 'ai' RateLimiter from Phase 2 (30/min per user/company). Gated by 'use ai' Bouncer ability + AiConfigurationService resolution. Returns {text} on success or {error, message} with 422 on any AiException.
**Frontend modal (AiTextGenerationModal.vue)** — mounted globally in CompanyLayout when bootstrap reports ai.enabled && text_generation_enabled. Uses the existing modalStore pattern: self-registers on componentName='AiTextGenerationModal'. Modal state includes prompt, useContext toggle, generatedText preview. Callers (currently RichEditor) pass onInsert/onReplace callbacks via modalStore.data; the modal invokes them with the final text and closes — it knows nothing about tiptap or ProseMirror.
**RichEditor integration** — the Sparkles toolbar button is pushed onto the existing editorButtons ref at setup time, gated on globalStore.ai.enabled && text_generation_enabled. The button opens the modal with the editor's current getHTML() as context and callbacks that use the tiptap chain API: insertContent for Insert, selectAll().deleteSelection().insertContent for Replace. No reactivity on the flag check — it's set once at bootstrap and doesn't change during a session.
**Tests** (7 new) — AiGenerationTest with a dedicated TextGenDriver test double that tracks the exact prompt passed to textCompletion(). Covers: happy path, context inclusion/omission, AI globally disabled rejection, text_generation role disabled rejection, prompt/context length validation, response whitespace trimming.
395 tests pass (was 388, +7 new). Pint clean. npm run build clean. The AI feature is now complete end-to-end: provider configuration (Phase 1), chat assistant with DB tool-calling (Phase 2), and text generation popup (Phase 3).
Second phase of the AI feature. Users can now open a slide-in chat drawer from the SiteHeader and ask natural-language questions about their company's invoices, customers, payments, and expenses. The LLM invokes pre-defined read-only tool functions (scoped to the current company at execute time) to fetch data and synthesize answers.
**Database** — new ai_conversations and ai_messages tables. Messages are stored in OpenAI's chat format so AiAssistantService serializes a conversation into an API request with zero translation. Columns: role, content, tool_call_id, tool_calls JSON, model, tokens_in, tokens_out. Conversations are scoped (company_id, user_id) — one user's chats are invisible to everyone else, even inside the same company. Foreign-key cascade deletes.
**Tool infrastructure** — AiTool abstract base + AiToolRegistry singleton (registered in a new AiServiceProvider). The base class enforces the security rule: every tool's execute() receives companyId and userId as injected parameters; tools' JSON schemas NEVER include a company_id field. An LLM physically cannot pass a company_id and escape tenancy. Modules can register their own tools by resolving the registry from their own ServiceProvider::boot().
**Nine built-in tools**: search_invoices, get_invoice, search_customers, get_customer, list_recent_payments, list_overdue_invoices, get_company_stats (aggregates for named periods), search_items, list_expense_categories. All read-only; no mutations. Each returns JSON-encodable data the LLM can parse.
**AiAssistantService orchestration loop** — the heart of Phase 2. Flow: persist user message → build payload from system prompt + recent history (40-message window) + new user message → call driver.chatCompletion with tools → if tool_calls, execute each one via the registry (with injected scope), persist tool result, loop → if plain text, persist and return. Hard cap at 5 iterations to prevent runaway LLMs. System prompt pins the assistant to this company's data and forbids mutation.
**Controllers + policy + rate limit** — POST /api/v1/ai/chat runs the orchestration loop. GET/PATCH/DELETE /api/v1/ai/conversations for CRUD. AiConversationPolicy enforces user_id+company_id match on every action. A new 'ai' RateLimiter in RouteServiceProvider throttles to 30 req/min per (user, company). New 'use ai' Gate defined in AppServiceProvider returns true for any authenticated user — the per-company kill-switch still goes through AiConfigurationService::resolveForCompany.
**Frontend** — new features/company/ai/ folder with a Pinia store (ai-chat.store.ts) holding drawer state, current conversation, messages, and loading flags. AiChatDrawer.vue is a slide-in panel teleported to <body>, mounted globally in CompanyLayout.vue when bootstrap reports ai.enabled && ai.chat_enabled. Sub-components: AiChatMessage (user bubbles vs assistant bubbles), AiChatMessageInput (Enter submits, Shift+Enter newline), AiChatConversationList (sidebar with 'new chat' button, rename, delete). A SparklesIcon button in SiteHeader toggles the drawer.
**Driver test double** — tests use a ScriptedAiDriver registered via AiDriverFactory::register('scripted', ...) that returns pre-queued AiChatResponse objects. Feature tests cover: happy path (new conversation + message persistence), tool-call loop (multi-round-trip with search_invoices), runaway-loop cap, driver-throws path, ai_enabled=NO rejection, chat role disabled rejection, per-user conversation visibility, cross-user policy enforcement, cascade delete.
388 tests pass (was 372, +16 new). Pint clean. npm run build clean. Phase 3 (WYSIWYG text generation popup) is the remaining follow-up.
Foundation for the AI chatbot + text generation feature. Phase 1 is infrastructure only: driver plumbing, configuration storage with encrypted API keys, global vs per-company resolution, admin + company UI pages, and an optional installer wizard step. The chat assistant and text-generation WYSIWYG integration come in later phases.
**Driver plumbing (app/Support/Ai/)** — AiDriver abstract, AiDriverFactory, AiException, AiChatResponse DTO, OpenRouterDriver concrete implementation. OpenRouter is the OpenAI-compatible aggregator that unlocks hundreds of models behind one API key and one request shape — ideal as the default v1 driver. Drivers are extensible the same way exchange rate drivers are: the module Registry's generic registerDriver('ai', ...) machinery plus a typed Registry::registerAiDriver() convenience wrapper (shipped in the upstream invoiceshelf/modules package in a paired commit).
**AiConfigurationService** — mirrors MailConfigurationService shape but with one deliberate deviation: API keys are encrypted at the service layer via Crypt::encryptString before persistence. OpenRouter bearer tokens have much bigger blast radius than SMTP passwords. Same settings / company_settings tables, same global-vs-per-company pattern, same use_custom_ai_config override toggle. Resolution order: global ai_enabled must be YES, then the company either overrides via use_custom_ai_config=YES (and can opt out with ai_enabled=NO inside the override) or inherits the global config.
**Controllers** — Admin/Settings/AiConfigurationController (global CRUD + driver list + test connection), Company/Settings/CompanyAiConfigurationController (per-company override + test), Setup/AiConfigurationController (installer wizard step, skippable with explicit ai_enabled=NO). API key is always masked as '********' in GET responses — the frontend submits the placeholder back on save and the backend preserves the stored value.
**Installer wizard** — new optional step 7 'AI' between Mail and Account. Default OFF with a Skip button. MailView.vue now routes to installation.ai instead of installation.account; installation.ai then routes to installation.account. Step order comment updated in routes.ts.
**Admin + Company settings pages** — AdminAiConfigView (no toggle, always global) and AiConfigView (with use_custom_ai_config BaseSwitchSection that auto-saves OFF). Both share AiConfigurationForm which renders the driver selector, API key input with show/hide, driver-specific config_fields (base_url for OpenRouter), and per-role enable toggles with free-text model inputs backed by a datalist of suggested models from driver metadata.
**Bootstrap endpoint** — adds an ai block to the response: { enabled, chat_enabled, text_generation_enabled }. All three are booleans resolved through AiConfigurationService::resolveForCompany(). Never leaks the API key. Frontend feature flags read from bootstrapData.ai to decide whether to show Phase 2/3 UI.
**Bouncer ability** 'manage ai config' added to SettingsPolicy, gated on isSuperAdmin() (same pattern as manage email config, manage pdf config).
**Tests** (22 new) — Unit: AiDriverFactory resolves built-in + Registry-contributed drivers, rejects unknown, merges availableDrivers. AiConfigurationService: encryption round-trip, resolution order (3 cases: global off, inherit global, override with company key, override with opt-out), makeDriver null/instance cases, listDrivers metadata. Feature: admin save + read with api key masking, preserve-on-placeholder behavior, company toggle ON/OFF semantics, bootstrap ai flags reflect resolution, company opt-out path.
372 tests pass (was 350, +22). Pint clean. npm run build clean. Phase 2 (chat assistant + tool calling) and Phase 3 (WYSIWYG text generation popup) are separate follow-up commits — this one is the foundation only.
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.
The per-company Modules management page moves off its own top-level sidebar slot (which sat in the Admin group alongside Members/Reports/Settings) and into a new Module Configuration entry inside Company Settings, alongside Tax Types, Payment Modes, Mail Configuration, etc. That's where every other 'configure how the company behaves' surface lives — the Modules page is a configuration surface, not a primary working area.
The label is deliberately 'Module Configuration' rather than 'Module Settings' because the latter collides with the existing per-module ModuleSettingsModal concept (the modal that opens when a user clicks an installed module's gear icon). Keeping the two names distinct means 'Module Configuration' unambiguously refers to the list of installed modules, and 'Module Settings' continues to mean the per-module schema form.
CompanyModulesIndexView is stripped of its standalone BasePage / BasePageHeader / BaseBreadcrumb wrappers — as a child of SettingsLayoutView it would have rendered a double header — and re-wrapped in BaseSettingCard, matching TaxTypesView and every other settings-child view. The module grid tightens from lg:grid-cols-2 xl:grid-cols-3 down to lg:grid-cols-2 since the settings sidebar eats 240px of horizontal real estate.
Routes consolidate: features/company/modules/routes.ts is deleted; the new settings.modules child route lives inside the settings routes file directly, alongside the rest. Top-level redirects are kept for the legacy /admin/modules and /admin/modules/:slug/settings URLs so existing bookmarks still resolve. ModuleRoutesConfigTest is re-pointed at settings/routes.ts and asserts the settings.modules route is owner-only.
Module-contributed sidebar entries (those registered via Registry::registerMenu()) are NOT moved. Modules that want top-level navigation visibility keep it; only the meta management page moves. This mirrors WordPress/Discourse conventions where plugin pages stay in the main navigation but the 'Plugins' admin screen itself lives under Settings.
Exchange rate providers are now pluggable via the module Registry. The four built-in drivers (currency_converter, currency_freak, currency_layer, open_exchange_rate) move from a static config array into App\\Providers\\DriverRegistryProvider, which calls Registry::registerExchangeRateDriver() for each during app boot with metadata the frontend needs: label (i18n key), website (help-text URL), and config_fields (schema for driver-specific driver_config JSON).
The Currency Converter's server-type selector and dedicated URL field — previously hardcoded in ExchangeRateProviderModal.vue — are now just another config_fields entry with a visible_when rule that shows the URL input only when type=DEDICATED. Any module that wants to ship a custom driver gets the same treatment for free: declare config_fields in the registration, and the host app's modal renders them automatically.
ExchangeRateDriverFactory::make() falls back to Registry::driverMeta() when a name isn't in the local built-in map, and availableDrivers() merges both sources. ConfigController handles the exchange_rate_drivers key specially by mapping Registry::allDrivers('exchange_rate') to enriched option objects, so the config-file route still works for every other key. The static exchange_rate_drivers + currency_converter_servers arrays in config/invoiceshelf.php are deleted.
Unit tests cover the new Registry::register/flushDrivers, the factory merging built-ins with Registry-contributed drivers, and the factory rejecting unknown names. A feature test exercises the end-to-end /api/v1/config?key=exchange_rate_drivers response shape.
NOTE: this commit depends on invoiceshelf/modules package commit e44d951 which adds the Registry driver API. The package needs to be released and pinned in composer.json before a fresh composer install on this commit will work.
Every main_menu entry moves from numeric group (1/2/3) to string-based group + group_label + priority. Groups now carry their own i18n label and child entries are sorted by an explicit priority field instead of config-array order, so module-contributed menu items can slot into any existing group at any position.
BootstrapController merges module-registered menu items into main_menu (previously they lived in a separate module_menu response key) and introduces a user_menu response key for items modules want to place in the avatar dropdown. The global store follows suit: moduleMenu becomes userMenu, menuGroups is a computed that sorts by priority, and hasActiveModules drops out.
New admin Appearance setting page with a single toggle for whether sidebar group labels render — so instances that prefer a compact sidebar can hide the Documents/Administration/Modules headings without losing the grouping itself. CompanyLayout watches route meta and re-bootstraps when the admin-mode flag flips so the sidebar repaints with the right menu on navigation across the admin boundary.
Test suites updated: module menu merging is asserted against main_menu (name: 'module-{slug}') rather than the old module_menu response; HelloWorldIntegrationTest verifies the schema translation path; CompanyModulesIndexTest covers the display_name attachment.
Rewires module installation to use slug + version + checksum_sha256 instead of the opaque module identifier. ModuleInstaller splits marketplace token handling out of install() into helpers, adopts structured error responses, and validates the downloaded archive's SHA-256 against the marketplace manifest before unpacking.
ModuleResource is simplified to accept an already-loaded installed-module instance rather than fetching it from state, exposes access_tier and checksum fields, and drops the auto-disable-on-unpurchased side effect that was bleeding write logic into a read resource. UnzipUpdateRequest accepts a nullable module with a conditional module_name field so the same endpoint serves both app and module updates.
ModulesPolicy::manageModules now short-circuits for super-admins so administration flows (token validation, store state) are not blocked on a company-scoped ability. Two new feature tests cover both the authorization bypass and ModuleResource serialization.
Currency dropdowns now display the most-traded currencies (USD, EUR, GBP,
JPY, CAD, AUD, CHF, CNY, INR, BRL) at the top, followed by the rest
alphabetically. The install wizard defaults to USD instead of EUR and
formats currency names as "USD - US Dollar" for consistency with the
rest of the app.
End-to-end coverage for the new module APIs and the custom module:make
stubs shipped from invoiceshelf/modules. Each test file is hermetic — uses
\InvoiceShelf\Modules\Registry::flush() in setup/teardown to prevent
cross-test contamination, and ModuleMakeStubTest cleans up generated test
artifacts (the throwaway scaffold directory and the storage statuses entry).
- CompanyModulesIndexTest: 4 tests covering only-enabled-modules filter,
has_settings flag computed against the real Registry, menu inclusion, and
the empty-state response.
- ModuleSettingsControllerTest: 7 tests covering 404 for unregistered slug,
show schema + defaults round-trip, persistence with the
module.{slug}.{key} prefix, missing-required-field rejection, unknown-key
silent-drop, update 404, and per-company isolation (the load-bearing
multi-tenancy guarantee).
- BootstrapModuleMenuTest: 3 tests covering Registry-driven module_menu
population on the company-context bootstrap branch, the empty default
when nothing is registered, and the absence of module_menu on the
super-admin-mode branch.
- ModuleMakeStubTest: 3 tests that actually run
Artisan::call('module:make', ['name' => ['ScaffoldProbe']]) against a
throwaway module name and assert the generated ServiceProvider contains
use InvoiceShelf\Modules\Registry, the generated composer.json requires
invoiceshelf/modules: ^3.0, and starter lang/en/{menu,settings}.php exist.
Validates that the custom stubs shipped from the package are picked up
via Stub::setBasePath().
Major changes to the file disk subsystem:
- Each FileDisk now gets a unique Laravel disk name (disk_{id}) instead
of temp_{driver}, fixing the bug where multiple local disks with
different roots overwrote each other's config.
- Move disk registration logic from FileDisk model to FileDiskService
(registerDisk, getDiskName). Model keeps only getDecodedCredentials
and a deprecated setConfig() wrapper.
- Add Disk Assignments admin UI (File Disk tab) with three purpose
dropdowns: Media Storage, PDF Storage, Backup Storage. Stored as
settings (media_disk_id, pdf_disk_id, backup_disk_id).
- Backup tab now uses the assigned backup disk instead of a per-backup
dropdown. BackupsController refactored to use BackupService which
centralizes disk resolution. Removed stale 4-second cache.
- Add local_public disk to config/filesystems.php so system disks
are properly defined.
- Local disk roots stored relative to storage/app/ with hint text
in the admin modal explaining the convention.
- Fix BaseModal watchEffect -> watch to prevent infinite request
loops on the File Disk page.
- Fix string/number comparison for disk purpose IDs from settings.
- Add safeguards: prevent deleting disks with files, warn on
purpose change, prevent deleting system disks.
When inviting an email without an InvoiceShelf account, the email now
links to a registration page (/register?invitation={token}) instead of
login. After registering, the invitation is auto-accepted.
Backend:
- InvitationRegistrationController: public details() and register()
endpoints. Registration validates token + email match, creates account,
auto-accepts invitation, returns Sanctum token.
- AuthController: login now accepts optional invitation_token param to
auto-accept invitation for existing users clicking the email link.
- CompanyInvitationMail: conditional URL based on user existence.
- Web route for /invitations/{token}/decline (email decline link).
Frontend:
- RegisterWithInvitation.vue: fetches invitation details, shows company
name + role, registration form with pre-filled email.
- Router: /register route added.
Tests: 3 new tests (invitation details, register + accept, email mismatch).
Redistribute methods:
- show() -> BootstrapController::currentCompany()
- store(), destroy(), userCompanies() -> Admin\CompaniesController
- transferOwnership() -> CompanySettingsController
Security fix: introduce 'owner' role for company-level admin, distinct
from 'super admin' which is now global platform admin only.
- CompanyService::setupRoles() creates 'owner' role per company
- Company creation assigns scoped 'owner' role instead of global 'super admin'
- Seeders updated to assign 'owner'
Migration renames all existing company-scoped 'super admin' roles to
'owner' and ensures every company owner has the role assigned.
V1/Admin -> Company (company-scoped controllers)
V1/SuperAdmin -> Admin (platform-wide admin controllers)
V1/Customer -> CustomerPortal (customer-facing portal)
V1/Installation -> Setup (installation wizard)
V1/PDF -> Pdf (consistent casing)
V1/Modules -> Modules (drop V1 prefix)
V1/Webhook -> Webhook (drop V1 prefix)
The V1 prefix served no purpose - API versioning is in the route prefix
(/api/v1/), not the controller namespace. "Admin" was misleading for
company-scoped controllers. "SuperAdmin" is now simply "Admin" for
platform administration.
Backend:
- Extract user profile methods (show, update, uploadAvatar) from
CompanyController into new UserProfileController
- CompanyController now only handles company concerns (updateCompany,
uploadCompanyLogo)
- Remove Account Settings from setting_menu config
Frontend:
- New /admin/user-settings page with 3 tabs: General, Profile Photo,
Security (password change)
- User dropdown now links to /admin/user-settings instead of
/admin/settings/account-settings
- Settings sidebar defaults to Company Information as first item
- Remove old monolithic AccountSetting.vue
Relocate all 14 files from the catch-all app/Space namespace into proper
locations: data providers to Support/Formatters, installation utilities to
Services/Installation, PDF utils to Services/Pdf, module/update classes to
Services/Module and Services/Update, SiteApi trait to Traits, and helpers
to Support.
Extract ~1,400 lines of business logic from 8 fat models (Invoice, Payment,
Estimate, RecurringInvoice, Company, Customer, Expense, User) into 9 new
service classes with constructor injection. Controllers now depend on
services instead of calling static model methods. Shared item/tax creation
logic consolidated into DocumentItemService.
* Fix CustomerPolicy missing hasCompany() check (cross-company IDOR)
Add $user->hasCompany($customer->company_id) check to view, update,
delete, restore, and forceDelete methods in CustomerPolicy, matching
the pattern used by all other policies (InvoicePolicy, PaymentPolicy,
EstimatePolicy, etc.).
Without this check, a user in Company A with view-customer ability
could access customers belonging to Company B by providing the target
customer's ID.
Add cross-company authorization tests to verify the fix.
Closes#565
* Scope bulk delete to current company to prevent cross-company deletion
Filter customer IDs through whereCompany() before passing to
deleteCustomers(), ensuring users cannot delete customers belonging
to other companies via the bulk delete endpoint.
* feat: Tax included
* Added a toggle switch in tax settings to enable the feature.
* Database migration adding tax_included field into estimates, invoices
and recurring invoices table.
* Toggle switch to enable and store the tax_included by estimates,
invoices and recurring invoices.
* In case of tax included enabled, total taxes will be recalculated and
the invoices, estimates and recurring invoices total won't be sum with
taxes.
* Apply tax included when discount_per_item/tax_per_item item is enabled.
* Custom component to show the net total when tax included is enabled.
* Update invoice and estimates pdfs with net total.
* chore: Tax included by default
A switch button inside the tax settings to enable the tax included by
default in invoices, estimates and recurring invoices.
* Possibility to set a fixed amount on tax types settings
* Pint and manage flat taxes on items
* Fix display errors and handle global taxes
* Tests
* Pint with PHP 8.2 cause with PHP 8.3 version it cause workflow error
* Merging percent and fixed amount into one column
* Now display the currency on SelectTaxPopup on fixed taxes
* Clone estimates
* Clone estimate test feature
* Resolve namespace
* Fix string to int for Carbon
* Fix homes routes and default queue key
* Move dropdown item below View and use the propper translation key
* Convert string references to `::class`
PHP 5.5.9 adds the new static `class` property which provides the fully qualified class name. This is preferred over using strings for class names since the `class` property references are checked by PHP.
* Use Faker methods
Accessing Faker properties was deprecated in Faker 1.14.
* Convert route options to fluent methods
Laravel 8 adopts the tuple syntax for controller actions. Since the old options array is incompatible with this syntax, Shift converted them to use modern, fluent methods.
* Adopt class based routes
* Remove default `app` files
* Shift core files
* Streamline config files
* Set new `ENV` variables
* Default new `bootstrap/app.php`
* Re-register HTTP middleware
* Consolidate service providers
* Re-register service providers
* Re-register routes
* Re-register scheduled commands
* Bump Composer dependencies
* Use `<env>` tags for configuration
`<env>` tags have a lower precedence than system environment variables making it easier to overwrite PHPUnit configuration values in additional environments, such a CI.
Review this blog post for more details on configuration precedence when testing Laravel: https://jasonmccreary.me/articles/laravel-testing-configuration-precedence/
* Adopt anonymous migrations
* Rename `password_resets` table
* Convert `$casts` property to method
* Adopt Laravel type hints
* Mark base controller as `abstract`
* Remove `CreatesApplication` testing trait
* Shift cleanup
* Fix shift first issues
* Updating Rules for laravel 11, sanctum config and pint
* Fix Carbon issue on dashboard
* Temporary fix for tests while migration is issue fixed on laravel side
* Carbon needs numerical values, not strings
* Minimum php version
* Fix domain installation step not fetching the correct company_id
* Fix Role Policy wasn't properly registered
---------