Files
accounted/lib/invoices/pdf-render-helpers.ts
T
MattssonandClaude Fable 5 619b446c52 fix(invoices): make the Swish QR encode the amount to pay after ROT/RUT deduction (#1685)
* fix(invoices): make the Swish QR encode the amount to pay after ROT/RUT deduction

The Swish payment QR on invoice PDFs encoded the pre-deduction invoice
total (getDisplayTotal), while the totals block and the invoice email
state "Att betala" as total minus the ROT/RUT deduction (getAmountToPay,
fakturamodellen). Since the Swish payload locks the amount (editmask 0),
a customer scanning a RUT/ROT invoice was asked to pay the full total
with no way to correct it: overpaying by the entire skattereduktion.

Swap the QR amount source to getAmountToPay(...).toPay so the QR, the
printed "Att betala" and the email always agree. A fully deducted
invoice (toPay = 0) now renders no QR via the existing amount > 0 guard.
All seven render surfaces (send, preview, pdf, v1 send/pdf, MCP commit,
recurring, issue-and-book) go through this one helper.

Reported by a user: "QR-koden for swish stammer INTE med beloppet man
ska betala. Den tar INTE hansyn till reduktionen."

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

* fix(invoices): select the amount-to-pay columns on the v1 pdf and send surfaces

Skeptic review of the Swish QR fix found it was a silent no-op on the v1
GET pdf route: its column projection predated ROT/RUT and omitted
deduction_total (and ore_rounding), so getAmountToPay saw undefined,
treated it as "no deduction", and the route kept emitting a locked
full-amount QR while the sent email said the deducted "Att betala".
INVOICE_FULL_COLUMNS (v1 send renders from it) likewise omitted
ore_rounding, ignoring the per-invoice oresavrundning override there.

Move INVOICE_PDF_COLUMNS into lib/api/v1/invoice-columns.ts, add
deduction_total, deduction_personnummer_last4 and ore_rounding to it, add
ore_rounding to INVOICE_FULL_COLUMNS, and pin the amount-path columns of
both projections with a test: a projection gap does not error, it renders
the wrong money on one surface only, so it must be caught structurally.

Also records the defect and remediation in DECISIONS.md per the
compliance-swarm change-risk finding (the repo has no risk_register.csv;
the decision log is its equivalent).

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

* fix(invoices): gate the Swish QR to payable documents and restore delivery_date on the v1 pdf

Swedish accounting review round 2: buildSwishQrDataUrl had no non-payable
gate, so a kreditfaktura (a refund document) still produced a locked
Swish payment QR at helper level; the template happens to hide the
payment box for credit notes, but a payment request against a refund
must stay impossible rather than merely unrendered. Apply the same
document gate buildPaymentLinkQrDataUrl already has (invoice documents
without credited_invoice_id only) and pin it with tests replacing the
credit-note parity case.

Also add delivery_date to INVOICE_PDF_COLUMNS: ML 17 kap 24 p.7 requires
leveransdatum on the invoice when it differs from the invoice date, the
template renders exactly that, and the v1 pdf projection silently
dropped it. Same projection-starvation class as the previous commit.

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

* docs(invoices): name the covered render surfaces and drop the contested lagrum point number

CodeRabbit round 3, both documentation-only: the DECISIONS defect record
said "all surfaces" while the editor preview is deferred to #1686, so it
now lists the covered surfaces explicitly; and the delivery_date comment
cited ML 17 kap 24 p.7 where CodeRabbit reads p.8 in SFS 2023:200 while
the repo's swedish-invoice-compliance reference table says p.7, so the
citation drops the point number and stays at the paragraph, which is
correct under either enumeration. No behavior change.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 16:39:49 +02:00

255 lines
11 KiB
TypeScript

/**
* Shared helpers for invoice PDF render call sites.
*
* Three responsibilities:
* 1. Build the branding object from company settings.
* 2. Resolve the company logo into a format @react-pdf/renderer can draw.
* 3. Build the optional Swish payment QR.
*
* Why the logo needs resolving (issue #772: "Logotyp kommer inte med på
* fakturor"): @react-pdf/renderer's <Image> only decodes JPG and PNG, but the
* logo upload route and the `logos` storage bucket both accept SVG and WebP.
* When the logo is an SVG/WebP, @react-pdf fails to decode it and *silently*
* swallows the error (a console.warn inside a try/catch in its fetchImage step):
* so the invoice renders fine but with no logo, and nothing surfaces.
*
* Fix: fetch the stored logo and re-encode it to a PNG data URL via sharp, then
* hand the template a company whose `logo_url` is that data URL. This makes the
* logo render regardless of the uploaded format and removes the render-time
* dependency on a remote fetch succeeding inside @react-pdf.
*/
import QRCode from 'qrcode'
import type { CompanySettings, Currency, Invoice } from '@/types'
import { brandingFromCompanySettings, SHOW_SWISH_ON_INVOICE, type InvoiceBranding } from '@/lib/invoices/pdf-template'
import { buildSwishQrPayload } from '@/lib/payments/swish'
import { getAmountToPay } from '@/lib/invoices/rounding'
import { createLogger } from '@/lib/logger'
import { LOGO_UPLOAD_MAX_BYTES } from '@/lib/invoices/branding-constants'
import { prepareInvoiceFont } from '@/lib/invoices/pdf-fonts'
import {
assertInvoicePaymentAccountForRender,
companyWithInvoicePaymentAccount,
} from '@/lib/invoices/payment-accounts'
const log = createLogger('invoice.swish-qr')
const paymentLinkLog = createLogger('invoice.payment-link-qr')
export interface InvoicePdfRenderExtras {
branding: InvoiceBranding
/**
* The company settings to pass to InvoicePDF. Identical to the input except
* `logo_url` is replaced by an embedded PNG data URL when the stored logo
* could be fetched and re-encoded. Falls back to the original settings
* unchanged on any failure, so behaviour is never worse than before.
*/
company: CompanySettings
}
export interface InvoicePdfRenderOptions {
paymentAccountRequired?: boolean
}
// A company's logo is reused across every invoice render, and twice per send
// (preflight + final render), and once per invoice in recurring/batch loops:
// so cache the re-encoded result keyed by logo URL. Only successes are cached
// (with a short TTL); a transient fetch blip is retried on the next render
// rather than sticking around as a logo-less invoice. Bounded so a long-lived
// self-hosted process doesn't grow the map without limit.
const LOGO_CACHE_TTL_MS = 5 * 60 * 1000
const LOGO_CACHE_MAX = 50
const logoDataUrlCache = new Map<string, { dataUrl: string; at: number }>()
// The invoice draws the logo at up to 240pt by 80pt, so 600px keeps it crisp
// while bounding the embedded base64 payload.
const LOGO_MAX_PX = 600
// Bound the logo fetch so a slow or oversized response can't hang or balloon an
// invoice render. logo_url is currently always a Supabase `logos`-bucket public
// URL (set only by the upload route), so SSRF is not reachable today: these
// caps are defense-in-depth for that invariant plus plain robustness.
const LOGO_FETCH_TIMEOUT_MS = 5_000
// Coalesce concurrent renders of the same logo (preflight + final on a send, and
// every invoice in a recurring/batch loop) onto one in-flight fetch+encode
// instead of each doing the full round-trip before the first result is cached.
const logoInflight = new Map<string, Promise<string | null>>()
/**
* Fetch a stored logo and re-encode it to a PNG data URL. Returns null on any
* failure (network error, timeout, oversized payload, unreadable image, sharp
* unavailable): the caller then keeps the original URL, which @react-pdf can
* still fetch directly for PNG/JPEG logos. Concurrent calls for the same URL
* share a single in-flight request.
*/
async function resolveLogoDataUrl(logoUrl: string): Promise<string | null> {
// Already embedded: nothing to fetch or convert.
if (logoUrl.startsWith('data:')) return logoUrl
const cached = logoDataUrlCache.get(logoUrl)
if (cached && Date.now() - cached.at < LOGO_CACHE_TTL_MS) return cached.dataUrl
const inflight = logoInflight.get(logoUrl)
if (inflight) return inflight
const work = encodeLogo(logoUrl)
logoInflight.set(logoUrl, work)
try {
return await work
} finally {
// Only successes are cached (in encodeLogo); dropping the in-flight entry
// here lets a transient failure be retried on the next render.
logoInflight.delete(logoUrl)
}
}
async function encodeLogo(logoUrl: string): Promise<string | null> {
try {
const res = await fetch(logoUrl, { signal: AbortSignal.timeout(LOGO_FETCH_TIMEOUT_MS) })
if (!res.ok) return null
// Reject oversized payloads up front when the server declares a length, and
// again after reading in case the header lied or was absent.
const declared = Number(res.headers.get('content-length') ?? '')
if (Number.isFinite(declared) && declared > LOGO_UPLOAD_MAX_BYTES) return null
const input = Buffer.from(await res.arrayBuffer())
if (input.byteLength > LOGO_UPLOAD_MAX_BYTES) return null
// SVGs must be rasterized at a higher density or sharp renders them at
// their intrinsic (often tiny) pixel size and the result looks blurry.
const contentType = res.headers.get('content-type') ?? ''
const isSvg =
/svg/i.test(contentType) ||
input.subarray(0, 256).toString('utf8').trimStart().startsWith('<')
// Lazy, isolated import: if sharp ever fails to load in a given runtime we
// degrade to the original URL instead of breaking invoice sending entirely.
const { default: sharp } = await import('sharp')
const png = await sharp(input, isSvg ? { density: 288 } : {})
.resize({
width: LOGO_MAX_PX,
height: LOGO_MAX_PX,
fit: 'inside',
withoutEnlargement: true,
})
.png()
.toBuffer()
const dataUrl = `data:image/png;base64,${png.toString('base64')}`
// Refresh insertion order so eviction is LRU-ish, then bound the cache.
logoDataUrlCache.delete(logoUrl)
if (logoDataUrlCache.size >= LOGO_CACHE_MAX) {
const oldest = logoDataUrlCache.keys().next().value
if (oldest !== undefined) logoDataUrlCache.delete(oldest)
}
logoDataUrlCache.set(logoUrl, { dataUrl, at: Date.now() })
return dataUrl
} catch {
return null
}
}
export async function prepareInvoicePdfRender(
company: CompanySettings,
currency?: Currency,
options: InvoicePdfRenderOptions = {},
): Promise<InvoicePdfRenderExtras> {
if (currency && options.paymentAccountRequired !== false) {
assertInvoicePaymentAccountForRender(company, currency)
}
const branding = await prepareInvoiceFont(
company,
brandingFromCompanySettings(company),
)
const paymentCompany = currency
? companyWithInvoicePaymentAccount(company, currency)
: company
if (!paymentCompany.logo_url) return { branding, company: paymentCompany }
const dataUrl = await resolveLogoDataUrl(paymentCompany.logo_url)
const resolved =
dataUrl && dataUrl !== paymentCompany.logo_url
? { ...paymentCompany, logo_url: dataUrl }
: paymentCompany
return { branding, company: resolved }
}
/**
* Build the payment-link QR for an invoice as a PNG data URL, or null when the
* invoice carries no payment_link_url or it isn't a payable document (credit
* notes, proformas and delivery notes show no payment box). The URL was
* https-validated at write time (lib/api/schemas.ts); the QR simply encodes it
* locally with the `qrcode` lib: no call to any payment provider.
*/
export async function buildPaymentLinkQrDataUrl(invoice: Invoice): Promise<string | null> {
const url = invoice.payment_link_url?.trim()
if (!url) return null
const docType = invoice.document_type || 'invoice'
if (docType !== 'invoice' || invoice.credited_invoice_id) return null
try {
return await QRCode.toDataURL(url, { margin: 1, width: 240, errorCorrectionLevel: 'M' })
} catch (err) {
paymentLinkLog.warn('payment link QR generation failed', {
invoiceId: invoice.id,
error: err instanceof Error ? err.message : String(err),
})
return null
}
}
/**
* Build the Swish payment QR for an invoice as a PNG data URL, or null when:
* Swish display is off, the document isn't a payable invoice (credit notes,
* proformas and delivery notes collect no payment), there's no/invalid Swish
* number, the invoice isn't in SEK (Swish is SEK-only), or the amount to pay
* is not positive. The encoded amount is the customer-facing "Att betala"
* from getAmountToPay: the rounded total minus any ROT/RUT deduction, the
* same figure the PDF totals block and the invoice email state. The Swish payload locks the amount (editmask 0),
* so encoding anything else makes the customer overpay with no way to correct
* it in the app. A fully deducted invoice (toPay = 0) therefore renders no QR.
* Generated locally with the `qrcode` lib: no call to any Swish API. Pass the
* result to InvoicePDF's `swishQrDataUrl` prop; the template gates rendering
* on the same payment box that already shows the Swish number.
*/
export async function buildSwishQrDataUrl(
company: CompanySettings,
invoice: Invoice,
): Promise<string | null> {
// Swish on invoices is "coming soon": gated off in pdf-template. Bail before
// any work while the feature is disabled.
if (!SHOW_SWISH_ON_INVOICE) return null
// Swish display off is the normal "no QR" case: stay quiet. Every other
// skip is logged so a missing QR is diagnosable instead of silent.
if (!(company.invoice_show_swish ?? false)) return null
// Non-payable documents: the PDF hides the whole payment box for them, and
// a locked payment QR on a kreditfaktura (a refund document, "Er tillgodo")
// must stay impossible even if a template regression ever exposed the
// corner. Same gate as buildPaymentLinkQrDataUrl; quiet like display-off.
const docType = invoice.document_type || 'invoice'
if (docType !== 'invoice' || invoice.credited_invoice_id) return null
if ((invoice.currency ?? 'SEK') !== 'SEK') {
log.info('swish QR skipped: invoice not in SEK', { invoiceId: invoice.id, currency: invoice.currency })
return null
}
const amount = getAmountToPay(invoice, company).toPay
const payload = buildSwishQrPayload(company.swish, amount, invoice.invoice_number ?? '')
if (!payload) {
log.warn('swish QR skipped: invalid number or non-positive amount', {
invoiceId: invoice.id,
hasSwish: !!company.swish,
amount,
})
return null
}
try {
return await QRCode.toDataURL(payload, { margin: 1, width: 240, errorCorrectionLevel: 'M' })
} catch (err) {
log.warn('swish QR generation failed', {
invoiceId: invoice.id,
error: err instanceof Error ? err.message : String(err),
})
return null
}
}