fix(invoices): invoice PDF draft stamp, page breaks, wrapping, units, multi-line descriptions (#2369)

* fix(invoices): draft stamp out of flow, page-break control, whole-word wrapping, English units, multi-line descriptions

A user reported five things about the invoice PDF after the English
translation shipped:

- The yellow draft banner pushed the whole document down, so a draft
  previewed differently from the final invoice. It is now a small stamp in
  the page's top margin (absolute, fixed), repeated on every page, out of
  the flow.
- Table rows, totals, the payment box and the notice boxes could split
  across a page break, and a section heading could be left alone at the
  bottom of a page. Those blocks now carry wrap={false}; headings and the
  table header carry minPresenceAhead.
- react-pdf hyphenated Swedish words with English patterns ("Septem-ber").
  Descriptions, notes, notices and the footer now wrap whole words.
- "st" printed verbatim on an English invoice. The editor's known units map
  to English labels at render time; user-typed units print as stored.
- Descriptions were single-line inputs, so a user could never choose where
  a line breaks. The editor field is now an auto-growing textarea; the PDF
  and the on-screen views keep the line breaks; the Peppol item name
  collapses them (single-line field).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* fix(invoices): keep long tokens printable and oversize text splittable in the invoice PDF

Skeptic findings on the first commit:

- A word wider than its column was dropped from the page or overprinted the
  quantity column, because the no-hyphenation callback gave react-pdf no
  break point inside it. Words up to 16 characters still wrap whole; longer
  tokens (URLs, e-mail addresses, references) break after separators and
  every 16 characters.
- wrap={false} on a text row or notes taller than a page clipped everything
  past the page edge. Line descriptions and notes are now kept together only
  while a line estimate says they fit; past that they split.
- A multi-line description reached the periodisering voucher text and broke
  the SIE export (one record per line). The accrual builder and the Peppol
  item name share toSingleLine(); the SIE writer collapses line breaks in
  quoted text as a format guard.
- The English no-number draft stamp ended 1.3pt below the top margin.

Tests lay the document out with @react-pdf/layout and assert that no text
node ends past the page edge and that every line's ink stays inside its
column, for 80-line descriptions and notes, a 3000-character description,
and four long tokens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* fix(invoices): count rendered lines and cap the kept-together budget so no font can clip a row

Skeptic cycle 2: with the bundled Source Serif 4 font (13.7pt per line)
and a description of 20 source lines of wide glyphs, the estimate said
"fits" while the chunked token wrapping produced 58 rendered lines; the
non-splittable row then ran off the page. The estimate now counts the
chunks a long token is broken into, and the cap is 12 lines: at 20pt per
line that is under a third of the page for any font a company can pick.

The page-edge test now measures absolute positions (box.top is
parent-relative) and covers the bundled serif case through
prepareInvoiceFont().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* fix(invoices): keep words whole by estimated ink width, not a character cap

Skeptic cycle 2 (regression): the flat 16-character cap gave react-pdf a
break point inside ordinary Swedish compounds (Fastighetsskötse-l,
Företagsförsäkri-ng), which the breaker used whenever it filled the line
better. Words are now kept whole whenever a rough per-glyph width estimate
says they fit the column; only a token wider than the column gets parts,
after separators and where the column is full. The page-fit estimate uses
the same widths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* fix(invoices): estimate word width from real Helvetica metrics

Skeptic cycle 3: the hand-rolled per-glyph estimate over-counted ordinary
lowercase (6.5pt for a 5.56pt glyph), so 24 to 28 character compounds
(Momskompensationsansökan, Mervärdesskattedeklarationen) were still handed
a break point while they fit the column, and under-counted rare glyphs
(æ, œ, Cyrillic) so a token of those could overflow. The estimate now uses
Helvetica advances measured through react-pdf's own metrics
(lib/invoices/pdf-glyph-widths.ts) with a 10% margin for the bundled
fonts, and counts any glyph outside Helvetica at the widest Latin advance.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* docs(invoices): state the bundled-font width margin accurately

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

* fix(invoices): review round: SIE backslash escaping, text-row budget, ROT/RUT box keep-together

- CodeQL: the SIE quoted-text escaper left backslashes alone while backslash
  is the escape character. It now writes a literal backslash as two, and the
  parser unescapes both that and the escaped quote, so text round-trips.
- CodeRabbit: a free-text row renders at full table width but was budgeted
  at the description column, so it could split unnecessarily. It uses the
  full-width budget now.
- CodeRabbit: the ROT/RUT box was unconditionally kept on one page although
  its per-line breakdown carries the (possibly multi-line) descriptions. It
  uses the same keep-together estimate as rows and notes, and its line texts
  wrap whole words.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-07 14:45:38 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent c634cf9ae0
commit 7a30f623ba
18 changed files with 1020 additions and 52 deletions
+4
View File
@@ -1636,5 +1636,9 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-06] Bundled SKV ROT/RUT payout books ONE voucher (one 1513 leg per begäran) and the set is suggested at read time with no hint column: one bank row = one verifikat (match-batch precedent) and a uuid[] hint would need six clear paths and go stale; N vouchers + the 1:N reconciliation split was rejected because its half-failure state has no UI exit, and begäran, not the invoice, is the unit under fakturamodellen.
[2026-09-06] Utlägg via lön settles claims with an idempotent RPC after the salary verifikat is posted (pre-checked before posting), not with a trigger on salary_runs -> booked: a raise inside that trigger after the entries exist would leave a paid run with posted verifikat and a retry would double-post; the RPC path fails to "booked, claims still open, re-runnable".
[2026-09-06] A privately paid supplier invoice is booked through registerExpenseClaim (verifikat + expense_claims row, source_type expense_claim) with the invoice's kontering as custom lines, and a person-paid inbox document goes to the core route with inbox_item_id instead of the extension's convert endpoint: the form's switch, the second entry generator and the convert bypass were three write paths for one fact, so one writer wins over adding a claims insert beside the old generator (the issue's shape) or copying the branch into the convert handler.
[2026-09-07] Draft stamp moved to the page margin (absolute + fixed) instead of the reporter's position:fixed corner badge: the 40pt top margin is the only place that is guaranteed empty on every page, and the stamp must not overlap the header title on the right.
[2026-09-07] Hyphenation disabled per Text node in the invoice template, not via a global Font.registerHyphenationCallback: the global hook would also change line breaking in årsredovisning, payslips and every report PDF; that is a separate decision.
[2026-09-07] Invoice PDF word wrapping keeps a word whole whenever an ink-width estimate says it fits its column (ordinary Swedish compounds of 17 to 25 characters always do); only a wider token (URL, e-mail, reference) gets break points, after separators and where the column is full, with react-pdf's hyphen at the break (a break after "/" prints "/-"; unavoidable in textkit, accepted over a dropped token). A flat character cap was tried first and rejected: it split ordinary compounds at arbitrary positions. Free-text rows and notes are kept on one page only while a rendered-line estimate (chunks counted, 12 lines max, safe for any font) says they fit; past that they may split, because a non-splittable block taller than a page is clipped silently.
[2026-09-07] Line breaks in line descriptions are collapsed by every single-line consumer (Peppol cbc:Name, accrual voucher text) and additionally in the SIE writer's quoted-text escaper: SIE is one record per line by spec, so the writer guards the format regardless of where the text came from.
[2026-09-07] Enable Banking callbacks return to the initiating white-label host by recording the allowlisted request origin on the pending row and replaying the callback there, instead of extending the provider_otc handoff from PR #2305: the brand host already holds the session, so a /login?next=<callback> bounce on that host forwards straight back into the callback with cookies, needing one nullable column and no encrypted payload, no second table and no cron. The provider redirect URI stays canonical, so nothing changes in the Enable Banking console. Stripe, Gmail and cloud backup have the same shape but no partner-domain users yet; tracked as a follow-up issue rather than built speculatively.
[2026-09-07] Stripe checkout and portal return URLs resolve through the existing resolveRequestAppOrigin allowlist (NEXT_PUBLIC_WHITELABEL_DOMAINS), not a DB brand lookup: it is the same trust boundary invites and email-change links already use, so one allowlist governs every host we redirect a browser to. Return paths stay fixed literals; no caller-supplied URL is accepted. Session-expiry and company-switch handling were left alone: the middleware already bounces to /login on the same host with the path preserved, and the webhook keys on company_id metadata.