Enforce MFA on critical mutation routes + post-audit foundation (A1) (#646)

* feat(lib): add canonical money + format + fetch primitives (audit Tier 0)

Foundation for post-audit cleanup: shared primitives so subsequent refactors import one helper instead of reinventing (the duplication the audit found).

- lib/money.ts: canonical roundOre/ORE_TOLERANCE (+ equalOre/isZeroOre/sumOre); lib/bokslut/rounding.ts re-exports for back-compat
- lib/utils.ts: formatAmount, formatWholeKr, formatDateTime
- lib/hooks/use-fetch.ts: generic client fetch hook (abort, bilingual errors, refetch)
- components/common/DataState.tsx: loading/error/empty wrapper over Skeleton/EmptyState
- messages: common.retry / common.load_error (sv+en)
- tests: 16 tests incl. the 1.005 half-ore case and locale-robust format assertions

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(guards): ratchet against new MFA-bypassing routes and naive ore-rounding

Adds scripts/checks/no-new-antipatterns.mjs + committed baseline. Fails CI only when a PR ADDS a route hand-rolling supabase.auth.getUser() (which skips MFA AAL2 enforcement) or a new Math.round(x*100)/100. Baseline: 178 raw-auth routes, 668 naive rounds — ratchets down as the A1 (route-auth) and D1 (rounding) migrations land. Wired into core-build.yml; green at baseline.

Note: scripts/ is gitignored (.gitignore:70 '/scripts') yet tracks 39 files via force-add; these two were force-added to match that existing pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api,errors): enforce MFA on journal-entry mutation routes via withRouteContext (A1)

Migrates the 4 journal-entry mutation routes (commit, correct, reverse, recordate) off hand-rolled supabase.auth.getUser() onto withRouteContext, which enforces MFA AAL2 (requireAuth) + non-viewer role (requireWrite) and routes thrown errors through the canonical errorResponse envelope. Fixes audit finding A1 for the most compliance-critical mutations and folds in C8 for these routes (drops bookkeepingErrorResponse; they now emit message_en).

Also fixes a latent bug: errorResponse()/extractBookkeepingDetails only handled 11 of 15 typed bookkeeping errors, so MeaninglessCorrection / NoOpenPeriodForDate / TargetPeriodClosed / TargetPeriodLocked silently degraded to a generic 500 (affecting existing v1 callers too). Adds the 4 missing registry codes + extract cases -> correct 400/409.

Behavior change: untyped engine throws now return the canonical 500 envelope instead of 400+raw-string; typed errors keep their status (verified against the registry). Tests updated to the realistic typed-error contract + a 403 write-gate test on commit. Updates .claude/rules/api-routes.md to prescribe withRouteContext. Ratchets the antipattern guard 178 -> 174. Full unit suite green (5023); tsc: no new errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): enforce MFA on salary run authorization routes via withRouteContext (A1)

Migrates the salary-run lifecycle write routes (approve, paid, revert) — the highest-PII A1 surface — off hand-rolled supabase.auth.getUser() onto withRouteContext (enforces MFA AAL2 + non-viewer role). Explicit { error } returns are preserved unchanged (passed through the wrapper); only auth changes, so no error-shape regression. Salary unit suite green (8). Ratchets the antipattern guard 174 -> 171.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* review: address PR #646 bot findings

- guard: match withRouteContext/requireAuth at the CALL site (withRouteContext[<(]), not a bare import — closes the false-negative greptile flagged. It surfaced app/api/sandbox/seed (hand-rolled getUser; the loose regex had matched a code comment). Switched that route to requireAuth() — the documented stopgap for routes that can't use withRouteContext (it runs before a company exists; anonymous users, so MFA is a no-op but the auth path is now consistent). Guard stays at 171.
- money.test: add the negative half-ore case roundOre(-1.005) === -1 to lock the rounding direction against regressions.
- use-fetch: document keep-previous-data + deferred-loading (effect-tick) semantics.
- structured-errors: drop the BFL 5 kap. 5 § citation from MEANINGLESS_CORRECTION per the swedish-compliance bot (5 § governs correction procedure, not the no-op precondition).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* review: enrich wrapper error logging + document sandbox GDPR controls (PR #646)

- with-route-context: log unhandled errors and route errorResponse through the resolved { userId, companyId } logger, not just { requestId, operation } — closes the OWASP V16 audit-trail finding for all 82+ routes using the wrapper. Documented in the JSDoc.
- sandbox/seed: document the GDPR Art.32 compensating controls for the anonymous write path (anonymous-only, /24 rate limit, synthetic demo data, own-company RLS scope). No functional change — the flagged behaviour is pre-existing by design; this records the reasoning inline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-03 15:34:58 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent c74b19df1b
commit 0b86901a2b
28 changed files with 1102 additions and 380 deletions
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest'
import { formatAmount, formatWholeKr, formatDateTime, formatDate } from '@/lib/utils'
// Intl sv-SE groups thousands with a non-breaking / narrow space (U+00A0 or
// U+202F depending on ICU version) and may render negatives with U+2212. Both
// vary across Node builds, so normalize them to plain ASCII before asserting —
// the test cares about format shape, not the exact whitespace codepoint.
const norm = (s: string) => s.replace(/\s/g, ' ').replace(/−/g, '-')
describe('formatAmount', () => {
it('renders two decimals with sv-SE grouping and no currency symbol', () => {
expect(norm(formatAmount(1234.5))).toBe('1 234,50')
expect(norm(formatAmount(0))).toBe('0,00')
expect(norm(formatAmount(-1234.56))).toBe('-1 234,56')
})
it('does not include "kr" or the SEK symbol', () => {
expect(formatAmount(100)).not.toMatch(/kr|SEK/)
})
})
describe('formatWholeKr', () => {
it('rounds to whole krona with grouping, no decimals', () => {
expect(norm(formatWholeKr(1234.56))).toBe('1 235')
expect(norm(formatWholeKr(999.4))).toBe('999')
expect(norm(formatWholeKr(0))).toBe('0')
})
})
describe('formatDateTime', () => {
it('renders ISO-ordered date and time', () => {
expect(formatDateTime('2026-05-11T14:30:00')).toBe('2026-05-11 14:30')
})
it('accepts a Date instance', () => {
expect(formatDateTime(new Date('2026-01-02T09:05:00'))).toBe('2026-01-02 09:05')
})
it('stays date-aligned with formatDate on the date portion', () => {
const iso = '2026-12-31T23:59:00'
expect(formatDateTime(iso).startsWith(formatDate(iso))).toBe(true)
})
})
+69
View File
@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest'
import { roundOre, ORE_TOLERANCE, equalOre, isZeroOre, sumOre } from '@/lib/money'
describe('roundOre', () => {
it('rounds exact-half öre values up where naive Math.round fails', () => {
// The whole reason this helper exists: 1.005 stored as 1.00499999… makes
// naive Math.round(x*100)/100 yield 1.00. roundOre must give 1.01.
expect(roundOre(1.005)).toBe(1.01)
expect(roundOre(2.675)).toBe(2.68)
expect(roundOre(0.615)).toBe(0.62)
})
it('leaves well-formed decimals untouched', () => {
expect(roundOre(1.234)).toBe(1.23)
expect(roundOre(1.235)).toBe(1.24)
expect(roundOre(100)).toBe(100)
expect(roundOre(1234.56)).toBe(1234.56)
})
it('preserves the sign of negative zero', () => {
expect(Object.is(roundOre(-0), -0)).toBe(true)
expect(roundOre(0)).toBe(0)
})
it('handles negative amounts', () => {
expect(roundOre(-1.234)).toBe(-1.23)
expect(roundOre(-99.999)).toBe(-100)
// The EPSILON nudge moves a stored negative value slightly toward zero, so
// an exact-half negative rounds toward +∞ (mirrors Math.round on negatives):
// -1.005 → -1.00, not -1.01. Documented so a refactor can't silently flip it.
expect(roundOre(-1.005)).toBe(-1)
})
})
describe('ORE_TOLERANCE / equalOre / isZeroOre', () => {
it('is half an öre', () => {
expect(ORE_TOLERANCE).toBe(0.005)
})
it('treats sub-öre float drift as equal', () => {
expect(equalOre(0.1 + 0.2, 0.3)).toBe(true) // classic 0.30000000000000004
expect(equalOre(100.001, 100.0)).toBe(true)
})
it('flags a real one-öre discrepancy as not equal', () => {
expect(equalOre(100.01, 100.0)).toBe(false)
})
it('isZeroOre absorbs drift around zero', () => {
expect(isZeroOre(0.1 + 0.2 - 0.3)).toBe(true)
expect(isZeroOre(0.01)).toBe(false)
})
})
describe('sumOre', () => {
it('sums then rounds once', () => {
expect(sumOre([0.1, 0.2])).toBe(0.3)
expect(sumOre([1.005, 1.005])).toBe(2.01)
expect(sumOre([])).toBe(0)
})
})
describe('lib/bokslut/rounding back-compat re-export', () => {
it('exposes the same roundOre/ORE_TOLERANCE from the legacy path', async () => {
const legacy = await import('@/lib/bokslut/rounding')
expect(legacy.roundOre(1.005)).toBe(1.01)
expect(legacy.ORE_TOLERANCE).toBe(ORE_TOLERANCE)
})
})
+10 -3
View File
@@ -6,7 +6,8 @@
* - emits one structured `info` log on completion with duration
* - converts any thrown value into the canonical error envelope via
* errorResponse(); the request id appears in the response body and the
* X-Request-Id response header
* X-Request-Id response header. Unhandled errors are logged ("op failed")
* with the resolved { requestId, operation, userId, companyId } context.
*
* Usage:
* export const POST = withRouteContext('invoice.send', async (req, ctx) => {
@@ -95,6 +96,10 @@ export function withRouteContext<P extends DynamicParams = { params: Promise<Rec
const requestId = generateRequestId()
const start = Date.now()
const log = createLogger(`api/${operation}`, { requestId, operation })
// Upgraded as request context resolves, so an unhandled throw in the catch
// below is logged with the richest available { userId, companyId } context
// (audit trail / OWASP V16), not just { requestId, operation }.
let errLog = log
try {
const auth = await requireAuth()
@@ -111,6 +116,7 @@ export function withRouteContext<P extends DynamicParams = { params: Promise<Rec
const { user, supabase } = auth
const userLog = log.child({ userId: user.id })
errLog = userLog
let companyId: string | null = null
try {
@@ -144,6 +150,7 @@ export function withRouteContext<P extends DynamicParams = { params: Promise<Rec
supabase,
companyId,
}
errLog = ctx.log
const response = await handler(request, ctx, params)
@@ -157,8 +164,8 @@ export function withRouteContext<P extends DynamicParams = { params: Promise<Rec
})
return response
} catch (err) {
log.error('op failed', err as Error, { durationMs: Date.now() - start })
return errorResponse(err, log, { requestId })
errLog.error('op failed', err as Error, { durationMs: Date.now() - start })
return errorResponse(err, errLog, { requestId })
}
}
}
+8 -41
View File
@@ -1,45 +1,12 @@
/**
* Centralized öre-precision rounding for bokslut and continuity logic.
* Öre-precision rounding for bokslut and continuity logic.
*
* Swedish öresavrundning was abolished in 2010, but our journal entries
* still store amounts in hundredths of SEK. Floating-point arithmetic
* accumulates IEEE 754 drift, so all monetary calculations must funnel
* through `roundOre()` before being compared, summed across rows, or
* persisted as journal_entry_lines.
* @deprecated The canonical home for these primitives is `@/lib/money`. This
* module re-exports them for back-compat; import from `@/lib/money` in new code.
*
* Per CLAUDE.md accounting guard rail #9: never use `.toFixed()` for money.
* Previously `continuity-check.ts` used 0.01 as its comparison threshold. That
* extra slack absorbed drift from chained Math.round calls, but with all
* rounding now centralized through `roundOre()` the half-öre `ORE_TOLERANCE`
* (0.005) is correct and tighter — a one-öre real discrepancy must surface.
*/
/**
* Round a SEK amount to the nearest öre (two decimal places).
*
* Naive `Math.round(x * 100) / 100` fails on exact-half values like 1.005
* because IEEE-754 stores 1.005 as 1.00499999…, so multiplying by 100
* yields 100.49999… and Math.round drops it to 100 instead of 101.
*
* The Number.EPSILON nudge bridges the IEEE gap for double-precision
* values near unit magnitude — large enough to push 100.49999… across
* the half-integer boundary, small enough to leave well-formed decimals
* (1.234, 1.235, etc.) untouched. Zero is special-cased so negative-zero
* inputs preserve their sign through the round trip.
*/
export function roundOre(n: number): number {
if (n === 0) return n
return Math.round((n + Number.EPSILON) * 100) / 100
}
/**
* Tolerance for comparing two öre-rounded amounts.
*
* Half an öre is the strictest meaningful threshold: any difference
* larger than this represents a real one-öre discrepancy, not float
* drift. Use for invariant assertions on closing entries, IB/UB
* continuity per-account, and balance-sheet equality checks.
*
* Note: previously `continuity-check.ts` used 0.01 as its threshold.
* That extra slack was meant to absorb drift from chained Math.round
* calls, but with all rounding now centralized through `roundOre()`
* the half-öre threshold is correct and tighter — a one-öre real
* discrepancy must always surface.
*/
export const ORE_TOLERANCE = 0.005
export { roundOre, ORE_TOLERANCE } from '@/lib/money'
+16
View File
@@ -34,6 +34,10 @@ import {
JournalEntryNotBalancedError,
JournalEntryNotFoundError,
CurrencyRevaluationAlreadyExistsError,
MeaninglessCorrectionError,
NoOpenPeriodForDateError,
TargetPeriodClosedError,
TargetPeriodLockedError,
isBookkeepingError,
} from '../bookkeeping/errors'
@@ -348,6 +352,18 @@ function extractBookkeepingDetails(err: unknown): { code: string; details?: unkn
details: { debitAccount: err.debitAccount, creditAccount: err.creditAccount },
}
}
if (err instanceof MeaninglessCorrectionError) {
return { code: err.code, details: { reason: err.reason } }
}
if (err instanceof NoOpenPeriodForDateError) {
return { code: err.code, details: { date: err.date } }
}
if (err instanceof TargetPeriodClosedError) {
return { code: err.code, details: { date: err.date } }
}
if (err instanceof TargetPeriodLockedError) {
return { code: err.code, details: { date: err.date, lockDate: err.lockDate } }
}
if (err instanceof BookkeepingDatabaseError) {
return { code: err.code, details: { operation: err.operation } }
}
+31
View File
@@ -197,6 +197,37 @@ const BOOKKEEPING: Record<string, StructuredErrorEntry> = {
message_en: 'Bookkeeping database operation failed.',
retryable: true,
},
MEANINGLESS_CORRECTION: {
httpStatus: 400,
message_sv: 'Rättelsen motsvarar ingen ekonomisk händelse — det finns inget att rätta.',
message_en: 'The correction represents no economic event — nothing to correct.',
},
NO_OPEN_PERIOD_FOR_DATE: {
httpStatus: 400,
message_sv:
'Det finns ingen räkenskapsperiod som täcker det valda datumet. Skapa eller öppna räkenskapsåret först.',
message_en: 'No fiscal period covers the selected date.',
remediation: {
description: 'Create or open the fiscal year that covers the date before retrying.',
resource: 'Accounted://period/active',
},
},
TARGET_PERIOD_CLOSED: {
httpStatus: 409,
message_sv:
'Räkenskapsåret som täcker datumet är stängt (bokslut) och kan inte öppnas. Bokför i en öppen period i stället.',
message_en: 'The fiscal year covering the date is closed and cannot be reopened.',
},
TARGET_PERIOD_LOCKED: {
httpStatus: 409,
message_sv: 'Räkenskapsperioden som täcker datumet är låst.',
message_en: 'The fiscal period covering the date is locked.',
remediation: {
description:
'Unlock the period (if status is "locked", not "closed") or use a date inside an open period.',
tool: 'gnubok_unlock_period',
},
},
PERIOD_LOCKED: {
httpStatus: 400,
message_sv: 'Bokföringen är låst för denna period.',
+112
View File
@@ -0,0 +1,112 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useLocale } from 'next-intl'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
/**
* Canonical client data-fetching hook.
*
* Replaces the hand-rolled `useState(loading)` + `useState(error)` + `useEffect`
* + bare `fetch()` block repeated across ~85 components. Gives every caller the
* same behaviour for free:
*
* - cancels the in-flight request on unmount / url change (AbortController), so
* a slow response can't land after the component moved on (no stale state,
* no "set state on unmounted component" races);
* - routes errors through the bilingual `getErrorMessage()` using the active
* UI locale, so error copy is consistent and localized;
* - exposes `refetch()` for retry / post-mutation refresh.
*
* Behaviour notes (intentional):
* - `data` is NOT cleared on `refetch()` or url change — it keeps the previous
* result while the new request is in flight (keep-previous-data), so lists
* don't blank out on refresh. Read `loading` to show a pending indicator.
* - When `url`/`enabled` start inactive and later become active, `loading`
* flips true on the effect tick, not synchronously on the activating render.
* Pair with `DataState` (which branches on `loading` first) to avoid a flash.
*
* Response convention: the JSON body is returned as-is, typed as `T`. Most
* Accounted routes wrap payloads as `{ data: ... }`, so the common usage is
* `useFetch<{ data: Account[] }>(...)` then read `result.data?.data`. Pass
* `select` to unwrap/transform at the hook boundary instead.
*
* @example
* const { data, loading, error, refetch } = useFetch<Account[]>(
* '/api/bookkeeping/accounts',
* { select: (body) => body.data ?? [] },
* )
*/
export interface UseFetchOptions<T, R> {
/** Skip the request until true (e.g. waiting on a dependency). Default true. */
enabled?: boolean
/** Transform/unwrap the parsed JSON body before it reaches `data`. */
select?: (body: T) => R
/** Extra `fetch` init (headers, etc.). The AbortController signal is merged in. */
init?: Omit<RequestInit, 'signal'>
}
export interface UseFetchResult<R> {
data: R | null
loading: boolean
error: string | null
/** Re-run the request. Safe to call from event handlers. */
refetch: () => void
}
export function useFetch<T = unknown, R = T>(
url: string | null,
options: UseFetchOptions<T, R> = {},
): UseFetchResult<R> {
const { enabled = true, select, init } = options
const locale = useLocale() as ErrorLocale
// Keep select/init out of the effect deps without re-running on every render.
const selectRef = useRef(select)
selectRef.current = select
const initRef = useRef(init)
initRef.current = init
const active = enabled && url != null
const [data, setData] = useState<R | null>(null)
const [loading, setLoading] = useState<boolean>(active)
const [error, setError] = useState<string | null>(null)
const [nonce, setNonce] = useState(0)
const refetch = useCallback(() => setNonce((n) => n + 1), [])
useEffect(() => {
if (!active || url == null) {
setLoading(false)
return
}
const controller = new AbortController()
setLoading(true)
setError(null)
;(async () => {
try {
const res = await fetch(url, { ...initRef.current, signal: controller.signal })
const body = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(
getErrorMessage(body ?? { error: res.statusText }, { locale, statusCode: res.status }),
)
}
if (controller.signal.aborted) return
const transform = selectRef.current
setData((transform ? transform(body as T) : (body as unknown as R)))
} catch (err) {
if (controller.signal.aborted || (err as Error)?.name === 'AbortError') return
setError(getErrorMessage(err, { locale }))
} finally {
if (!controller.signal.aborted) setLoading(false)
}
})()
return () => controller.abort()
}, [url, active, nonce, locale])
return { data, loading, error, refetch }
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Canonical money primitives for Accounted.
*
* Swedish öresavrundning was abolished in 2010, but our journal entries still
* store amounts in hundredths of SEK. Floating-point arithmetic accumulates
* IEEE 754 drift, so all monetary calculations must funnel through `roundOre()`
* before being compared, summed across rows, or persisted as
* journal_entry_lines.
*
* Per CLAUDE.md accounting guard rail #9: never use `.toFixed()` for money, and
* never hand-roll `Math.round(x * 100) / 100` — that naive form is subtly wrong
* (see `roundOre` below). Import these helpers instead.
*
* This module is the single source of truth. `lib/bokslut/rounding.ts`
* re-exports `roundOre`/`ORE_TOLERANCE` from here for back-compat; new code
* should import from `@/lib/money`.
*/
/**
* Round a SEK amount to the nearest öre (two decimal places).
*
* Naive `Math.round(x * 100) / 100` fails on exact-half values like 1.005
* because IEEE-754 stores 1.005 as 1.00499999…, so multiplying by 100 yields
* 100.49999… and Math.round drops it to 100 instead of 101.
*
* The Number.EPSILON nudge bridges the IEEE gap for double-precision values
* near unit magnitude — large enough to push 100.49999… across the half-integer
* boundary, small enough to leave well-formed decimals (1.234, 1.235, etc.)
* untouched. Zero is special-cased so negative-zero inputs preserve their sign
* through the round trip.
*/
export function roundOre(n: number): number {
if (n === 0) return n
return Math.round((n + Number.EPSILON) * 100) / 100
}
/**
* Tolerance for comparing two öre-rounded amounts.
*
* Half an öre is the strictest meaningful threshold: any difference larger than
* this represents a real one-öre discrepancy, not float drift. Use for
* invariant assertions on closing entries, IB/UB continuity per-account, and
* balance-sheet equality checks.
*/
export const ORE_TOLERANCE = 0.005
/**
* True when two amounts are equal to the öre (within `ORE_TOLERANCE`). Prefer
* this over `a === b` for money — direct equality on floats fails on drift.
*/
export function equalOre(a: number, b: number): boolean {
return Math.abs(a - b) <= ORE_TOLERANCE
}
/**
* True when `n` is zero to the öre. Useful for "fully settled / balances"
* checks where accumulated float drift would defeat `n === 0`.
*/
export function isZeroOre(n: number): boolean {
return Math.abs(n) <= ORE_TOLERANCE
}
/**
* Sum a list of SEK amounts with a single öre-round applied to the total.
*
* Rounding once at the end (rather than per addend) matches how a verifikat is
* totalled and avoids compounding half-öre rounding across many lines.
*/
export function sumOre(values: readonly number[]): number {
return roundOre(values.reduce((acc, v) => acc + v, 0))
}
+42
View File
@@ -24,6 +24,48 @@ export function formatDate(date: Date | string): string {
return formatDateFns(d, 'yyyy-MM-dd')
}
/**
* Date + time for audit / metadata displays: `2026-05-11 14:30`. ISO-ordered
* and locale-independent (sortable, unambiguous), matching `formatDate`'s
* accounting convention. Use for "created at" / "last synced" timestamps. For
* date-only accounting values use `formatDate`; for friendly long-form metadata
* dates use `formatDateLong`.
*/
export function formatDateTime(date: Date | string): string {
const d = typeof date === 'string' ? parseISO(date) : date
return formatDateFns(d, 'yyyy-MM-dd HH:mm')
}
/**
* Bare amount with sv-SE grouping and exactly two decimals, no currency symbol:
* `1234.5` → `1 234,50`. Use in table cells / inputs where the column header or
* surrounding context already conveys "kr" and `formatCurrency`'s symbol would
* be noise. Stays sv-SE in both locales (Swedish accounting convention, not a
* UI string) — same rule as `formatCurrency`. When you need the SEK symbol, use
* `formatCurrency`.
*/
export function formatAmount(amount: number): string {
return new Intl.NumberFormat('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
}
/**
* Whole-krona amount, no decimals, sv-SE grouping: `1234.56` → `1 235`. For
* compact KPI tiles and rounded summaries.
*
* NOTE: not for statutory output. INK2 / NE-bilaga / SRU require *truncation*
* (`Math.trunc`) per SFL 22:1, not rounding — use the dedicated SRU formatter
* for those surfaces.
*/
export function formatWholeKr(amount: number): string {
return new Intl.NumberFormat('sv-SE', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount)
}
/**
* Long-form date for metadata/audit contexts (e.g. "9 maj 2026" / "May 9, 2026").
* Use formatDate for transaction/voucher/invoice dates that need to align in tables.