From 7a30f623ba0a7df61a72dbd08e6324e118aadd5e Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:45:38 +0200 Subject: [PATCH] fix(invoices): invoice PDF draft stamp, page breaks, wrapping, units, multi-line descriptions (#2369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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 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 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 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 Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW * docs(invoices): state the bundled-font width margin accurately Co-Authored-By: Claude Fable 5.1 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 Claude-Session: https://claude.ai/code/session_01BuYbae3WWwYzBucTUVayoW --------- Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 4 + app/(dashboard)/invoices/[id]/page.tsx | 8 +- components/invoices/AutoGrowTextarea.tsx | 51 ++ components/invoices/InvoiceEditor.tsx | 5 +- components/invoices/InvoiceReviewContent.tsx | 8 +- .../accruals/__tests__/from-invoices.test.ts | 25 + lib/bookkeeping/accruals/from-invoices.ts | 7 +- lib/import/__tests__/sie-parser.test.ts | 17 + lib/import/sie-parser.ts | 4 +- .../__tests__/pdf-template-layout.test.ts | 536 ++++++++++++++++++ lib/invoices/__tests__/unit-labels.test.ts | 36 ++ lib/invoices/display.ts | 11 + lib/invoices/pdf-glyph-widths.ts | 41 ++ lib/invoices/pdf-template.tsx | 219 +++++-- lib/invoices/peppol-bis-billing.ts | 5 +- lib/invoices/unit-labels.ts | 25 + lib/reports/__tests__/sie-export.test.ts | 63 ++ lib/reports/sie-export.ts | 7 +- 18 files changed, 1020 insertions(+), 52 deletions(-) create mode 100644 components/invoices/AutoGrowTextarea.tsx create mode 100644 lib/invoices/__tests__/pdf-template-layout.test.ts create mode 100644 lib/invoices/__tests__/unit-labels.test.ts create mode 100644 lib/invoices/pdf-glyph-widths.ts create mode 100644 lib/invoices/unit-labels.ts diff --git a/DECISIONS.md b/DECISIONS.md index cc1e3fba..85c81639 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1636,5 +1636,9 @@ One line per decision: `[YYYY-MM-DD] : `. 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= 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. diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index ded7e83d..9a5f3467 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -2256,13 +2256,13 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st {invoice.items.map((item) => isTextLikeLine(item) ? ( - + {item.description || ' '} ) : ( - + {item.description} {lineSubInfo(item)} @@ -2284,11 +2284,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{invoice.items.map((item) => isTextLikeLine(item) ? ( -

{item.description || ' '}

+

{item.description || ' '}

) : (
-

{item.description}

+

{item.description}

{item.quantity} {item.unit} × {formatCurrency(item.unit_price, invoice.currency)}

diff --git a/components/invoices/AutoGrowTextarea.tsx b/components/invoices/AutoGrowTextarea.tsx new file mode 100644 index 00000000..b5c3e3a6 --- /dev/null +++ b/components/invoices/AutoGrowTextarea.tsx @@ -0,0 +1,51 @@ +'use client' + +import { forwardRef, useCallback, useLayoutEffect, useRef, type TextareaHTMLAttributes } from 'react' +import { cn } from '@/lib/utils' + +/** + * A single-line-looking textarea that grows with its content. + * + * Invoice line descriptions used to be ``s, so a user could never put + * a line break where they wanted one; the PDF then wrapped wherever the + * column ran out. This keeps the dense one-row look of the editor grid while + * letting Enter insert a newline, and re-measures on every render so a + * prefilled multi-line description (edit flow) opens at the right height. + */ +export const AutoGrowTextarea = forwardRef>( + function AutoGrowTextarea({ className, onInput, ...props }, ref) { + const inner = useRef(null) + + const setRef = useCallback( + (el: HTMLTextAreaElement | null) => { + inner.current = el + if (typeof ref === 'function') ref(el) + else if (ref) ref.current = el + }, + [ref], + ) + + useLayoutEffect(() => { + resize(inner.current) + }) + + return ( +