feat(invoices): diagonal UTKAST/DRAFT watermark on draft PDFs instead of the top-margin banner (#2441)

* feat(invoices): mark draft PDFs with a diagonal UTKAST/DRAFT watermark instead of a banner

Why the problem occurred: the draft marking was a boxed yellow banner in
the page's top margin. It stayed out of the flow (#2369) but still read as
UI chrome pasted on a document, and carried a two-line legal sentence that
nobody reads on a preview.

What was removed: the banner block, its three styles and the four legal
sentences (sv+en). The draft state is now one word, bold, rotated -35deg at
14% opacity, centred on every page, the way a stamp marks paper. The
download dialog (#2399) already explains why a draft is not a valid
invoice before the file exists, so the PDF does not repeat it.

Why this shape: rotation and opacity sit on a padded wrapper View so the
word turns about its own centre and the Text keeps a plain type style.
The overlay is absolutely positioned over the page box and `fixed`, so the
document underneath previews pixel-identical to the final print; a test
asserts the first row sits at the same y as on a sent invoice. BETALD and
MAKULERAD banners are unchanged (separate concern).

Fixes #2437

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

* fix(invoices): darken the draft watermark so it survives a greyscale print

Skeptic refutation: #6b7280 at 0.14 composites to about 92% brightness on
white, which a monochrome print or greyscale scan drops, and a numbered
draft otherwise prints the FAKTURA title, its number and an OCR like an
issued invoice. Now #4b5563 at 0.3 (about 79% brightness), with a test
pinning the composited grey between 70% and 85% so neither extreme can
creep back in.

Refs #2437

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(invoices): paint the draft watermark last so opaque boxes cannot cover it

Skeptic refutation (correctness and regression, independently): the
overlay was the first child of the Page. react-pdf paints children in
document order and `fixed` does not hoist, so the customer box and the
full-width payment section (opaque #f5f5f5 / #f8f9fa) painted over the
word. On a two-page draft the last page, the one with totals, bankgiro
and OCR, lost the word entirely.

The overlay is now the last child of the Page, behind a single
isDraftMarked flag that also keeps the cancelled > draft > paid banner
precedence. A new test inflates the rendered PDF content streams and
asserts the UTKAST glyph run comes after the last rectangle fill on
every page, so the element tree alone can no longer pass while the
paint order is wrong.

Refs #2437

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(invoices): note the English DRAFT label in the download-decision comment

CodeRabbit on #2441: the comment said every draft is stamped UTKAST; an
English document says DRAFT.

Refs #2437

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-08 18:11:43 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 2d49a81508
commit 32721b9f61
11 changed files with 216 additions and 83 deletions
@@ -609,8 +609,8 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => {
expect(status).toBe(200)
// The in-memory invoice still reads 'draft' after the DB status flip
// (it's never re-fetched). We must override it before render or
// pdf-template.tsx prints the "UTKAST: inte en giltig faktura" banner
// on the archived underlag.
// pdf-template.tsx prints the "UTKAST" watermark on the archived
// underlag.
expect(vi.mocked(InvoicePDF)).toHaveBeenCalledTimes(1)
const renderArgs = vi.mocked(InvoicePDF).mock.calls[0][0]
expect(renderArgs.invoice.status).toBe('sent')
@@ -986,7 +986,7 @@ describe('POST /api/invoices/[id]/send', () => {
// Final render: invoice already has an invoice_number on the fixture, so
// preflight is skipped and InvoicePDF is called exactly once. The status
// passed in must be 'sent': otherwise pdf-template.tsx renders the
// "UTKAST: inte en giltig faktura" banner on the customer's PDF.
// "UTKAST" watermark on the customer's PDF.
expect(vi.mocked(InvoicePDF)).toHaveBeenCalledTimes(1)
const renderArgs = vi.mocked(InvoicePDF).mock.calls[0][0]
expect(renderArgs.invoice.status).toBe('sent')
+1 -1
View File
@@ -363,7 +363,7 @@ export const POST = withRouteContext(
// the email and later archived as underlag. Override status to 'sent' on
// the in-memory copy: the DB flip happens after email delivery (line
// ~185), but if we render with the stale 'draft' status the customer
// receives a PDF stamped "UTKAST: inte en giltig faktura".
// receives a PDF stamped "UTKAST".
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
company as CompanySettings,
@@ -807,7 +807,7 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/send', () => {
// a preflight render runs first with the F-PREVIEW placeholder. The final
// render is the second call: its invoice must carry status: 'sent' and
// the freshly-assigned invoice_number, otherwise the customer's PDF is
// stamped "UTKAST: inte en giltig faktura".
// stamped "UTKAST".
const calls = vi.mocked(InvoicePDF).mock.calls
expect(calls.length).toBeGreaterThanOrEqual(2)
const finalRenderArgs = calls[calls.length - 1][0]
@@ -561,7 +561,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
// Also override `status` to 'sent' on the in-memory copy. The actual DB
// flip happens at step 9a (after email delivery), but if we render with
// the stale 'draft' status the customer receives a PDF stamped
// "UTKAST: inte en giltig faktura".
// "UTKAST".
const renderableInvoice: Invoice = {
...(typed as Invoice),
invoice_number: finalInvoiceNumber,