diff --git a/DECISIONS.md b/DECISIONS.md index 0bdf096c..676dfb45 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1320,6 +1320,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-27] Invite-only brand signup ships accepting a low-severity allowlist enumeration residual: POST /api/auth/signup returns 403 for a non-allowlisted email vs 200/400 for an allowlisted one, and the 403 short-circuits before GoTrue, so it is captcha-free and unthrottled: someone with candidate emails can test which are on a brand's allowlist. Not closed because (a) the app deliberately never holds the Turnstile secret (it lives in Supabase/GoTrue; a repo test forbids TURNSTILE_SECRET_KEY in app env), and (b) the clear "you're not invited, go to Accounted" redirect UX inherently reveals the verdict. It leaks membership of guessed emails, not the list, and no ledger/credential data. Follow-up option if it matters later: add signup-endpoint rate limiting. The related fail-OPEN (a brands-table error was read as unbranded, opening invite-only signup during a DB blip) WAS fixed: the gate now returns lookupFailed and both signup routes answer 503. [2026-08-27] Byrå-team invite acceptance was implemented only in POST /api/team/accept, which the email+password signup flow never reaches before the dashboard (hosted requires email confirmation, so the register page gets no session to run its client-side accept, and the auth callback + onboarding recovery only knew company_invitations). A new byrå admin therefore landed on /onboarding instead of /clients. Fix: one shared server helper acceptPendingTeamInviteByToken (lib/company/pending-invites.ts), called by the route (unchanged HTTP contract), the auth callback (accepts BEFORE landing resolves, so resolveLandingDestination sees the membership and sends admins to /clients; cookie cleared on success), and acceptPendingInviteByToken (onboarding/select-company recovery, tries company then team). hasPendingInviteForEmail now checks both invite tables. No migration. [2026-08-28] Per-company hiding of standardmallar via new booking_template_hidden table (insert=hide, delete=unhide), not is_active or a library column: system template rows are shared globally, so per-company state must live beside them; hiding is opt-in per company and restorable in settings (user request). +[2026-08-28] AR-PDF minus fix uses ASCII hyphen formatting, not font embedding: registering a Unicode TTF for react-pdf would change the whole document's typography and bundle size to fix one glyph; formatPdfKronor keeps built-in Helvetica and sidesteps WinAnsi's missing U+2212. [2026-08-28] Same-bank warning limited to observed one-session banks (SEB only): prod shows Handelsbanken tolerates 4 concurrent sessions, and the generic warning made a user abandon a legitimate renewal. Planned sync-death visibility work was dropped: already shipped via #1271 (health probe), #1727 (stale state), #1969 (cron unstarve). [2026-08-28] Same-bank warning revised to three tiers after skeptic refutation: hard warn SEB, silent/calm only for verified multi-session banks (Handelsbanken, 4 distinct session_ids observed), legacy hedged warning for unknown banks (fail closed), shared-session siblings exempt (fan-out carries them). [2026-08-28] /migrate SIE guard extended to every provider (Fortnox exemption removed) as "a completed SIE import must exist for the company", not "must be part of this run", plus a wizard hint that disables Start when SIE is unchecked and never imported; chose this over forcing the checkbox on because the route is the only seam a direct API call or a stale client cannot bypass, and "must exist" keeps entities-only re-runs after a full migration working (#2000). diff --git a/lib/bokslut/arsredovisning/__tests__/pdf-format.test.ts b/lib/bokslut/arsredovisning/__tests__/pdf-format.test.ts new file mode 100644 index 00000000..57c99122 --- /dev/null +++ b/lib/bokslut/arsredovisning/__tests__/pdf-format.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { formatPdfKronor } from '../pdf-format' + +// Build the special characters from code points so the assertions cannot be +// corrupted by editor/encoding round-trips: U+00A0 is the sv-SE thousands +// separator, U+2212 is the locale minus that WinAnsi Helvetica cannot render. +const NBSP = String.fromCharCode(0x00a0) +const U2212 = String.fromCharCode(0x2212) + +describe('formatPdfKronor', () => { + it('formats a loss with an ASCII hyphen-minus, never U+2212', () => { + const out = formatPdfKronor(-4684.24) + expect(out).toBe(`-4${NBSP}684`) + expect(out).not.toContain(U2212) + }) + + it('keeps sv-SE thousands grouping for positive amounts', () => { + expect(formatPdfKronor(1234567.89)).toBe(`1${NBSP}234${NBSP}568`) + }) + + it('pins the group separator to U+00A0 regardless of the ICU CLDR choice', () => { + // Newer CLDR data groups sv-SE with U+202F NARROW NO-BREAK SPACE, which + // is also missing from WinAnsi; the formatter must normalize to U+00A0. + const U202F = String.fromCharCode(0x202f) + const out = formatPdfKronor(-9876543) + expect(out).toBe(`-9${NBSP}876${NBSP}543`) + expect(out).not.toContain(U202F) + }) + + it('rounds to whole kronor and never renders "-0"', () => { + expect(formatPdfKronor(-0.4)).toBe('0') + expect(formatPdfKronor(-0.6)).toBe('-1') + expect(formatPdfKronor(0)).toBe('0') + }) + + it('always uses ASCII hyphen for negative amounts', () => { + for (const n of [-1, -999, -1000, -4684.24, -1_000_000.5]) { + const out = formatPdfKronor(n) + expect(out).not.toContain(U2212) + expect(out.startsWith('-')).toBe(true) + } + }) +}) diff --git a/lib/bokslut/arsredovisning/anlaggningstillgangar-note.ts b/lib/bokslut/arsredovisning/anlaggningstillgangar-note.ts index 788c3341..7c78df97 100644 --- a/lib/bokslut/arsredovisning/anlaggningstillgangar-note.ts +++ b/lib/bokslut/arsredovisning/anlaggningstillgangar-note.ts @@ -24,6 +24,7 @@ import type { NoteEntry } from './types' import type { AssetDepreciationFigures } from './asset-note-figures' +import { formatPdfKronor } from './pdf-format' export interface AnlaggningAsset { category: string @@ -161,7 +162,9 @@ export function computeRollforwardTotals( } } -const fmt = (n: number) => Math.round(n).toLocaleString('sv-SE') +// ASCII hyphen for negatives: the note body renders in the ÅR PDF with +// Helvetica/WinAnsi, which has no U+2212 glyph. +const fmt = (n: number) => formatPdfKronor(n) /** * Build the anläggningstillgångar roll-forward note. Returns null when no diff --git a/lib/bokslut/arsredovisning/arsredovisning-k3-pdf.tsx b/lib/bokslut/arsredovisning/arsredovisning-k3-pdf.tsx index 07b5006b..0b12636d 100644 --- a/lib/bokslut/arsredovisning/arsredovisning-k3-pdf.tsx +++ b/lib/bokslut/arsredovisning/arsredovisning-k3-pdf.tsx @@ -1,5 +1,6 @@ import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer' import type { ArsredovisningData, StatementRow } from './types' +import { formatPdfKronor } from './pdf-format' /** * K3 årsredovisning PDF template (BFNAR 2012:1). @@ -160,7 +161,8 @@ const styles = StyleSheet.create({ }) function fmt(amount: number): string { - return Math.round(amount).toLocaleString('sv-SE') + // ASCII hyphen for negatives: Helvetica/WinAnsi has no U+2212 glyph. + return formatPdfKronor(amount) } /** diff --git a/lib/bokslut/arsredovisning/arsredovisning-pdf.tsx b/lib/bokslut/arsredovisning/arsredovisning-pdf.tsx index ee114596..d838e3fa 100644 --- a/lib/bokslut/arsredovisning/arsredovisning-pdf.tsx +++ b/lib/bokslut/arsredovisning/arsredovisning-pdf.tsx @@ -1,5 +1,6 @@ import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer' import type { ArsredovisningData, StatementRow } from './types' +import { formatPdfKronor } from './pdf-format' const styles = StyleSheet.create({ page: { @@ -100,7 +101,8 @@ const styles = StyleSheet.create({ function fmt(amount: number): string { // sv-SE thousands grouping, no decimals: typical for K2 ÅR. - return Math.round(amount).toLocaleString('sv-SE') + // ASCII hyphen for negatives: Helvetica/WinAnsi has no U+2212 glyph. + return formatPdfKronor(amount) } /** diff --git a/lib/bokslut/arsredovisning/k3-noter-builder.ts b/lib/bokslut/arsredovisning/k3-noter-builder.ts index 541f1e3d..621c9af3 100644 --- a/lib/bokslut/arsredovisning/k3-noter-builder.ts +++ b/lib/bokslut/arsredovisning/k3-noter-builder.ts @@ -2,6 +2,7 @@ import type { EgenKapitalRow, NoteEntry, } from './types' +import { formatPdfKronor } from './pdf-format' /** * K3 noter builder (BFNAR 2012:1). @@ -161,8 +162,8 @@ export function buildUppskjutenSkattNot(params: { const { noteNumber, latentTaxOpening, latentTaxChange, latentTaxClosing } = params // sv-SE thousand separator, no decimals: typical for ÅR notes. - const fmt = (n: number) => - Math.round(n).toLocaleString('sv-SE') + // ASCII hyphen for negatives: PDF Helvetica/WinAnsi has no U+2212 glyph. + const fmt = (n: number) => formatPdfKronor(n) const lines: string[] = [ 'Posten avser uppskjuten skatteskuld redovisad på konto 2240.', '', @@ -367,7 +368,7 @@ export function buildMateriellaAnlaggningsNot(params: { } // If any asset has components, render a per-component breakdown per asset. - const fmt = (n: number) => Math.round(n).toLocaleString('sv-SE') + const fmt = (n: number) => formatPdfKronor(n) const withComponents = active.filter((a) => isComponentArray(a.k3_components)) if (withComponents.length > 0) { linesOut.push('', 'Komponentuppdelning per tillgång:') diff --git a/lib/bokslut/arsredovisning/pdf-format.ts b/lib/bokslut/arsredovisning/pdf-format.ts new file mode 100644 index 00000000..7de4766a --- /dev/null +++ b/lib/bokslut/arsredovisning/pdf-format.ts @@ -0,0 +1,23 @@ +/** + * Whole-krona amount formatting for text that react-pdf renders with the + * built-in Helvetica font (the K2/K3 arsredovisning PDFs and their notes). + * + * `toLocaleString('sv-SE')` formats negative numbers with U+2212 MINUS SIGN, + * which WinAnsi-encoded standard fonts cannot encode; react-pdf drops the + * glyph silently, so a loss like -4 684 printed as "−4 684" loses its + * sign and reads as a profit. Format the absolute value and prepend an ASCII + * hyphen-minus instead. The sv-SE thousands separator (U+00A0 NBSP) is in + * WinAnsi and renders fine. + */ +const NBSP = String.fromCharCode(0x00a0) + +export function formatPdfKronor(amount: number): string { + const rounded = Math.round(amount) + // Pin the group separator to U+00A0: depending on the Node/ICU CLDR + // version, sv-SE groups with U+00A0 or U+202F NARROW NO-BREAK SPACE, and + // U+202F is missing from WinAnsi too (digits would silently run together). + const grouped = Math.abs(rounded) + .toLocaleString('sv-SE') + .replace(/\s/g, NBSP) + return rounded < 0 ? `-${grouped}` : grouped +}