mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-08-04 07:02:13 +00:00
dbc6ca7ad674508ccb69bb74da55421db06ae8f5
102 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dbc6ca7ad6 | fix(recurring-invoices): scale scheduled generation | ||
|
|
8ae82ae91e | fix(documents): correct demo sequences and save errors (#740) | ||
|
|
1e72d9d449 | fix(invoices): require settlement before completion (#739) | ||
|
|
00c9c4268e |
fix(pdf): give the reports their margin back, and one shared chrome (#738)
* fix(pdf): put the page margin back on the report PDFs
The report templates carry no inset of their own. They set
`.sub-container { padding: 0px 20px }` and nothing else, and relied entirely on
dompdf's built-in 1.2cm page margin. #727 made DompdfDriver always inject an
`@page` rule from PdfPageSetup, and #735 defaulted those margins to zero so
invoice2 and estimate2 could bleed their header band to the paper edge. The
document templates were fine, they carry their own 30px/50px insets. The reports
were not: every one of them now renders flush against the paper, with the
company name's glyph box actually clipped 1.2pt above the top edge.
The zero default has to stay for documents, so reports get a margin of their
own: `pdf.page.report_margin`, PDF_REPORT_MARGIN, defaulting to the 1.2cm they
were drawn against. It is a separate key on purpose, so an operator tuning the
document margins for their invoice template does not silently reflow every
report as a side effect.
A page margin rather than padding on the templates because reports run to
several pages and padding only insets the first one. Sales by customer already
spans two on the demo data, and page two moves with the rest.
Plumbed as an optional PdfPageSetup on the driver contract, defaulting to the
configured page, so every existing call site renders exactly as before and only
the five report controllers ask for anything different.
Measured on the five reports, page 595.28 x 841.89pt, 1.2cm = 34.02pt: first
page ink moves from xMin 15.0-15.8 / yMin -1.2 to xMin 49.0-49.8 / yMin 32.8,
every axis shifting by exactly the margin.
Also fixes three untranslated keys this exposed: the expenses report printed its
column headings as the literal strings "expenses.date", "expenses.note" and
"expenses.amount", which have never existed in lang/en.json. They are now
pdf_expense_{date,note,amount}_label, and a test pins that every translation key
a report template uses resolves in English.
* fix(pdf): put the minus sign in front of the currency symbol
format_money_pdf() formatted the signed value and then concatenated the symbol,
so a negative amount came out as "$-24,738.00". Credit notes made that common:
every line on a credit note PDF reads negative, and one credit note in a period
is enough to make the customer sales report show a negative total.
The magnitude is formatted first now and a single minus is prefixed to the whole
assembled string, so the sign leads and the symbol stays glued to the digits.
Only the symbol-first branch changes. number_format() already put the sign in
front of the digits, so a trailing-symbol currency read "-24,738.00$" before and
is byte-identical after.
The sign is decided on the formatted digits rather than on the raw input, so an
amount that rounds away at the currency's precision renders as zero rather than
as "-0". A stray cent on a zero-precision currency is the case that needs it.
* refactor(pdf): one shared chrome for the report PDFs
The five report templates were five drifted copies of one 2018 stylesheet, and
the insets had stopped agreeing with each other. profit-loss alone put its
header and income row at +20px, its "Expenses" heading at +23px, its category
rows at +30px, and its total rule and NET PROFIT band at +0, because that markup
sat outside the container everything above it was in. Four left edges on one
page. Every report also carried the same self-cancelling total rule, where
`padding: 0px` follows the two longhands it silently overrides, and expenses
carried six rule blocks nothing referenced at all, including the only horizontal
rule in the file.
There is now one layout partial and one stylesheet, and each report is content
only: 236 lines down to 47 for profit-loss, and about 1200 lines deleted across
the five. One content edge, measured: every band starts at 34.016pt and every
amount ends at 561.260pt, on every page of every report.
What changed on the page:
- Real tables with a thead, so column headings repeat across page breaks. Only
expenses had headings before and none of them used thead. sales-items emitted
a separate table per item, which is why its rows never lined up.
- The company logo in the header, the same fallback-to-name pattern the document
templates use.
- An empty period renders a "no records" row. profit-loss, sales-items and
tax-summary rendered their total row and rule unconditionally, so a month with
no data showed a heading, a gap, a rule and a lone $0.00.
- Sections stay whole across a page break where they fit, and a section heading
never sits at the foot of a page with its rows overleaf.
- Credit notes stay in the sales totals, since a reversal netting the sale out is
correct, but the line is tagged so a CN- number is not read as a sale. It
reuses the document's own label, which is already in the shipped locales.
- Labels stopped carrying their own presentation: "TOTAL EXPENSE" (also
singular) is "Total expenses" and the stylesheet does the uppercasing.
The five controllers drop the dead colour-settings block: nine *_color settings
were queried and shared by every report, no template ever read them, and no
migration, seeder or UI ever wrote them, so the query always returned an empty
collection. Every other shared variable name is untouched, because a custom
report template is a copy that references them by name.
make:template had to learn the same lesson: it only ever copied
partials/table.blade.php, so a cloned report would extend a layout that does not
exist in its namespace and die on render. It now copies every partial a type
ships and rewrites references by view name, including partial-to-partial ones,
so each custom template still gets its own copies.
|
||
|
|
885042f13a | feat(expenses): add tax tracking and reporting (#737) | ||
|
|
3455ceb594 |
feat(invoices): native credit notes (#689)
* feat(invoices): add credit note (Stornorechnung) backend
Implement native credit notes as invoices with type=CREDIT_NOTE that
reference the original invoice they reverse. All monetary fields are
negated as integer cents (no float arithmetic).
- Migration: add invoices.type + related_invoice_id (unsignedInteger,
matching invoices.id which is INT UNSIGNED via increments() rather than
bigIncrements() -- required for the self-referencing foreign key to
form correctly on MySQL/MariaDB) + widen invoice_items.price/base_price
to signed (idempotent guards)
- Invoice model: TYPE_* constants, relatedInvoice/creditNotes relations,
isCreditNote() helper
- InvoiceService::createCreditNote() copies + negates the source invoice,
sets creator_id from the authenticated user
- POST /invoices/{invoice}/credit-note endpoint (201) with CreditNotePolicy
authorization; 422 when the source is already a credit note, 403 across
companies
- CreditNoteResource exposing type + related_invoice reference
- Dedicated credit-note PDF template (Stornorechnung header + reference line)
- SendCreditNoteRequest + SendCreditNoteMail + send endpoint/email template
- en/de translations for the credit-note PDF labels
- Feature tests: create, item negation, relation, 422, 403, PDF, email
Fresh implementation addressing all review feedback from PR #536.
Refs #317
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* feat(invoices): add credit note UI (button, badge, back-link)
- 'Create Credit Note' action in the invoice dropdown (hidden on rows that
are already credit notes)
- Credit-note badge in the invoice list
- Credit-note banner with a link back to the reversed invoice on the detail
view; show() returns CreditNoteResource for credit notes so the original
invoice reference is available
- Expose type + related_invoice_id on InvoiceResource
- invoiceService.createCreditNote + store action
- Invoice domain type: InvoiceType, RelatedInvoice, type/related fields
- en/de translations for the credit-note UI strings
Refs #317
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Settle balances on credit note creation and surface cancellation in UI
Creating a credit note (full reversal) now settles both documents, the
mechanism @gdarko praised in PR #536 ("Invoice due amount adjustment
logic for create/update/delete is well thought out"), adapted to the
invoice-row-with-type architecture:
- The original invoice's due_amount/base_due_amount drop to 0 and its
status/paid_status are recalculated through the existing
changeInvoiceStatus() path (COMPLETED/PAID), so it falls out of every
awaiting-payment view. The UI shows a distinct "Cancelled" badge and a
"Cancelled via credit note: ST-XXXX" banner instead of the generic
Paid badge, avoiding Xero's documented paid-vs-credited ambiguity.
- The credit note itself is created settled (due 0, PAID): nothing is
ever owed on it, so it never surfaces as an open negative balance.
- Deleting a credit note restores the original invoice's balance,
recomputed from recorded payments (integer cents), covering unpaid
and partially-paid invoices; skipped when both documents are deleted
in one batch.
- One credit note per invoice: a second full reversal would
double-negate the books (422 + hidden dropdown action).
- InvoiceResource exposes minimal credit_notes refs, mirroring the
existing related_invoice back-link, for the banner/badge in
InvoiceDetailView and InvoiceIndexView.
8 new feature tests cover settlement, resource exposure, the
one-per-invoice guard, and delete-side restoration.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Render credit notes through the invoice's own template, add cancellation banner to PDFs
Fixes two bugs found via screenshot: the actual generated PDF (not just the
Vue preview) is what customers download/print/email, and it was broken in
both directions.
1. Credit notes always rendered through one hardcoded standalone layout
(app/pdf/credit-note/credit-note.blade.php) regardless of which of the
3 invoice templates the company actually uses. A company on invoice2/3
branding got a credit note PDF that looked nothing like their real
invoices. Fixed by making invoice1/2/3.blade.php credit-note-aware
(swap number/date labels, add the red "CREDIT NOTE" banner + reference
line, scaled to each template's own visual language) and removing the
isCreditNote() special case in InvoiceService::getPdfData() so credit
notes now go through the same PdfTemplateUtils resolution as regular
invoices. The standalone template is deleted.
2. The original (now-cancelled) invoice's PDF showed zero indication it
had been reversed by a credit note — only the Vue UI banner existed.
Added an equivalent amber "Cancelled" banner + reference line to all
three templates, conditional on $invoice->creditNotes->isNotEmpty().
getPdfData() now eager-loads both relatedInvoice and creditNotes
unconditionally (cheap either way) so every template has what it needs.
Covers all 3 templates x both document types (6 combinations), verified
by generating real PDFs (not just Vue previews) for each. 4 new feature
tests hit the actual /invoices/pdf/{hash}?preview=1 endpoint under
non-default templates (invoice2, invoice3) and assert on
template-specific structural markers plus the new banner text, so this
class of bug (works on the default template, breaks on the others)
cannot regress silently.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Create credit notes as COMPLETED and hide Record Payment on settled documents
The credit note is born fully settled (due_amount 0, paid_status PAID), but
its own status was hardcoded to SENT at creation and never advanced. It
therefore showed a stale "Sent" badge forever and the detail view kept
offering "Record Payment" on a document with nothing owed.
- InvoiceService::createCreditNote() now creates the credit note with
STATUS_COMPLETED, matching the end-state the original invoice reaches
through the settlement path.
- The "Record Payment" button (detail view) and dropdown item now also
require due_amount > 0, so no already-settled document ever offers to
record a payment, regardless of its status string.
- CreditNoteTest: assert the credit note is created with STATUS_COMPLETED.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* Refresh invoice sidebar list after creating a credit note from detail view
Creating a credit note from the split-pane invoice detail view
(/admin/invoices/{id}/view) left the left-hand invoice list stale until a
manual page reload: the new credit note never appeared, and the original
invoice's row kept showing its pre-cancellation status.
InvoiceDropdown's createCreditNote() now calls the loadData/table refresh
callbacks after success, matching the pattern already used by
removeInvoice(). InvoiceDetailView wires loadData to a new
refreshInvoiceList() that resets invoiceList before refetching (loadInvoices()
is append-only, built for infinite scroll, so calling it without a reset
would duplicate every already-loaded row) — mirroring the existing
onSearched() reset-and-refetch pattern.
Also add the "Cancelled" badge to the sidebar's list-item template; it
previously only existed in InvoiceIndexView.vue, so the split-pane sidebar
never reflected an invoice's cancelled-by-credit-note state even after a
full reload.
* fix(migrations): drop the credit-note foreign key constraint
related_invoice_id kept a DB-level foreign key with nullOnDelete(). That is
against the codebase-wide convention established after the FK breakage in
PRs #618 / #683: reference columns are plain unsignedInteger plus an index,
with the relation declared on the model and cascades handled in app code.
Replace the constraint with an index, and swap the dropForeign() in down()
for a dropIndex() that runs before the column drop (MySQL and PostgreSQL
would drop the index with the column, SQLite leaves it behind and then fails
with "no such column").
Documents why the type default is load-bearing while here: it backfills
existing rows with INVOICE, and the type-scoped serial-number queries would
otherwise restart numbering on an existing install.
* feat(invoices): give credit notes their own number sequence
Credit notes were numbered out of the invoice sequence, so cancelling an
invoice consumed an invoice number and the two document series interleaved
(INV-000001, INV-000002 for a credit note, INV-000003 for the next invoice).
Credit notes are Invoice rows with type = CREDIT_NOTE, and SerialNumberService
derived both the format setting and the sequence lookup from the model class
alone, which cannot distinguish them.
SerialNumberService gains two optional, fluent hooks that leave the default
behaviour untouched:
- setSettingKey() overrides the company setting the format is read from, so a
document type sharing a table can have its own format.
- setSequenceScope() takes column => value constraints applied on top of the
company (and customer) filters in both setNextSequenceNumber() and
setNextCustomerSequenceNumber(), so each type counts on its own.
createCreditNote() uses both: credit_note_number_format (default
{{SERIES:CN}}{{DELIMITER:-}}{{SEQUENCE:6}}) and a CREDIT_NOTE scope. Every
call site that numbers a real invoice is scoped to TYPE_INVOICE so the credit
notes now sharing the table do not inflate the invoice sequence: create,
update, clone, the recurring-invoice generator, estimate conversion, and the
factory. The update() scope is not redundant: a customer change there triggers
a customer-sequence recompute through setModelObject(), which would otherwise
count credit notes. Estimate and payment numbering is unaffected.
The format is configurable in Settings alongside invoice numbering (a second
NumberCustomizer on the Invoices tab, no component changes needed) and is
seeded for new companies by CompanyService. A data migration backfills it for
existing companies: without a row, CompanySetting::getSetting() returns null
and the generated serial is silently empty.
The next-number endpoint answers key=credit_note with the CN format, so the
settings preview and any future create form read the right sequence.
* feat(pdf): show credit-note and cancellation banners on invoice PDFs
The generated PDF is what the customer downloads, prints and receives by
mail, and it was silent in both directions: a credit note rendered as an
ordinary invoice, and the invoice it reversed carried no sign that it had
been cancelled. Only the Vue UI said anything.
The banner lives in one partial, resources/views/app/pdf/partials/
credit-note-banner.blade.php, rather than being copied into the three stock
templates. It reads the shared $invoice, renders a red CREDIT NOTE box with
a reference to the original when the document is a credit note, an amber
CANCELLED box naming the credit note when the document has been reversed,
and nothing at all otherwise -- pdf:compare reports byte-identical ink
boxes for all seven stock templates, so regular documents are untouched.
Every rule is inline on the elements. A partial included in the body cannot
add anything to <head>, and inline styles are the one thing dompdf and
Chromium honour identically; line-height is stated explicitly for the same
reason. The box is a plain block with clear: both, so it cannot disturb the
float layouts the templates are built on.
Custom templates published into the pdf_templates namespace keep rendering
exactly as they do today and opt in by including the partial.
Alongside it, the cheap label swaps the same document type needs: the
<title>, invoice1's and invoice3's number/date labels, invoice2's header
heading, and the PDF's own Title/Subject metadata now say Credit Note, and
the due-date row is dropped from a credit note, which has none.
* feat(invoices): give credit notes the normal lifecycle and guard the unsafe paths
A credit note had a parallel universe of its own: a dedicated send endpoint,
its own request class, its own two gates and policy methods, all of which
duplicated the invoice send path verbatim except for which mailable was
constructed. It was also born COMPLETED, which is the one status that hides
every Send affordance in the UI, so the endpoint that existed for it could
not be reached from the app at all.
Both halves are now the invoice's. InvoiceService::send() picks the mailable
by document type and sendPreview() picks the matching template, so a credit
note goes out through POST /invoices/{invoice}/send under the existing
'send invoice' authorization. The credit-note send route, controller action,
service method, SendCreditNoteRequest, and the 'send credit note' /
'view credit note' gates are gone; CreditNotePolicy keeps only create(),
which is the one ability that is genuinely different (it is gated on the
*source* invoice's company). SendCreditNoteMail and its Blade template stay
exactly as they were.
The credit note is now created DRAFT with due_date null, still settled
(paid_status PAID, both due amounts 0). DRAFT gives it the ordinary
create-review-send lifecycle: the Send button appears on the detail page and
send() promotes it to SENT. Nothing is owed on it in any status, so Record
Payment stays hidden either way.
The guards are the other half. A credit note is now immutable (InvoicePolicy
::update returns false: saving it back through the invoice form would
recompute its totals positive), cannot be cloned or converted to an estimate
(both copy the amounts unnegated), and can never be minted through the create
endpoint -- getInvoicePayload() hard-sets type and related_invoice_id, which
were previously forwarded straight from the request into Invoice::create.
Reversing an invoice that already took a payment, or one that was never
issued at all, is refused with a 422 alongside the two existing domain rules.
CheckInvoiceStatus only considers real invoices, so a credit note can never
be flagged overdue, and the invoice next-number preview is scoped by type
like every invoice create path already is, so it can never count credit
notes.
Test changes follow the behaviour changes: invoices being credited are
created SENT rather than relying on the factory's DRAFT default, the
"created settled" test now expects DRAFT plus a null due date, the send test
goes through the invoice endpoint and asserts the promotion to SENT, and the
partially-paid delete-restore test mints its credit note through the service
because the API now refuses that invoice. Nine tests cover the new guards.
* fix(invoices): stop credit notes distorting counts, queries and deletes
Three follow-ups to the credit-note feature, all of them cases where a
reversal row was treated as if it were another invoice.
Counts. A credit note is not an issued document, so "invoices: 12" must not
become 13 the moment one is cancelled. The company dashboard, the customer
portal dashboard and its invoice list, and both AI stat tools now count
type = INVOICE only. Every sum is deliberately left alone: the negated total
is exactly what nets a reversed sale back out of the figure, which is the
whole point of storing it that way. The admin index meta count is also left
alone, because that list shows credit notes and the count has to match it.
Queries. InvoiceResource probed creditNotes twice per row -- an exists()
then a fetch -- on a resource that is serialized for every row of a
paginated list, and CreditNoteResource did the same for relatedInvoice.
Both now read the loaded relation and emit nothing when it was not loaded,
and the two places whose UI consumes the field (the index list badge, which
also feeds the detail-page sidebar, and the detail page banner) eager-load
it with the two columns they need.
Deletes. related_invoice_id has no DB foreign key by convention, so both
sides of the cascade have to be written out. Deleting an invoice whose
credit note is not in the same batch is now a validation failure --
RelationNotExist cannot express "unless it is also going", so the rule takes
the whole ids list. And InvoiceService::delete() nulls out any surviving
pointer into the deleted batch, so even a caller that bypasses the request
layer cannot leave a credit note referencing a row that is gone.
Also: creating a credit note from the dropdown swallowed server errors, so a
refused reversal (paid invoice, draft, already credited) looked like it had
worked. It now surfaces the message the way the sibling actions in the app
do.
* fix(i18n): translate credit-note guard errors and complete locale coverage
The create/clone/convert guards throw ValidationException with bare
snake_case keys, and none of them were in ERROR_TRANSLATION_MAP, so the
toast rendered the raw key instead of a sentence.
- map the six guard keys to new errors.* translations
- give clone and convert-to-estimate a catch so their guards surface at
all, and factor the toast into showApiErrorNotification()
- add every credit-note string to fr, it and mk (avoir / nota di credito
/ книжно одобрение) so the branch ships in all five maintained locales
- proofread the German: "durch" instead of "via", reword
errors.credit_note_attached
* refactor(db): consolidate credit-note migrations into one
Nothing in PR #689 has shipped, so the branch's two separate migrations are
squashed into a single 2026_08_02_120000_add_credit_note_support. Installs
upgrading to 3.0.0 apply one migration instead of three, and the columns the
next phases need land in the same file rather than as follow-ups.
Carried over unchanged from the deleted files: the INVOICE default on
invoices.type (it backfills existing rows so they stay inside the type-scoped
serial-number queries), the no-FK unsignedInteger convention for
related_invoice_id per PRs #618 / #683, the SQLite requirement to dropIndex
before dropColumn in down(), and the company_settings backfill of
credit_note_number_format for installs predating independent credit-note
numbering.
New in this migration, for partial credit notes:
- invoices.credit_reason (nullable text)
- invoice_items.source_invoice_item_id (nullable unsignedInteger + index),
recording which line of the original invoice a credit-note line credits
- taxes.base_amount widened to a signed bigInteger
The base_amount change fixes a pre-existing bug, not just a credit-note need.
taxes.amount was made signed in 2024_02_08_181804_taxes_amount_as_signed but
base_amount, added as unsignedBigInteger in 2021_07_16_075100, was left behind,
so any negative tax row on a company with an exchange rate is rejected outright
under strict-mode MySQL.
CreditNoteSchemaTest covers the new columns and their nullability, the INVOICE
default, a Tax row with a negative base_amount, a negative-price invoice item
linked to its source line, and the seeded credit_note_number_format setting.
Round-tripped up and down on both SQLite and MySQL 8.0.
* feat(credit-notes): add the pure telescoping credit-note calculator
CreditNoteAmounts computes partial and full credit notes from a plain-array
snapshot of the original invoice, with no Eloquent involved. Every credit is
the difference between two cumulative credits derived from the original's
stored integers, so crediting a line in chunks sums back to the invoice to the
cent and the order of the chunks does not matter.
Quantities are carried as integer hundredths, money stays in integer minor
units, and rounding is the same half-away-from-zero rule DocumentTotals uses.
Fixed-amount tax rows are pro-rated like percentage ones (otherwise a flat tax
is credited once per chunk and exceeds what was invoiced), and percentage rows
are pro-rated from the stored amount rather than re-derived, so a full credit
reproduces the invoice instead of re-making a rounding decision.
* feat(credit-notes): credit an invoice line by line, not all at once
A credit note is no longer a whole-invoice reversal. CreditNoteService takes
the lines and quantities to credit (an empty list still means everything that
is left) and drives CreditNoteAmounts, so an invoice can be credited in as
many pieces as needed.
The model is telescoping, not incremental: each credit note is the difference
between the cumulative credit at the quantities credited after it and the one
before it, both derived from the original invoice's stored integers and never
from earlier credit notes. Chunks therefore sum back to the invoice to the
cent in every field, in any order, and a fully credited invoice nets to zero.
Because those integers are already exact, the line items are written with
recompute disabled: re-deriving a total from price times quantity, or a base_*
column through the exchange rate, would round a second time and break the
identity by a cent.
How much of each line is already credited is read off the surviving credit
notes through source_invoice_item_id, so deleting one gives its quantities
back with no separate bookkeeping. The invariants live in the service, under a
row lock, because each is a read-then-write on the invoice: nothing may be
credited twice, no line beyond what was invoiced, and no more in total than
the invoice's unpaid balance. The one-credit-note-per-invoice and no-payments
guards in the controller are gone, replaced by those.
Paid status and balance are kept apart. A credit is not a payment: an invoice
credited in part is still UNPAID, just for less, and only the payments decide
whether it reads PARTIALLY_PAID. recalculateBalance() owns that rule and runs
only when a credit note is created or deleted, which is why it does not live
in getInvoiceStatusByAmount(): PaymentService::create() adjusts the invoice
before the Payment row exists, so a payments-derived rule there would read a
stale sum. PaymentService::delete() now derives the status from the payments
that remain rather than from due == total, which is the same answer on an
uncredited invoice and the correct one on a credited one.
A credited invoice is frozen (its item ids anchor the credit-note lines), the
reason for a credit is stored and cannot be written through the invoice form,
and the invoice resource reports how much has been credited, whether that is
partial or full, and how much of each line.
* feat(credit-notes): choose the lines and quantities to credit in the UI
Creating a credit note was a yes/no dialog that reversed the whole
invoice, which is all the API could do. The API now credits any subset
of the lines, so the front end has to ask which ones.
The dropdown action opens a form instead of a confirmation: one row per
invoice line showing what was invoiced, what is already credited and
what is left, with the remaining quantity pre-filled and editable.
Quantities are compared in integer hundredths so the last unit of a
line is never blocked by float drift, and selecting every line at its
full remaining quantity on an untouched invoice is sent as the
no-items full reversal the server already implements.
Only the line subtotals are shown: discounts and taxes are apportioned
by the server's calculator, so re-deriving them here would disagree
with the document that gets written. The note under the table says so.
The "Cancelled" badge and banner now key on credited_status rather than
on the presence of any credit note, so a partly credited invoice reads
as partly credited and keeps its real paid status alongside.
* feat(credit-notes): show partial credits on the document and in every locale
The PDF said one of two things about a credited invoice: nothing, or
"Cancelled". Partial credits made both wrong. An invoice credited by half now
carries a Partially Credited banner naming the amount and the credit notes it
came from, a fully credited one lists every credit note rather than the first,
and the credit note itself prints the reason it was issued.
The totals block was the more dangerous half. It was gated on paid_status, so
an invoice settled by a credit note announced "Amount Paid" for money nobody
sent. It now splits the two: Amount Credited comes off the credit notes, Amount
Paid is whatever the balance dropped by beyond them. For a document with no
credit notes the arithmetic collapses to the old expression and the rendering
is unchanged word for word and position for position, checked against dompdf
output and against pdf:compare across all seven stock templates.
The reason is operator-written free text, so it is echoed escaped rather than
through @lang, which does not escape.
Locale-side, the phase-4 English-only strings are translated into the four
maintained catalogues, and the two guards partial crediting retired (one credit
note per invoice, and no crediting an invoice with payments) are deleted
everywhere so no translated install can still show them. A test walks all five
catalogues and fails on either a missing key or a surviving one.
The dashboard's recent-due list eager-loads the credit notes so its rows can
tell a credited invoice from a paid one.
* fix(payments): stop an overpayment from silently vanishing
A payment carried no maximum, so any amount larger than the invoice's
outstanding balance was accepted. PaymentService then handed it to
Invoice::subtractInvoicePayment(), which drove the balance negative, and
Invoice::getInvoiceStatusByAmount() returns an empty array for a negative
amount, so the status change was never applied. The money was recorded
against an invoice whose balance and paid status no longer described it.
The bug predates credit notes, but partial credit notes shrink an
invoice's balance without shrinking its total, which makes it easy to
walk into.
PaymentRequest now caps the amount at the invoice's due amount. On an
edit of a payment that already belongs to that same invoice its own
amount returns to the pool, matching what PaymentService::update() does;
a payment with no invoice stays uncapped. The message string is the
translation key, as elsewhere in the app, and is mapped and localized in
all five shipped locales.
The 'update payment' fixture stacked two randomly sized payments on a
randomly sized invoice, which the cap turns into an intermittent failure,
so its amounts are pinned.
* fix(payments): show the rejected-save message on the payment form
The create-payment view swallowed every API failure in a bare catch, so a
validation rejection, including the new remaining-balance cap, looked like
a save that silently stalled. The catch now routes the error through the
shared toast path the other forms use.
* fix(dashboard): stop the recent-due list crashing on partial credits
The recent-due invoices are serialized as raw models, so every loaded
relation runs the full $appends set. The column-limited creditNotes eager
load left its children without company_id, the company date-format lookup
returned null, and formattedCreatedAt took the endpoint down with a 500.
The relation is not needed there at all: credited_status is a resource
field the raw payload never carried, and a fully credited invoice has no
due amount so it never appears in this list. Drop the eager load and pin
the scenario (a partially credited invoice among the recent due) with a
test that fails 500 on the old code.
---------
Co-authored-by: Linus <lkurz@posteo.de>
Co-authored-by: Darko Gjorgjijoski <dg@darkog.com>
|
||
|
|
fda75e9af7 |
fix(pdf): make dompdf honour declared line-heights (#736)
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
|
||
|
|
bd9602b130 |
fix(pdf): stop an unresolvable template taking the PDF route down (#735)
* fix(pdf): stop an unresolvable template taking the PDF route down RealisticDemoSeeder::seedEstimate() never set template_name, while seedInvoice() has always set invoice1. Every demo estimate therefore had '', so findFormattedTemplate() returned null and EstimateService did $template['custom'] on it -- a 500 on the estimate PDF route, on either driver, since the exception is thrown before a driver is reached. That is the "Unable to load document preview" people were seeing. The seeder now sets estimate1, but seeding was only how this surfaced. The stored name is validated when a document is saved through the UI and nowhere else: seeders, imports, recurring-invoice copies and rows predating that validation all bypass it, and a template can also be deleted from disk after the fact. A name that cannot be resolved should fall back to the default design, not take the route down. PdfTemplateUtils::resolveView() -- already the resolver for payment receipts and reports -- gains an optional fallback and tries each candidate as custom then built-in. Both document services collapse to a single call and can no longer index null. The fallback logs a warning, so a bad name stays visible rather than being silently swapped. Also casts two nulls in GeneratesPdfTrait: an address line or custom field that was never filled in reaches htmlspecialchars() and strtr() as null, which every PDF render was emitting a deprecation for on PHP 8.4 and would be an error on 9. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF * feat(pdf): a command that measures the two drivers against each other "The PDF looks different" has been diagnosed by eye every time, because nothing compares the renderers. Asserting on PDF bytes is useless and rendering through Gotenberg needs a live service, so the suite has never covered it. pdf:compare renders each stock template through both drivers and reports the page box, page count and the bounding box of the text on page one, then flags any template whose ink lands more than --tolerance points apart. It goes through the real document services, so it exercises the same shared view data and template resolution a request would. Two things it has to get right to be honest: Comparing designs means persisting the template choice -- InvoiceService reads it back with Invoice::find($id)->template_name, so assigning in memory silently compares the same design every row. The run happens inside a transaction that is always rolled back. Page numbers are turned off for the duration. They are a Chromium capability with no dompdf equivalent, so leaving them on puts ink at the foot of every Gotenberg page and drowns out every difference worth seeing -- which is exactly what the first run of this command did. Word positions come from poppler's pdftotext, which is on most dev machines but not in the app container; without it the command still compares page geometry and says what it could not check. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF * fix(pdf): let the two renderers agree on the items table, and drop the shim Two parts: the stock templates stop doing their own page margins, and the items table stops relying on a property that does not apply to it. Page margins. The templates carried their own via `html { margin-top: 50px }`, which predates page setup owning them. dompdf largely collapses that margin; Chromium honours it and adds it to the page box, so the same template came out 38px from the top on one renderer and 77px on the other. The html rule is gone and body is reset instead, which is what makes the page box agree. Headers that were positioned absolutely at a negative offset -- only possible because of that margin -- are back in flow. The items table. Every stock template sets `table { border-collapse: collapse }`, and CSS says padding does not apply to a table in that mode. dompdf applies it anyway; Chromium follows the spec and drops it, so the table's `padding: 0 30px` inset the content on one renderer and not the other. Measured in isolation: with border-collapse, content starts at x=24.0 on dompdf and x=1.5 on Chromium -- the full 30px. All of the table's spacing moves to .items-table-wrapper, a plain block both engines treat the same, using padding so nothing collapses through it either. Measured across the seven document templates, that closes the horizontal gap outright: xMin was 57 on dompdf against 37 on Chromium for five of them, and is now within 3pt on all seven. GotenbergStockTemplateCompatibility is removed. Its premise was that dompdf inflates declared line heights by 1.5x, and that does not hold: rendering the same text at 12px, 18px, 36px and unitless 1.0/1.5 through both engines gives line spacing within 0.5pt every time. It also applied its multiplier to the reports, where line-height 21px pairs with font sizes of 14, 16 and 20px -- so .report-footer-value at a 1.05 ratio was being blown out to 31.5px, half again taller than dompdf renders it. A residual vertical difference remains and is localised, not guessed at: it accumulates only in the address blocks, which are <br>-joined text emitted by getFormattedString() with an <h3> in front. Reduced to that construct alone, Chromium steps 11.25pt per line -- exactly the declared line-height: 15px -- while dompdf steps 14.4pt. That needs deciding on its own terms rather than a global multiplier, so it is left visible and measurable via pdf:compare. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF * fix(pdf): restore the stock template design Two regressions from the page-setup work, both visible on the page. The coloured header band stopped bleeding to the paper edges. invoice2 and estimate2 are built around a full-width band, and it now sits in normal flow at the top of body, so it only reaches the edge when the page margin is nothing. #728 defaulted margins to 1.2cm on the reasoning that it matched dompdf's built-in default and so kept existing output unchanged. That was the wrong reference: the templates are drawn for a zero margin and carry their own 30px insets, and Gotenberg rendered them at zero before #728, which is the intended look. Margins now default to nothing. Setting one still works and is honoured by both drivers, at the cost of the band no longer reaching the edge. A bare `0` is valid CSS and the only length needing no unit, so CssLength and PdfPageSetup accept it -- without that the new default would have thrown on every render. The totals block was pushed in from the items table's right edge. Fixing the border-collapse padding problem moved the table's 30px inset onto a wrapper that contains the whole partial, so it stacked on the insets the hr (25px) and the totals container (25px) already had. Those two were always honoured by both renderers; only the table's own padding was not. The inset now lives on a div wrapping just the table, and the wrapper keeps vertical spacing only, which restores the original 30px/25px relationship rather than inventing a new one. Also drops the negative margin-bottom that pulled the addresses up into the band and hid "Bill to,", and removes a stray `bottom: 0px` on invoice1's .header-bottom-divider that combined with `top: 90px` to stretch the rule down the page. Checked by rendering, not only by measurement: invoice2 and invoice1 on both drivers now match the intended design. pdf:compare puts the two renderers within a few points horizontally on all seven documents, xMin 21-22 and xMax 564-575. The remaining vertical difference is the address-block line spacing documented earlier and is unchanged by this. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF * feat(demo): make the demo data actually demo the product The demo company had no address row, and Invoice::getCompanyAddress() returns false outright in that case, so every seeded document rendered with an empty company block -- the name only appeared because the header falls back to it when there is no logo. Several headline features had no demo data at all: zero tax types, zero notes, zero recurring invoices, zero custom fields. DemoSeeder, which the test suite and reset:app both run, now creates Acme Inc with a postal address, tax ids and a country -- the fields the default address format actually renders. The address is created through the relation, as CompaniesController does, so company_id is set and type/user_id/customer_id stay null: Company::address() is an unscoped hasOne, so anything else carrying that company_id would be picked up as the company's own. It also stops trusting currency id 1. Migration 2025_08_18 inserts Algerian Dinar via firstOrCreate() before any seeder runs, so on a fresh migrate+seed the demo priced everything in "DA". RealisticDemoSeeder already worked around this for itself; resolving USD by code fixes it at source for reset:app and the tests too. RealisticDemoSeeder gains a logo, two tax types, a notes library, custom fields and an active recurring invoice. Notes are seeded twice over on purpose: the library and a document's notes column are unrelated in this application -- there is no foreign key, and is_default only drives a badge in the settings list, so nothing pre-fills a document with one. Tax is applied at document level to most but not all documents, so the demo has a zero-rated example in it. The arithmetic is the caller's: the service layer trusts whatever amount it is handed rather than recomputing it, so tax is rounded once off the subtotal and carried through total, due_amount and every base_* twin -- miss due_amount and a paid invoice renders as part-paid. Custom fields are on Customer, the only model_type with a create/edit UI end to end. The PDF renders only model_type 'Item', which would add a column to the items table and disturb a layout that was just squared up across both drivers. The logo is a generated Acme mark rather than one of InvoiceShelf's own, which would read as InvoiceShelf billing the customer. Also documents both seeders in AGENTS.md. RealisticDemoSeeder was referenced nowhere outside database/seeders/, which is a poor place to keep the thing that makes the app look real. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF * fix(pdf): tighten the spacing the old absolute header left behind invoice2 and estimate2 carried three stacked top offsets -- content-wrapper's 60px margin plus address-container's 18px margin and 20px padding -- 98px of dead white between the coloured band and the first line of content. They existed because the band used to be position: absolute and out of flow, so everything below had to be pushed clear of where it visually sat. The band takes its own height now, so the compensation is just a gap. Collapsed to a single 32px. Only those two templates had it, which is the tell: they are exactly the two whose headers were absolutely positioned. Also pins the margins on the <h3> the address formats emit. Left to the user-agent default it pushed the company column out of line with the Bill to / Ship to columns beside it, so the three column headings started at three different heights. They line up now. That h3 is also where the two renderers were measured drifting apart, and pinning it narrows invoice2 from 85.8pt to 72.0pt and estimate2 from 84.4 to 76.8. The templates without a coloured band barely move, which places the rest of the difference in the per-line spacing of the <br>-joined address lines rather than in the heading -- consistent with the isolated measurement earlier (dompdf 14.4pt per line against Chromium's 11.25pt) and still open. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF |
||
|
|
35a248b48b |
fix(pdf): send the installed fonts to Gotenberg (#733)
FontService writes absolute host paths into the @font-face rules:
src: url("/var/www/html/storage/fonts/NotoSansSC-Regular.ttf")
dompdf shares that filesystem so they resolve. Chromium runs inside the Gotenberg
container and cannot see any of it, so every installed font package silently
failed to load and documents fell back to whatever fonts that image happens to
ship. The docs recommend Gotenberg specifically for mixed-script documents, which
made this exactly the wrong way round -- it worked only by accident, because
Chromium's own font set covers more than dompdf's single-font behaviour.
The font files now travel with the document as Gotenberg assets, and the rules
are rewritten to name them. Gotenberg unpacks assets next to index.html, so a
bare filename resolves.
Only fonts the markup actually references are sent. A CJK package is several
megabytes and has no business riding along on a request that never mentions it.
Confirmed against a stock gotenberg:8, reading the fonts back out of the
rendered PDF:
before AAAAAA+LiberationSerif (Gotenberg's fallback)
after AAAAAA+NotoSans-Regular (the app's own font)
Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
|
||
|
|
773670c18f |
feat(pdf): archival PDF/A output and document properties (#732)
Generated files carried no document properties at all, so an archive of them showed a column of blank titles and no author. Title, Subject, Author and Creator are now written from the document number and company, on both drivers: dompdf via addInfo(), Gotenberg via metadata(). dompdf needed more than the API call. It reads Title from the <title> element during render(), which happens after addInfo(), so metadata set through the API alone was silently overwritten by whatever the template put there and the two drivers disagreed about what the file was called. The title is written into the markup as well, escaped. Also adds an archival format setting for Gotenberg: off, PDF/A-1b, -2b or -3b. PDF/A-3 is what the EU e-invoicing formats expect. Verified against a stock gotenberg:8 -- LibreOffice inside the image does the conversion and the output carries the right pdfaid:part in its XMP -- so no extra components are needed. A fixed list rather than free text, because the SDK forwards whatever it is given and an unsupported value would surface only as an HTTP error from the service at render time. Empty is a real choice meaning an ordinary PDF, so it overrides an env default rather than falling through it. Gotenberg only: dompdf cannot produce PDF/A. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF |
||
|
|
05e8acc3b1 |
feat(pdf): let payment receipts and reports be overridden too (#731)
Only invoices and estimates could be customised. Payment receipts and all five
reports were hardcoded to app.pdf.*, so changing them meant editing files inside
the image -- and losing the edit on the next upgrade.
Those documents have no template picker and no design to choose between, so
overriding one is not a selection: it is a same-named file in
storage/app/templates/pdf/{type}/ winning over the built-in. PdfTemplateUtils::
resolveView() is that rule, and it needs no setting, no column and no UI.
resolveView asks View::exists rather than checking the storage disk. The disk and
the view namespace are registered separately and could disagree about where
custom templates live; asking the thing that will actually render removes that
possibility.
make:template covers the new types. Their names are not free, since an override
replaces one specific document, so it validates against the real list -- 'payment'
for payments, and the five report names -- and reports what is available when the
name is wrong. Neither type gets a preview image written, having no picker to
show one in.
The payment preview route also went through the built-in view directly rather
than the service, so ?preview ignored an override and rendered with none of the
shared data. It goes through the service now, like invoices and estimates.
Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
|
||
|
|
8ab860a1ae |
fix(pdf): make custom templates behave the way the docs describe (#730)
Custom templates are a real feature with no test coverage at all, and several
rough edges that only show up once someone actually uses one.
make:template validated nothing. --type was checked only by the interactive
prompt, so `--type=payment` skipped the prompt and died on an uncaught
FileNotFoundException looking for payment1.blade.php: a stack trace instead of a
message. The name was not checked either, so `../escaped` wrote outside the
templates directory. Both are refused now.
Every custom template of a type shared one partials/table.blade.php. It was
written on first use and reused thereafter, so editing the items table for one
custom template silently changed it for all of them -- a file that looks
per-template and behaves globally. Each template now gets its own copy under
partials/{name}/, and its include is rewritten to match. Existing templates keep
including the old shared path, which still resolves.
A custom template sharing a built-in's name appeared twice in the picker with
the same label, and findFormattedTemplate() array_reverses and takes the first
match, so the custom one silently won. The listing is keyed by name now, so it
appears once, as the entry that will actually be used.
A custom template with no same-named .png rendered <img src=""> in the picker: a
blank tile, no error, no hint anything was missing. It falls back to the preview
of the template it was cloned from.
template_name was validated as `required` and nothing else, so any string was
accepted and stored. findFormattedTemplate() returns null for an unknown name,
the null reads as "not custom", and rendering falls through to
app.pdf.{type}.{name} -- a raw "view not found" 500 at PDF time, long after the
save that caused it. New PdfTemplateExists rule, scoped per document type.
make:template also copies a _header/_footer companion when the source template
has one, so a scaffolded template keeps the repeating page furniture.
Both getEstimateTemplateName/getInvoiceTemplateName asked for the template list
with the default image format, base64-encoding a preview of every template just
to read the names back.
Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
|
||
|
|
713e0bc2e8 |
feat(pdf): repeating page headers and footers, and page numbers (#729)
Takes over #690 by csoscd. The companion-view idea is theirs; this reworks it onto the shared page setup and fills in the gaps that stopped it landing. A `{template}_header` or `{template}_footer` view next to a template is rendered alongside it and repeated by Chromium on every page. The suffix resolves through the pdf_templates:: namespace too, so custom templates get it with no extra wiring. Two things had to change for that to be useful. Companion views are now hidden from the template picker. getFormattedTemplates() lists every .blade.php it finds, so an invoice1_footer would otherwise appear as a separately selectable template with no preview image -- the feature would have introduced that the moment anyone used it. And it does something out of the box. #690 shipped no companion views, so both of its margin settings were visible no-ops until someone hand-wrote a Blade file. Instead there is a pdf_page_numbers setting, off by default, that supplies a footer when a template has none. A template's own companion still wins, so turning page numbers on cannot overwrite a designed footer. The setting sits under Gotenberg because only Chromium can repeat a footer; dompdf has no equivalent. Its value is still carried by the dompdf form so saving from there cannot clear the choice -- the field is absent from that payload, and the controller only writes it when present. Margins come from the page setup rather than #690's separate header_margin and footer_margin. Chromium draws header and footer inside the page margin, so the existing margins are the space they occupy; two more settings for the same distance would have been a second way to say the same thing. Verified against a live gotenberg:8 on a two-page document: off produces no footer, on produces "1/2" and "2/2" on the respective pages, and a companion footer replaces both. #690's own test is not carried over. It asserted nothing: a bare View::shouldReceive('exists') is an allowance rather than an expectation, andReturn(false) never entered the companion branch, and the call sat inside try { } catch (Throwable) { }, so it passed with the feature deleted. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF |
||
|
|
a54a5ee007 |
feat(pdf): one page setup, honoured by both drivers (#728)
Paper size was a Gotenberg-only setting stored as a single "210mm 297mm"
string. dompdf had no page settings at all: size was pinned to config/dompdf.php's
fixed 'a4', its top-level `orientation` key was read by nothing (the installed
barryvdh v3 builds options only from `defines`), and margins were whatever
dompdf's own stylesheet said. So the two drivers disagreed about margins by
default -- dompdf 1.2cm, Gotenberg hardcoded to zero -- and selecting dompdf
silently discarded the paper size.
Replaces gotenberg_papersize with pdf_paper_width / pdf_paper_height /
pdf_orientation / pdf_margin_{top,right,bottom,left}, saved and applied for
either driver. Width and height are separate CSS lengths because that is the
only lossless shared notation: Gotenberg has no named sizes, and dompdf's named
table cannot express everything Gotenberg accepts. Named presets (A3/A4/A5/
Letter/Legal) are a convenience in the UI that resolve to a pair of lengths.
PdfPageSetup resolves it once and translates: a points array plus an orientation
argument for dompdf, CSS lengths plus landscape() for Gotenberg. Both are handed
the portrait pair, since each swaps the axes itself. Gotenberg's margins() takes
top, bottom, left, right, which is not the CSS order.
dompdf exposes no margin API, so DompdfDriver injects an @page rule -- at the
top of <head>, so a template declaring its own still wins. Doing it in the driver
rather than a Blade partial means custom templates get it without including
anything.
Margins default to 1.2cm, dompdf's existing default, so Gotenberg starts
matching it rather than rendering edge-to-edge. Verified against a live
gotenberg:8: A4 portrait, A4 landscape and Letter at zero margins all come out
with the same page box and the same ink offsets on both drivers.
A malformed length now throws rather than being ignored. Blank still falls back,
but a value that is set and wrong is an operator mistake, and the drivers would
otherwise fail differently: dompdf throws converting to points, Gotenberg would
forward the string and render at some other size.
Also here:
- Migration splits an existing gotenberg_papersize into the new pair. It earns
its place because that key ships in 2.x, not just a 3.x alpha, so a stable
install that chose Letter would otherwise come back up on A4. Drops
gotenberg_margins, which 2.x also stores and neither driver ever read.
- Removes EnvironmentManager::savePDFVariables/getPDFConfiguration, which had no
caller anywhere, and the unused EnvironmentManager injection in the controller.
- config/dompdf.php: drops the dead `orientation` key and defaults enable_remote
to false, matching .env.example, which sets it explicitly and explains why.
Installs predating that line were falling back to true.
- Retires the settings.pdf.footer_text and pdf_layout strings, which no component
referenced.
Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
|
||
|
|
6cb754da60 |
fix(pdf): give both drivers one contract, and fix what that was hiding (#727)
PdfDriver and ResponseStream existed but nothing implemented them. The factory returned the vendor dompdf wrapper for one driver and a bespoke class for the other, so the two were never held to the same shape. Three things had slipped through that gap. Report PDFs answered 403 for everyone. The five report routes carry no company header, so ScopeBouncer is not in their middleware stack and the ability scope was never set; 'view-financial-reports' is stored scoped to a company, so the check could not pass. They now scope to the company named in the URL. The policy still checks membership, so this grants nothing new. Also firstOrFail() on the hash lookup, so an unknown company is a 404 rather than a 500 on a null. Report downloads were fatal on Gotenberg. GotenbergPdfResponse had no download(), and the report controllers are its only callers. Added, alongside stream() and output(), with the whole set now on the interface. Streamed documents carried an HTTP preamble. GeneratesPdfTrait wrapped $pdf->stream() -- already a Response -- in another response()->make(), which stringified it and prepended "HTTP/1.0 200 OK" plus headers to the file. Readers scan the first kilobyte for %PDF so nobody noticed, but the bytes were malformed. Passing ->output() fixes it, and the render test now asserts the position. Two driver-parity settings, both checked against a real gotenberg:8 rather than inferred: emulateScreenMediaType(), because Chromium defaults to print media while config/dompdf.php renders as screen, so a @media print rule applied on one driver and not the other; and printBackground(), which turns out to affect only the root background, since Chromium paints element backgrounds either way. No stock template sets a body background, so that one changes nothing today and is here to keep custom templates consistent across drivers. Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF |
||
|
|
fdd958c1e5 |
fix(setup): support mariadb in the installation wizard (#704)
Fixes InvoiceShelf/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'". |
||
|
|
f8cfb6cd33 |
fix: allow Gotenberg to reach private/Docker-internal hosts (#691)
* fix: allow Gotenberg to reach private/Docker-internal hosts (Issue #688) The SSRF guard introduced in #664/#671 correctly blocks arbitrary private URLs, but also prevents legitimate use-cases where Gotenberg runs alongside InvoiceShelf in a Docker Compose network (e.g. the default http://pdf:3000 service name resolves to a private IP). Add a `gotenberg_allow_private_host` setting (env: GOTENBERG_ALLOW_PRIVATE_HOST, default false) that: - skips PrivateNetworkGuard in GotenbergPdfDriver - skips PublicHttpUrl validation in PDFConfigurationRequest - exposes a clearly-warned toggle in the admin PDF settings UI - is persisted to the settings table and loaded via AppConfigProvider A disabled guard is safe for controlled private networks (Docker Compose, LAN); it must never be enabled for untrusted hosts. The UI surfaces a prominent warning to communicate this constraint. Closes #688 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(gotenberg): scope the private-host exemption to a declared host Reshapes the escape hatch from a boolean admin setting into an environment-declared host allowlist. The driver streams the upstream response body back as the PDF, so a mis-set Gotenberg host is full-response SSRF — pointed at a link-local metadata endpoint it returns cloud credentials. A blanket "allow private" switch left that reachable: gotenberg_host stays editable from the admin UI, so any install that enabled the switch to run a sidecar could have the host repointed at an internal service. The population the flag existed to serve was exactly the population it failed to protect. GOTENBERG_ALLOWED_PRIVATE_HOST now names the single host that may skip the guard. Only that exact value is exempt; every other private target stays blocked. GotenbergHostPolicy owns the comparison so the save-time rule and the runtime driver guard cannot drift, and normalises case, trailing slash and surrounding whitespace on both sides. Being env-only also drops the settings-table key, the AppConfigProvider branch and the whole admin UI surface — the toggle there could not be switched on in any case, since BaseSwitchSection has no slot and was passed no v-model, so the child BaseSwitch was discarded and the value never changed from false. Restores the gotenberg_margins validation rule, which the previous revision replaced rather than added alongside. Tests cover both directions, including that declaring one private host does not exempt another; sabotaging the policy to always exempt fails 16 of the 22. Co-authored-by: csoscd <csoscd@users.noreply.github.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Darko Gjorgjijoski <dg@darkog.com> Co-authored-by: csoscd <csoscd@users.noreply.github.com> |
||
|
|
01da03624d |
fix(seeder): assign unique_hash to seeded documents (#698)
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. |
||
|
|
403a4d6722 |
test(pdf): cover every stock PDF template, and silence the PHP 8.5 PDO deprecation (#696)
* 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. |
||
|
|
9e496102d4 |
feat(updater): disable the in-app updater in containerized installs
The Docker image already injects CONTAINERIZED=true; consume it via config('invoiceshelf.containerized'), expose it on /app/version, block the update endpoints + console command, and show a 'docker compose pull' panel instead of the updater. Adds missing i18n keys.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
d3202b8b2a |
fix(members): scope member view & update to the acting company
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> |
||
|
|
8d929ec09d |
feat(api): generate OpenAPI spec with Scramble for api-docs.invoiceshelf.com (#685)
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> |
||
|
|
217deb0bf9 |
feat: localize country names in PDFs via symfony/intl (#681)
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> |
||
|
|
4ab62b98c6 |
ci: speed up PHP test jobs (disable Xdebug, drop frontend build, run parallel) (#657)
* 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. |
||
|
|
84524ce247 |
fix(security): recompute document totals server-side (GHSA-8c69) (#672)
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. |
||
|
|
99ea898e88 |
fix(security): block SSRF via the Gotenberg host setting (GHSA-mfxg) (#671)
v3 port. The Gotenberg PDF driver was missed when the SSRF guards were added to the AI, exchange-rate and file-disk drivers: gotenberg_host was validated only with 'url', and the driver POSTs the rendered HTML to it. Reuses the existing infrastructure (consistency with the other drivers): - Wires App\Rules\PublicHttpUrl into the gotenberg_host validation rule. - Adds PrivateNetworkGuard::assertAllowed() in GotenbergPdfDriver before the outbound call (covers env/seed/stale config + DNS rebinding). Adds a unit test asserting the gotenberg_host rule rejects private/loopback/ link-local addresses and allows a public one. |
||
|
|
ca6dd57bf9 |
fix(security): block ORDER BY SQL injection via orderByField (GHSA-cp8p) (#670)
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. |
||
|
|
e56b6b4fe5 |
fix(security): harden public EmailLog token endpoints (GHSA-73q7) (#669)
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. |
||
|
|
c6a00df120 |
fix(security): enforce company scope on notes, doc-convert, and member bulk-delete (#668)
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. |
||
|
|
ac2a8ca939 |
fix(security): gate AI tools by user ability and block admin-URL SSRF
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 |
||
|
|
3df2a01832 |
feat(ai): add customer/item/expense ranking tools to the chat assistant
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. |
||
|
|
5b53a7c283 |
refactor(ai): move chat + text-gen prompts into resources/ai/prompts/
Extracts the two inline LLM prompts (AiAssistantService's chat system
prompt and AiTextGenerationService's writing preamble) out of PHP
heredocs and into plain-markdown template files under resources/ai/
prompts/. Each file can now be edited without opening a service
class, without wrestling with PHP string interpolation, and with
proper markdown syntax highlighting in editors.
A tiny PromptLoader helper at app/Support/Ai/PromptLoader.php reads
the file and does {{placeholder}} substitution via strtr() — no
Blade, because Blade's {{ $var }} HTML-escapes ampersands and quotes,
which is wrong for LLM prompts (a company called "Smith & Co" would
be sent as "Smith & Co"). Missing templates throw RuntimeException
so they fail loud during development.
Pure refactor: no prompt wording changes. Existing AI feature tests
(AiChatFlowTest, AiGenerationTest) pass unchanged — they assert on
message structure via ScriptedAiDriver, not on prompt content. Three
new unit tests in PromptLoaderTest lock the helper's contract:
placeholder substitution, no-var loading, missing-file error.
|
||
|
|
b761ea9931 |
feat(ai): Phase 3 — text generation popup on WYSIWYG editors
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).
|
||
|
|
e861fc1fc1 |
feat(ai): Phase 2 — chat assistant with tool-calling
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.
|
||
|
|
c7fab5d52f |
feat(ai): Phase 1 — provider configuration, installer step, admin + company settings
Foundation for the AI chatbot + text generation feature. Phase 1 is infrastructure only: driver plumbing, configuration storage with encrypted API keys, global vs per-company resolution, admin + company UI pages, and an optional installer wizard step. The chat assistant and text-generation WYSIWYG integration come in later phases.
**Driver plumbing (app/Support/Ai/)** — AiDriver abstract, AiDriverFactory, AiException, AiChatResponse DTO, OpenRouterDriver concrete implementation. OpenRouter is the OpenAI-compatible aggregator that unlocks hundreds of models behind one API key and one request shape — ideal as the default v1 driver. Drivers are extensible the same way exchange rate drivers are: the module Registry's generic registerDriver('ai', ...) machinery plus a typed Registry::registerAiDriver() convenience wrapper (shipped in the upstream invoiceshelf/modules package in a paired commit).
**AiConfigurationService** — mirrors MailConfigurationService shape but with one deliberate deviation: API keys are encrypted at the service layer via Crypt::encryptString before persistence. OpenRouter bearer tokens have much bigger blast radius than SMTP passwords. Same settings / company_settings tables, same global-vs-per-company pattern, same use_custom_ai_config override toggle. Resolution order: global ai_enabled must be YES, then the company either overrides via use_custom_ai_config=YES (and can opt out with ai_enabled=NO inside the override) or inherits the global config.
**Controllers** — Admin/Settings/AiConfigurationController (global CRUD + driver list + test connection), Company/Settings/CompanyAiConfigurationController (per-company override + test), Setup/AiConfigurationController (installer wizard step, skippable with explicit ai_enabled=NO). API key is always masked as '********' in GET responses — the frontend submits the placeholder back on save and the backend preserves the stored value.
**Installer wizard** — new optional step 7 'AI' between Mail and Account. Default OFF with a Skip button. MailView.vue now routes to installation.ai instead of installation.account; installation.ai then routes to installation.account. Step order comment updated in routes.ts.
**Admin + Company settings pages** — AdminAiConfigView (no toggle, always global) and AiConfigView (with use_custom_ai_config BaseSwitchSection that auto-saves OFF). Both share AiConfigurationForm which renders the driver selector, API key input with show/hide, driver-specific config_fields (base_url for OpenRouter), and per-role enable toggles with free-text model inputs backed by a datalist of suggested models from driver metadata.
**Bootstrap endpoint** — adds an ai block to the response: { enabled, chat_enabled, text_generation_enabled }. All three are booleans resolved through AiConfigurationService::resolveForCompany(). Never leaks the API key. Frontend feature flags read from bootstrapData.ai to decide whether to show Phase 2/3 UI.
**Bouncer ability** 'manage ai config' added to SettingsPolicy, gated on isSuperAdmin() (same pattern as manage email config, manage pdf config).
**Tests** (22 new) — Unit: AiDriverFactory resolves built-in + Registry-contributed drivers, rejects unknown, merges availableDrivers. AiConfigurationService: encryption round-trip, resolution order (3 cases: global off, inherit global, override with company key, override with opt-out), makeDriver null/instance cases, listDrivers metadata. Feature: admin save + read with api key masking, preserve-on-placeholder behavior, company toggle ON/OFF semantics, bootstrap ai flags reflect resolution, company opt-out path.
372 tests pass (was 350, +22). Pint clean. npm run build clean. Phase 2 (chat assistant + tool calling) and Phase 3 (WYSIWYG text generation popup) are separate follow-up commits — this one is the foundation only.
|
||
|
|
47907f9bf3 |
refactor(support): flatten Integrations umbrella and rename Formatters to Formatting
Two plural outliers were the only directories in Services/ and Support/ that didn't follow the singular naming convention we normalized in commit
|
||
|
|
f657b53215 |
refactor(services): split driver infrastructure out of Services into Support
Services/Integrations/ExchangeRate/ and Services/Pdf/ were both mostly Support-shaped: interfaces, abstract classes, static factories, concrete adapter drivers, DTOs, and exceptions — infrastructure that doesn't carry business logic. They only each had one real DI-injected service mixed in. This commit applies the same Services=DI-business-logic / Support=stateless-plumbing rule we've been using throughout the reorg: **Moved to Support/Integrations/ExchangeRate/** (7 files): ExchangeRateDriver (abstract), ExchangeRateDriverFactory (static), ExchangeRateException, and the four concrete drivers (CurrencyConverter, CurrencyFreak, CurrencyLayer, OpenExchangeRate). These are HTTP adapters over third-party currency APIs — same shape as the Hashids library wrapper classes already in Support. **Moved to Support/Pdf/** (6 files, merging with existing Pdf utilities): PdfDriver (interface), PdfDriverFactory (static), PdfService (static facade), GotenbergPdfDriver, GotenbergPdfResponse (DTO), ResponseStream (interface). The Support/Pdf/ dir now contains the full PDF rendering subsystem — drivers + sanitizer + template/image utilities. **Promoted to Services/ root** (the real DI services): ExchangeRateProviderService (CRUD for ExchangeRateProvider model) and FontService (font package install/download orchestration). Both are proper DI services — instance methods, model writes, HTTP side effects. Services/Integrations/ and Services/Pdf/ are now empty and deleted. Services/ holds only DI-injected classes; Support/ holds all the plumbing. 17 files renamed (git detects 90-99% similarity), 4 consumer files updated (DriverRegistryProvider, PdfServiceProvider, ExchangeRateProviderController, FontController, GeneratesPdfTrait, test). 350 tests pass, Pint clean. |
||
|
|
947d00a9f1 |
refactor(services): Documents→Document + ExchangeRate→Integrations/ExchangeRate
Two follow-ups to the Services reorg that landed in
|
||
|
|
6d1816bd1b |
refactor: reorganize app/Services and app/Support by domain
The app/Services/ directory had grown into 22 flat files at the root plus 7 uneven subdirectories — finding anything required scrolling through an alphabetical mix of small CRUD services, infrastructure drivers, and install-time utilities. This commit groups services by domain, folds Backup into a new Storage namespace, and moves framework-infrastructure and install-time helpers out of Services and into Support where they belong. New Services layout: Documents/ (Invoice, Estimate, RecurringInvoice, Payment, Expense, Transaction, DocumentItem, SerialNumber, Currency — matches the 'Documents' navigation group); Company/ (Company, Member, Invitation); Mail/ (MailConfiguration, CompanyMailConfig); Storage/ (FileDisk, plus Backup folded in). ExchangeRateProviderService moves next to its drivers in ExchangeRate/; FontService moves into Pdf/ where it belongs. CustomerService, ItemService, CustomFieldService stay at the Services root as standalone single-file domains. Moves to Support/: Hashids/ (library wrapper — not business logic); Setup/ (one-shot install-time utilities — stateless helpers); Pdf/ (ImageUtils, PdfTemplateUtils, plus the existing PdfHtmlSanitizer consolidated into the same subdir). These are all framework infrastructure and stateless utilities — the 'service' label never really fit them. Namespace declarations in 29 moved files updated to match new paths. 62 consumer files (controllers, other services, tests, database factories, seeders, routes, bootstrap/providers.php) have their use statements rewritten via a literal-string replacement script — no regex meant no risk of half-matching. Three Documents services needed an explicit 'use App\Services\Mail\CompanyMailConfigService' added because the same-namespace short reference they relied on no longer resolves after the split. Verified: composer dump-autoload, 350 tests pass (850 assertions), vendor/bin/pint clean, npm run build succeeds. |
||
|
|
31a2a66127 |
refactor(modules): move Modules into Company Settings as Module Configuration
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. |
||
|
|
e44657bf7e |
feat(exchange-rate): make providers extendible via module Registry
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.
|
||
|
|
7885bf9d11 |
feat(menu): priority-sorted menu groups, user-menu items, sidebar appearance toggle
Every main_menu entry moves from numeric group (1/2/3) to string-based group + group_label + priority. Groups now carry their own i18n label and child entries are sorted by an explicit priority field instead of config-array order, so module-contributed menu items can slot into any existing group at any position.
BootstrapController merges module-registered menu items into main_menu (previously they lived in a separate module_menu response key) and introduces a user_menu response key for items modules want to place in the avatar dropdown. The global store follows suit: moduleMenu becomes userMenu, menuGroups is a computed that sorts by priority, and hasActiveModules drops out.
New admin Appearance setting page with a single toggle for whether sidebar group labels render — so instances that prefer a compact sidebar can hide the Documents/Administration/Modules headings without losing the grouping itself. CompanyLayout watches route meta and re-bootstraps when the admin-mode flag flips so the sidebar repaints with the right menu on navigation across the admin boundary.
Test suites updated: module menu merging is asserted against main_menu (name: 'module-{slug}') rather than the old module_menu response; HelloWorldIntegrationTest verifies the schema translation path; CompanyModulesIndexTest covers the display_name attachment.
|
||
|
|
23d1476870 |
refactor(modules): marketplace install flow with checksum validation
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. |
||
|
|
42ce99eeba |
Show common currencies first in dropdowns and default to USD in install wizard
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. |
||
|
|
9174254165 | Refactor install wizard and mail configuration | ||
|
|
93b04a0c2a |
test(modules): integration tests for company surfaces and stub generator
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().
|
||
|
|
1fb5886d06 |
Sanitize PDF address fields against SSRF in getFormattedString chokepoint
Closes the residual surface from the three published SSRF advisories (GHSA-pc5v-8xwc-v9xq, GHSA-38hf-fq8x-q49r, GHSA-q9wx-ggwq-mcgh / CVE-2026-34365 to 34367) that the original 2.2.0 fix only covered for the Notes field. The same blade templates render company/billing/shipping address fields with {!! !!} via Invoice/Estimate/Payment::getCompanyAddress(), getCustomerBillingAddress(), getCustomerShippingAddress() — and those flow through GeneratesPdfTrait::getFormattedString() which did not call PdfHtmlSanitizer.
Customer-controlled fields (name, street, phone, custom-field values) are substituted into address templates via getFieldsArray() without HTML-escaping, so a malicious customer name like "Acme <img src='http://attacker/probe'>" reaches Dompdf as raw HTML through the address path. Today this is blocked only by the secondary defense of dompdf's enable_remote=false; if a self-hoster sets DOMPDF_ENABLE_REMOTE=true for legitimate remote logos, the address surface immediately re-opens.
Move PdfHtmlSanitizer::sanitize() into the chokepoint at GeneratesPdfTrait::getFormattedString() so all four sinks — notes plus the three address fields, on all three models — get the same treatment via a single call site. v3.0's models (Invoice, Estimate, Payment) already had the simpler getNotes() shape (no per-method PdfHtmlSanitizer wrapper), so the trait edit alone is sufficient — no model edits required on this branch. Verified getFormattedString() is only called from PDF code paths (no email body callers, which use strtr() directly).
This is the v3.0 counterpart to master's f387e751. Re-implemented directly on v3.0 instead of cherry-picked because the import-block divergence from the larger v3.0 refactor produced four merge conflicts that were noisier than just porting the chokepoint change manually.
Extends tests/Unit/PdfHtmlSanitizerTest.php with three new cases covering the address-template scenario, iframe/link tag stripping, and on* event handler removal. All 8 tests pass via vendor/bin/pest tests/Unit/PdfHtmlSanitizerTest.php.
|
||
|
|
20085cab5d |
Refactor FileDisk system with per-disk unique names and disk assignments UI
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.
|
||
|
|
39c9179888 |
Support internationalized domain names (IDN) in email validation
Add IdnEmail validation rule that converts IDN domains to Punycode via idn_to_ascii() before validating with FILTER_VALIDATE_EMAIL. Applied to all email fields: customers, members, profiles, admin users, customer portal profiles, and mail configuration. Includes unit tests for standard emails, IDN emails, and invalid inputs. Fixes #388 |
||
|
|
74b4b2df4e | Finalize Typescript restructure |