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
parent 2d49a81508
commit 32721b9f61
11 changed files with 216 additions and 83 deletions
+1
View File
@@ -1672,4 +1672,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-08] Issue #2413 BAS 2026 kontogrupp 12: kept 1249/1259/1269 in the catalog renamed after their free heads and dropped only 1241/1242/1251/1261, instead of removing all seven retired sub-accounts and moving the asset module's vehicle/computer defaults to BAS 2026 (1226/1224 on 1229): the asset module's DEFAULT_ACCOUNTS_BY_CATEGORY still books vehicles on 1240/1249 and computers on 1250/1259 (31 live assets in prod, guard test requires the triple in BAS_REFERENCE), so dropping the contra accounts would have forced a depreciation-default change into a label fix; that change is the founder's call and lives in #2414. The prod backfill renames only the exact catalog literal next to a free-labelled head, so old-BAS imports (1240 Bilar + 1249 Ack. avskr. bilar) and user renames stay untouched.
[2026-09-08] Migration files must carry their own BEGIN/COMMIT when they use transaction-only statements (LOCK TABLE, SET LOCAL, SET CONSTRAINTS): CI replays each file with psql -f in autocommit and the Supabase branch runner does the same on prod, so the bare LOCK TABLE in 20260908113353 (#2413, PR #2419) failed both and stalled prod's migration queue behind it. Prod never recorded the failed version, so the file was deleted and re-issued as 20260908120449 rather than edited in place.
[2026-09-08] Receipt purchase date: fixed the extraction prompt's description of invoice.invoiceDate (schema comment plus an explicit "invoiceDate on receipts" rule) instead of adding a receipt-specific purchaseDate field. Why: prod (last 30 days) showed receipts losing the date on 46% of items (75% via WhatsApp) while supplier invoices lost it on 0.4%, and purchaseTime was filled on nearly every affected receipt: the date was described as a bare ISO field under the invoice block beside a purchaseTime rule marked "receipts only", and the model read that as "invoice-only". A new field would need a schema change, a UI column and every consumer (WhatsApp M4 ack date, the Bokför entry-date default that falls back to today, the worklist) taught a second date; the prompt fix stops the class with no data-shape change. Existing undated items are not re-extracted by this; a backfill is a separate call.
[2026-09-08] Draft invoice PDF marks a draft with one diagonal, faint word (UTKAST / DRAFT) across every page instead of a banner in the top margin (#2437): a banner reads as UI chrome on a document, a watermark reads as a stamp and leaves the preview pixel-identical to the final print. The long legal sentence (saknar löpnummer, ML 17 kap 24 §) is dropped on purpose: the word alone says the document is not a valid invoice, and the download dialog (#2399) already explains why before the file exists. Rotation and opacity sit on a padded wrapper View so the word turns about its own centre. Skeptic refutation accepted: the first cut (#6b7280 at 0.14, about 92% brightness) would drop out of a monochrome print or greyscale scan, and a numbered draft otherwise prints title, number and OCR like a real faktura; now #4b5563 at 0.3 (about 79% brightness), with a test pinning the composited grey between 70% and 85%. A 1-bit scan can still threshold the word away; a second explicit line on numbered drafts was left out because the request was the word alone, and that residual is Emil's call. Second refutation accepted: the overlay is emitted as the LAST child of the Page, because react-pdf paints in document order and `fixed` does not hoist, so an overlay emitted first was painted under the opaque payment and customer boxes and the word vanished on the page that carries totals and OCR; a test now inflates the PDF content streams and asserts the glyph run comes after the last rectangle fill on every page. BETALD and MAKULERAD banners are left as they are.
[2026-09-08] Negative journal-line amounts: fixed the sign at three levels (producers flip the SIDE via lib/bookkeeping/line-side.ts, the engine refuses negative amounts before any write, and a NOT VALID CHECK on journal_entry_lines) instead of only patching the supplier-invoice generator or hiding negative items in the form. Why: the invariant lived nowhere (no Zod rule, no engine check, no constraint), so MCP, templates and any future producer could repeat it; negative items themselves are valid input (rabatt, öresavrundning), so rejecting them at input would break real invoices. reverseEntry now swaps on the net so legacy negative lines storno cleanly before the data repair runs.
@@ -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,
@@ -2,9 +2,9 @@
* Page layout of the invoice PDF: what a user reported after the English
* translation shipped.
*
* - The draft stamp must not move the document: it sits in the page margin,
* out of the flow, on every page. A draft is otherwise a preview that lies
* about where the final invoice will break.
* - The draft watermark must not move the document: a faint diagonal word
* over the page, out of the flow, on every page. A draft is otherwise a
* preview that lies about where the final invoice will break.
* - Table rows, totals, the payment box and the notice boxes never split
* across a page; a section heading never ends up alone at a page bottom.
* - Words wrap whole: react-pdf's default English hyphenation split Swedish
@@ -13,11 +13,16 @@
* - A description with line breaks keeps them.
*/
import { describe, expect, it } from 'vitest'
import { inflateSync } from 'node:zlib'
import type { ReactElement, ReactNode } from 'react'
import { renderToBuffer } from '@react-pdf/renderer'
import { Font, pdf } from '@react-pdf/renderer'
import layoutDocument from '@react-pdf/layout'
import {
DRAFT_WATERMARK_COLOR,
DRAFT_WATERMARK_FONT_SIZE_PT,
DRAFT_WATERMARK_OPACITY,
DRAFT_WATERMARK_ROTATION_DEG,
HEADING_MIN_PRESENCE_AHEAD,
InvoicePDF,
DESCRIPTION_COLUMN_PT,
@@ -126,6 +131,27 @@ function pageCount(buffer: Buffer): number {
return (buffer.toString('latin1').match(/\/Type\s*\/Page\b/g) ?? []).length
}
/** Every deflated content stream in a rendered PDF, as PDF operator text. */
function contentStreams(buffer: Buffer): string[] {
const s = buffer.toString('latin1')
const out: string[] = []
let idx = 0
for (;;) {
const start = s.indexOf('stream\n', idx)
if (start < 0) break
const dataStart = start + 'stream\n'.length
const end = s.indexOf('endstream', dataStart)
if (end < 0) break
try {
out.push(inflateSync(buffer.subarray(dataStart, end)).toString('latin1'))
} catch {
// Not a deflated stream (font program, image): skip.
}
idx = end + 'endstream'.length
}
return out
}
// The laid-out node tree react-pdf hands to the painter: every node carries
// its resolved box (top/left/width/height, relative to the page) and TEXT
// nodes carry their broken lines. This is what the PDF will look like, so it
@@ -213,17 +239,43 @@ function expectEveryLineInsideItsBox(pages: LaidOutNode[], needle: string) {
const PAGE_TOP_PADDING = 40
describe('draft stamp', () => {
it('is out of the flow, in the top margin, and repeats on every page', () => {
describe('draft watermark', () => {
// #2437: one diagonal, faint word across the page instead of a banner in
// the top margin. Out of the flow, on every page, and nothing else: the
// legal sentence about löpnummer is gone on purpose.
it('is a fixed full-page overlay carrying only the word UTKAST', () => {
const tree = InvoicePDF({ invoice: draftInvoice(), customer, items: [makeItem()], company })
const stamp = elements(tree).find(
const overlay = elements(tree).find(
(el) => el.props.fixed === true && containsText(el, 'UTKAST'),
)
expect(stamp).toBeDefined()
const style = styleOf(stamp!)
expect(overlay).toBeDefined()
const style = styleOf(overlay!)
expect(style.position).toBe('absolute')
// The page has a 40pt top padding; the stamp must fit inside it.
expect(style.top).toBeLessThan(40)
expect([style.top, style.left, style.right, style.bottom]).toEqual([0, 0, 0, 0])
expect(textLeaves(overlay!.props.children)).toEqual(['UTKAST'])
// Rotation and opacity sit on a wrapper around the Text; the Text
// carries the type.
const wrapper = elements(overlay!).find((el) => containsText(el, 'UTKAST') && el !== overlay)
const wrapperStyle = styleOf(wrapper!)
expect(wrapperStyle.opacity).toBe(DRAFT_WATERMARK_OPACITY)
// Faint enough to stay background, dark enough to survive a greyscale
// print: the composited grey on white must land between 70% and 85%.
const [r, g, b] = [1, 3, 5].map((i) => parseInt(DRAFT_WATERMARK_COLOR.slice(i, i + 2), 16))
const composited = 255 - DRAFT_WATERMARK_OPACITY * (255 - (0.2126 * r + 0.7152 * g + 0.0722 * b))
expect(composited / 255).toBeGreaterThan(0.7)
expect(composited / 255).toBeLessThan(0.85)
expect(wrapperStyle.transform).toBe(`rotate(${DRAFT_WATERMARK_ROTATION_DEG}deg)`)
const word = elements(wrapper!).find((el) => el.props.children === 'UTKAST')
expect(styleOf(word!).fontSize).toBe(DRAFT_WATERMARK_FONT_SIZE_PT)
})
it('says DRAFT on an English document', () => {
const tree = InvoicePDF({ invoice: draftInvoice(), customer, items: [makeItem()], company, language: 'en' })
const overlay = elements(tree).find((el) => el.props.fixed === true && containsText(el, 'DRAFT'))
expect(overlay).toBeDefined()
expect(textLeaves(overlay!.props.children)).toEqual(['DRAFT'])
expect(elements(tree).some((el) => containsText(el, 'not a valid invoice'))).toBe(false)
})
it('is not rendered for a numbered, sent invoice', () => {
@@ -231,7 +283,7 @@ describe('draft stamp', () => {
expect(elements(tree).some((el) => containsText(el, 'UTKAST'))).toBe(false)
})
it('renders a real PDF with the stamp on each page of a long draft', { timeout: 30_000 }, async () => {
it('renders a real PDF with the watermark on each page of a long draft', { timeout: 30_000 }, async () => {
const items = Array.from({ length: 60 }, (_, i) =>
makeItem({ sort_order: i, id: `item-${i}`, description: `Rad ${i + 1}` }),
)
@@ -247,23 +299,87 @@ describe('draft stamp', () => {
}
})
it('is painted last on every page, so no opaque box can cover the word', { timeout: 30_000 }, async () => {
// react-pdf paints children in document order and `fixed` does not hoist:
// an overlay emitted before the payment box (opaque #f8f9fa, full content
// width) is painted underneath it and the word disappears on exactly the
// page that carries totals, bankgiro and OCR. Skeptic finding on #2437.
const items = Array.from({ length: 22 }, (_, i) =>
makeItem({ sort_order: i, id: `item-${i}`, description: `Rad ${i + 1}` }),
)
const tree = InvoicePDF({ invoice: draftInvoice(), customer, items, company })
const page = elements(tree).find((el) => el.props.size === 'A4')
const pageChildren = (page!.props.children as ReactNode[]).flat().filter(
(c) => c !== null && c !== undefined && typeof c !== 'boolean',
)
const last = pageChildren[pageChildren.length - 1] as AnyElement
expect(last.props.fixed).toBe(true)
expect(containsText(last, 'UTKAST')).toBe(true)
// And in the actual PDF: within each page's content stream the watermark
// glyph run comes after the last rectangle fill.
const buffer = await renderToBuffer(tree)
expect(pageCount(buffer)).toBeGreaterThan(1)
const streams = contentStreams(buffer)
// U T K A S T as WinAnsi glyph codes, letter-spaced, in one TJ array.
const watermarkRun = /\[<55>[^\]<]*<54>[^\]<]*<4b>[^\]<]*<41>[^\]<]*<53>[^\]<]*<54>[^\]<]*\]\s*TJ/
const pagesWithWord = streams.filter((s) => watermarkRun.test(s))
expect(pagesWithWord.length).toBe(pageCount(buffer))
for (const s of pagesWithWord) {
const wordAt = s.search(watermarkRun)
const lastFill = Math.max(s.lastIndexOf(' re\nf'), s.lastIndexOf(' re\n'))
expect(lastFill).toBeGreaterThan(-1)
expect(wordAt).toBeGreaterThan(lastFill)
}
})
it('does not move the document: the first row sits where it sits on a sent invoice', async () => {
const items = [makeItem({ description: 'Rad 1' })]
const draftPages = await layOut(InvoicePDF({ invoice: draftInvoice(), customer, items, company }))
const sentPages = await layOut(InvoicePDF({ invoice: sentInvoice(), customer, items, company }))
const rowBottom = (ps: LaidOutNode[]) =>
absoluteTextBottoms(ps[0]).find((t) => t.text === 'Rad 1')?.bottom
expect(rowBottom(draftPages)).toBeDefined()
expect(rowBottom(draftPages)).toBe(rowBottom(sentPages))
})
it.each([
['sv', 'draft'],
['en', 'draft'],
['sv', 'sent'],
['en', 'sent'],
] as const)('stays inside the top margin (%s, %s without number)', async (language, status) => {
// 'sent' without a number is the corrupt-state case with the longest text.
const invoice = { ...draftInvoice(), status }
['sv', 'draft', 'invoice'],
['en', 'draft', 'invoice'],
['sv', 'sent', 'invoice'],
['en', 'sent', 'invoice'],
['sv', 'draft', 'quote'],
['en', 'draft', 'quote'],
] as const)('covers the page and keeps the word on one line inside it (%s, %s %s)', async (language, status, documentType) => {
// 'sent' without a number is the corrupt-state case; it is marked too.
const invoice = { ...draftInvoice(), status, document_type: documentType }
const pages = await layOut(InvoicePDF({ invoice, customer, items: [makeItem()], company, language }))
const stamp = textNodes(pages[0]).find((n) => /UTKAST|DRAFT/.test(textOf(n)))
expect(stamp).toBeDefined()
let box: LaidOutNode['box']
const word = textNodes(pages[0]).find((n) => /^(UTKAST|DRAFT)$/.test(textOf(n)))
expect(word).toBeDefined()
expect(word!.lines).toHaveLength(1)
// word -> rotated wrapper -> full-page overlay
let wrapper: LaidOutNode | undefined
let overlay: LaidOutNode | undefined
walk(pages[0], (n) => {
if (n.children?.includes(stamp!)) box = n.box
if (n.children?.includes(word!)) wrapper = n
})
expect(box).toBeDefined()
expect(box!.top + box!.height).toBeLessThanOrEqual(PAGE_TOP_PADDING)
walk(pages[0], (n) => {
if (wrapper && n.children?.includes(wrapper)) overlay = n
})
expect(overlay?.box).toBeDefined()
const page = pages[0].box!
expect(overlay!.box!.top).toBe(0)
expect(overlay!.box!.left).toBe(0)
expect(overlay!.box!.width).toBeCloseTo(page.width, 0)
expect(overlay!.box!.height).toBeCloseTo(page.height, 0)
// Rotation happens at paint time around the word's centre; the unrotated
// ink must fit the page width so no glyph is clipped once turned.
const ink = word!.lines![0].xAdvance ?? word!.lines![0].box.width
expect(ink).toBeLessThan(page.width)
// Centred on the page, well below the top margin (boxes are parent-relative;
// the overlay sits at the page origin, so the wrapper's top is absolute).
expect(wrapper!.box!.top).toBeGreaterThan(PAGE_TOP_PADDING)
expect(wrapper!.box!.top + wrapper!.box!.height / 2).toBeCloseTo(page.height / 2, 0)
})
})
+3 -2
View File
@@ -3,8 +3,9 @@ import type { Invoice } from '@/types'
/**
* What "Ladda ner PDF" should do for a document that is not yet issued.
*
* The renderer stamps every status='draft' document "UTKAST: inte en giltig
* faktura". That stamp is correct: an unbooked invoice is not issued. The
* The renderer watermarks every status='draft' document "UTKAST" ("DRAFT"
* on an English document). That stamp is correct: an unbooked invoice is
* not issued. The
* problem (#2399) is that nothing said so before the file was on disk, and
* the stamped PDF was mailed to customers by mistake. So the download asks
* first, and offers the path that produces the real document.
+1 -1
View File
@@ -89,7 +89,7 @@ export async function archiveIssuedInvoicePdf(args: {
// The DB status flip already happened, but the in-memory `invoice` is
// stale and still reads 'draft': override here so the archived underlag
// isn't stamped "UTKAST: inte en giltig faktura".
// isn't stamped "UTKAST".
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
const paymentAccountRequired = invoiceRequiresPaymentAccount(invoice as Invoice)
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
+64 -49
View File
@@ -138,6 +138,21 @@ export function fitsOnOnePage(text: string | null | undefined, budgetPt: number)
*/
export const HEADING_MIN_PRESENCE_AHEAD = 40
/**
* Draft watermark geometry (#2437). One word ("UTKAST" / "DRAFT"), bold,
* diagonal and faint, centred on every page. The font size keeps the widest
* word ("UTKAST", 6 glyphs with letter spacing) inside the 595pt A4 width
* once rotated. The colour and opacity are a balance: the invoice underneath
* must stay legible, and the word must survive a monochrome print or a
* greyscale scan, because a numbered draft otherwise carries every field of
* a real faktura (title, number, OCR). #4b5563 at 0.3 composites to about
* 79% brightness on white: clearly grey on paper, still background.
*/
export const DRAFT_WATERMARK_FONT_SIZE_PT = 96
export const DRAFT_WATERMARK_OPACITY = 0.3
export const DRAFT_WATERMARK_COLOR = '#4b5563'
export const DRAFT_WATERMARK_ROTATION_DEG = -35
type PdfLang = 'sv' | 'en'
// Customer-facing labels. Statutory chapter references (ML 17 kap 24§, ML 3 kap.)
@@ -155,11 +170,7 @@ const LABELS = {
cancelledTitle: 'MAKULERAD: inte en giltig faktura',
cancelledWithNumber: (n: string) => `Faktura ${n} har makulerats. Numret behålls i serien för att hålla nummerföljden obruten enligt ML 17 kap 24§, men dokumentet är inte ett giltigt fakturaunderlag.`,
cancelledNoNumber: 'Detta utkast har makulerats och är inte ett giltigt fakturaunderlag.',
draftTitle: 'UTKAST: inte en giltig faktura',
draftTitleQuote: 'UTKAST',
draftTextQuote: 'Detta är ett utkast av offerten.',
draftWithNumber: 'Detta är ett utkast. Markera fakturan som skickad eller skicka via systemet för att göra den giltig som fakturaunderlag.',
draftNoNumber: 'Denna faktura saknar löpnummer och kan inte användas som fakturaunderlag enligt ML 17 kap 24§. Skicka fakturan via systemet för att tilldela ett nummer.',
draftWatermark: 'UTKAST',
paidTitle: 'BETALD',
paidBannerText: (date: string, amount: string) => `Betald ${date} · ${amount}`,
paidBannerNoDate: (amount: string) => `Betald · ${amount}`,
@@ -253,11 +264,7 @@ const LABELS = {
cancelledTitle: 'VOID: not a valid invoice',
cancelledWithNumber: (n: string) => `Invoice ${n} has been voided. The number is retained in the sequence to keep the numbering unbroken (ML 17 kap 24§, Swedish VAT Act), but this document is not a valid invoice.`,
cancelledNoNumber: 'This draft has been voided and is not a valid invoice.',
draftTitle: 'DRAFT: not a valid invoice',
draftTitleQuote: 'DRAFT',
draftTextQuote: 'This is a draft of the quote.',
draftWithNumber: 'This is a draft. Mark the invoice as sent, or send it via the system, to make it a valid invoice.',
draftNoNumber: 'This invoice has no serial number and cannot be used as a valid invoice under ML 17 kap 24§ (Swedish VAT Act). Send the invoice via the system to assign a number.',
draftWatermark: 'DRAFT',
paidTitle: 'PAID',
paidBannerText: (date: string, amount: string) => `Paid ${date} · ${amount}`,
paidBannerNoDate: (amount: string) => `Paid · ${amount}`,
@@ -643,34 +650,35 @@ function createStyles(branding?: InvoiceBranding) {
creditNoteTitle: {
color: '#721c24',
},
// Draft stamp: lives in the page's top margin (page padding is 40pt; the
// stamp is at most about 32pt tall when the English no-number text wraps
// to two lines) and is taken out of the flow, so a draft previews exactly
// as the final invoice will print. `fixed` repeats it on every page. It
// used to be a full-size banner in the flow, which pushed the whole
// document down and made the preview lie about page breaks.
draftBanner: {
// Draft watermark (#2437): one word, diagonal and faint, across the whole
// page, the way a stamp marks a paper document. The overlay is absolutely
// positioned over the page box and taken out of the flow, so a draft
// previews exactly as the final invoice will print; `fixed` repeats it
// on every page. It replaced a yellow banner in the top margin, which
// read as UI chrome on a document (and, before that, a banner in the
// flow that pushed the whole document down).
draftWatermark: {
position: 'absolute',
top: 5,
left: 40,
right: 40,
paddingVertical: 2,
paddingHorizontal: 8,
backgroundColor: '#fff3cd',
borderWidth: 1,
borderColor: '#856404',
borderRadius: 3,
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
},
draftBannerTitle: {
fontSize: 9,
// Rotation and opacity sit on a padded wrapper so the word turns about
// the centre of its own box and the Text keeps a plain type style.
draftWatermarkWord: {
transform: `rotate(${DRAFT_WATERMARK_ROTATION_DEG}deg)`,
opacity: DRAFT_WATERMARK_OPACITY,
paddingVertical: 24,
paddingHorizontal: 24,
},
draftWatermarkText: {
fontSize: DRAFT_WATERMARK_FONT_SIZE_PT,
fontWeight: 'bold',
color: '#856404',
textAlign: 'center',
},
draftBannerText: {
fontSize: 7,
color: '#856404',
textAlign: 'center',
color: DRAFT_WATERMARK_COLOR,
letterSpacing: 6,
},
cancelledBanner: {
marginBottom: 16,
@@ -973,6 +981,11 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
// recomputation of the deduction-aware total; the fallback to the amount to
// pay covers legacy rows marked paid before paid_amount was recorded.
const paidState = resolvePdfPaidState(invoice, docType, isCreditNote, amountToPay.toPay)
// Draft watermark (#2437): genuine drafts, plus the corrupt-state case of a
// non-cancelled invoice that somehow lacks a number. Cancelled wins (the
// MAKULERAD banner below), and the interactive preview has its own title.
const isDraftMarked =
invoice.status !== 'cancelled' && !isPreview && (invoice.status === 'draft' || !invoice.invoice_number)
// Optional branding banner text. Rendered only when the company has set
// invoice_header_text: invisible chrome by default, so the byte-equivalence
@@ -994,8 +1007,8 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{/* Status banner: cancelled takes precedence over draft so a cancelled
row that lacks a number (legacy un-numbered draft that was later
cancelled) still surfaces as MAKULERAD rather than UTKAST. The draft
banner only shows for genuine drafts and for the corrupt-state case
of a non-cancelled invoice that somehow lacks a number. */}
watermark only shows for genuine drafts and for the corrupt-state
case of a non-cancelled invoice that somehow lacks a number. */}
{invoice.status === 'cancelled' ? (
<View style={styles.cancelledBanner}>
<Text style={styles.cancelledBannerTitle}>{L.cancelledTitle}</Text>
@@ -1005,18 +1018,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
: L.cancelledNoNumber}
</Text>
</View>
) : isPreview ? null : (invoice.status === 'draft' || !invoice.invoice_number) ? (
<View style={styles.draftBanner} fixed>
<Text style={styles.draftBannerTitle}>{isQuote ? L.draftTitleQuote : L.draftTitle}</Text>
<Text style={styles.draftBannerText} hyphenationCallback={wrapFullWidthWords}>
{isQuote
? L.draftTextQuote
: invoice.invoice_number
? L.draftWithNumber
: L.draftNoNumber}
</Text>
</View>
) : paidState?.kind === 'paid' && (
) : isPreview || isDraftMarked ? null : paidState?.kind === 'paid' && (
// BETALD stamp (#1693): the re-rendered copy of a settled faktura
// doubles as the betalningsbekräftelse the customer can be handed.
// partially_paid gets no banner, only the Betalt / Att betala rows.
@@ -1608,6 +1610,19 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
].filter(Boolean).join(' · ')}
</Text>
</View>
{/* Draft watermark (#2437), deliberately the LAST child of the page:
react-pdf paints children in document order and `fixed` does not
hoist, so anything emitted after it with a backgroundColor (the
customer box, the payment section, notice boxes) would paint over
the word. Absolute + fixed keeps it out of the flow on every page. */}
{isDraftMarked && (
<View style={styles.draftWatermark} fixed>
<View style={styles.draftWatermarkWord}>
<Text style={styles.draftWatermarkText}>{L.draftWatermark}</Text>
</View>
</View>
)}
</Page>
</Document>
)
+1 -1
View File
@@ -3130,7 +3130,7 @@ async function commitSendInvoice(
// Override `status` to 'sent' on the in-memory copy. The DB flip happens
// after email delivery (line ~625); rendering with the stale 'draft' status
// would stamp the customer's PDF with "UTKAST: inte en giltig faktura".
// would stamp the customer's PDF with "UTKAST".
const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
const { branding, company: renderCompany } = await prepareInvoicePdfRender(
company as CompanySettings,