(url, { select: b => b.data })
+ * return (
+ * }
+ * >
+ *
+ *
+ * )
+ *
+ * Loading uses the `Skeleton` primitive; empty expects an `EmptyState`; the
+ * error branch uses the only chrome-permitted semantic colour (`destructive`).
+ */
+export function DataState({
+ loading,
+ error,
+ isEmpty = false,
+ onRetry,
+ skeleton,
+ empty,
+ children,
+ className,
+}: DataStateProps) {
+ const t = useTranslations('common')
+
+ if (loading) {
+ return (
+
+ {skeleton ?? (
+
+
+
+
+
+ )}
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
{t('load_error')}
+
{error}
+ {onRetry && (
+
+ )}
+
+ )
+ }
+
+ if (isEmpty) {
+ return {empty}
+ }
+
+ return <>{children}>
+}
diff --git a/lib/__tests__/format.test.ts b/lib/__tests__/format.test.ts
new file mode 100644
index 00000000..260e50f8
--- /dev/null
+++ b/lib/__tests__/format.test.ts
@@ -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)
+ })
+})
diff --git a/lib/__tests__/money.test.ts b/lib/__tests__/money.test.ts
new file mode 100644
index 00000000..d7464a62
--- /dev/null
+++ b/lib/__tests__/money.test.ts
@@ -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)
+ })
+})
diff --git a/lib/api/with-route-context.ts b/lib/api/with-route-context.ts
index 95d99c43..abfc3e48 100644
--- a/lib/api/with-route-context.ts
+++ b/lib/api/with-route-context.ts
@@ -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 = {
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.',
diff --git a/lib/hooks/use-fetch.ts b/lib/hooks/use-fetch.ts
new file mode 100644
index 00000000..48b1aeba
--- /dev/null
+++ b/lib/hooks/use-fetch.ts
@@ -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(
+ * '/api/bookkeeping/accounts',
+ * { select: (body) => body.data ?? [] },
+ * )
+ */
+export interface UseFetchOptions {
+ /** 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
+}
+
+export interface UseFetchResult {
+ data: R | null
+ loading: boolean
+ error: string | null
+ /** Re-run the request. Safe to call from event handlers. */
+ refetch: () => void
+}
+
+export function useFetch(
+ url: string | null,
+ options: UseFetchOptions = {},
+): UseFetchResult {
+ 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(null)
+ const [loading, setLoading] = useState(active)
+ const [error, setError] = useState(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 }
+}
diff --git a/lib/money.ts b/lib/money.ts
new file mode 100644
index 00000000..32bc14b5
--- /dev/null
+++ b/lib/money.ts
@@ -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))
+}
diff --git a/lib/utils.ts b/lib/utils.ts
index c0bf535a..7caae269 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -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.
diff --git a/messages/en.json b/messages/en.json
index 934fded8..dc6fab82 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -14,6 +14,8 @@
"search": "Search",
"filter": "Filter",
"loading": "Loading...",
+ "retry": "Try again",
+ "load_error": "Could not load data",
"confirm": "Confirm",
"yes": "Yes",
"no": "No",
diff --git a/messages/sv.json b/messages/sv.json
index e4915c75..6069a9f6 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -14,6 +14,8 @@
"search": "Sök",
"filter": "Filtrera",
"loading": "Laddar...",
+ "retry": "Försök igen",
+ "load_error": "Kunde inte ladda data",
"confirm": "Bekräfta",
"yes": "Ja",
"no": "Nej",
diff --git a/package.json b/package.json
index 203e2534..e3d1aa00 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
+ "check:guards": "node scripts/checks/no-new-antipatterns.mjs",
"test": "vitest run --project unit",
"test:pg": "vitest run --project pg-real"
},
diff --git a/scripts/checks/antipatterns-baseline.json b/scripts/checks/antipatterns-baseline.json
new file mode 100644
index 00000000..21a1f88a
--- /dev/null
+++ b/scripts/checks/antipatterns-baseline.json
@@ -0,0 +1,182 @@
+{
+ "_comment": "Ratchet baseline for scripts/checks/no-new-antipatterns.mjs. These counts may only decrease. Re-run with --update after a migration lowers them. Goal: both reach 0 (A1 route-auth campaign, D1 rounding codemod).",
+ "rawRouteAuth": {
+ "count": 171,
+ "files": [
+ "app/api/account/delete/route.ts",
+ "app/api/account/password/route.ts",
+ "app/api/agent/composer/route.ts",
+ "app/api/agent/conversations/[id]/route.ts",
+ "app/api/agent/conversations/route.ts",
+ "app/api/agent/invoke/route.ts",
+ "app/api/agent/memory/[id]/route.ts",
+ "app/api/agent/memory/route.ts",
+ "app/api/agent/onboarding/stream/route.ts",
+ "app/api/agent/profile/route.ts",
+ "app/api/agent/profile/verify/route.ts",
+ "app/api/agent/skills/route.ts",
+ "app/api/audit-trail/route.ts",
+ "app/api/bookkeeping/account-balances/route.ts",
+ "app/api/bookkeeping/account-totals/route.ts",
+ "app/api/bookkeeping/accounts/[number]/route.ts",
+ "app/api/bookkeeping/accounts/activate/route.ts",
+ "app/api/bookkeeping/accounts/bas-lookup/route.ts",
+ "app/api/bookkeeping/accounts/reference/route.ts",
+ "app/api/bookkeeping/accounts/route.ts",
+ "app/api/bookkeeping/fiscal-periods/[id]/close/route.ts",
+ "app/api/bookkeeping/fiscal-periods/[id]/entry-count/route.ts",
+ "app/api/bookkeeping/fiscal-periods/[id]/route.ts",
+ "app/api/bookkeeping/fiscal-periods/period-status/route.ts",
+ "app/api/bookkeeping/fiscal-periods/route.ts",
+ "app/api/bookkeeping/journal-entries/[id]/chain/route.ts",
+ "app/api/bookkeeping/journal-entries/[id]/no-document-required/route.ts",
+ "app/api/bookkeeping/journal-entries/[id]/notes/route.ts",
+ "app/api/bookkeeping/journal-entries/[id]/route.ts",
+ "app/api/bookkeeping/journal-entries/route.ts",
+ "app/api/bookkeeping/mapping-rules/evaluate/route.ts",
+ "app/api/bookkeeping/mapping-rules/route.ts",
+ "app/api/bookkeeping/no-doc-required/route.ts",
+ "app/api/bookkeeping/voucher-gaps/route.ts",
+ "app/api/calendar/feed/route.ts",
+ "app/api/cash-accounts/route.ts",
+ "app/api/company/check-org-number/route.ts",
+ "app/api/company/current/route.ts",
+ "app/api/company/members/[id]/route.ts",
+ "app/api/company/members/invite/[id]/route.ts",
+ "app/api/company/members/invite/route.ts",
+ "app/api/company/members/route.ts",
+ "app/api/company/route.ts",
+ "app/api/currency/rate/route.ts",
+ "app/api/deadlines/[id]/complete/route.ts",
+ "app/api/deadlines/[id]/route.ts",
+ "app/api/deadlines/[id]/status/route.ts",
+ "app/api/deadlines/route.ts",
+ "app/api/documents/[id]/extraction-status/route.ts",
+ "app/api/documents/[id]/route.ts",
+ "app/api/documents/[id]/verify/route.ts",
+ "app/api/documents/[id]/versions/route.ts",
+ "app/api/documents/counts/route.ts",
+ "app/api/events/route.ts",
+ "app/api/extensions/[sector]/[slug]/data/route.ts",
+ "app/api/extensions/[sector]/[slug]/settings/route.ts",
+ "app/api/extensions/ext/[...path]/route.ts",
+ "app/api/extensions/skatteverket/skattekonto/drift/route.ts",
+ "app/api/import/sie/[id]/route.ts",
+ "app/api/import/sie/create-accounts/route.ts",
+ "app/api/import/sie/mappings/route.ts",
+ "app/api/import/sie/route.ts",
+ "app/api/invoices/[id]/convert/route.ts",
+ "app/api/invoices/[id]/mark-sent/route.ts",
+ "app/api/invoices/[id]/pdf/route.ts",
+ "app/api/invoices/[id]/route.ts",
+ "app/api/invoices/preview-pdf/route.ts",
+ "app/api/kpi/preferences/route.ts",
+ "app/api/mcp-oauth/authorize/route.ts",
+ "app/api/pending-operations/[id]/commit/route.ts",
+ "app/api/pending-operations/[id]/reject/route.ts",
+ "app/api/pending-operations/[id]/route.ts",
+ "app/api/pending-operations/bulk-commit/route.ts",
+ "app/api/pending-operations/route.ts",
+ "app/api/reconciliation/bank/link/route.ts",
+ "app/api/reconciliation/bank/mark-opening-balance/route.ts",
+ "app/api/reconciliation/bank/run/route.ts",
+ "app/api/reconciliation/bank/status/route.ts",
+ "app/api/reconciliation/bank/unlink/route.ts",
+ "app/api/reconciliation/bank/unmatched-entries/route.ts",
+ "app/api/reports/ar-ledger/customer/[customerId]/invoices/route.ts",
+ "app/api/reports/ar-ledger/route.ts",
+ "app/api/reports/ar-ledger/xlsx/route.ts",
+ "app/api/reports/audit-trail/route.ts",
+ "app/api/reports/avgifter-basis/route.ts",
+ "app/api/reports/balance-sheet/pdf/route.ts",
+ "app/api/reports/balance-sheet/xlsx/route.ts",
+ "app/api/reports/balansrapport/pdf/route.ts",
+ "app/api/reports/balansrapport/route.ts",
+ "app/api/reports/balansrapport/xlsx/route.ts",
+ "app/api/reports/continuity-check/route.ts",
+ "app/api/reports/full-archive/route.ts",
+ "app/api/reports/general-ledger/xlsx/route.ts",
+ "app/api/reports/income-statement/pdf/route.ts",
+ "app/api/reports/income-statement/xlsx/route.ts",
+ "app/api/reports/journal-register/route.ts",
+ "app/api/reports/journal-register/xlsx/route.ts",
+ "app/api/reports/kassaflodesanalys/pdf/route.ts",
+ "app/api/reports/kassaflodesanalys/route.ts",
+ "app/api/reports/kpi/route.ts",
+ "app/api/reports/kpi/xlsx/route.ts",
+ "app/api/reports/monthly-breakdown/route.ts",
+ "app/api/reports/monthly-breakdown/xlsx/route.ts",
+ "app/api/reports/resultatrapport/pdf/route.ts",
+ "app/api/reports/resultatrapport/route.ts",
+ "app/api/reports/resultatrapport/xlsx/route.ts",
+ "app/api/reports/salary-journal/route.ts",
+ "app/api/reports/salary-journal/xlsx/route.ts",
+ "app/api/reports/supplier-ledger/route.ts",
+ "app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/route.ts",
+ "app/api/reports/supplier-ledger/xlsx/route.ts",
+ "app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts",
+ "app/api/reports/trial-balance/route.ts",
+ "app/api/reports/trial-balance/xlsx/route.ts",
+ "app/api/reports/vacation-liability/route.ts",
+ "app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts",
+ "app/api/reports/vat-declaration/xlsx/route.ts",
+ "app/api/salary/employees/[id]/absence/route.ts",
+ "app/api/salary/employees/[id]/benefits/[benefitId]/route.ts",
+ "app/api/salary/employees/[id]/benefits/route.ts",
+ "app/api/salary/employees/[id]/route.ts",
+ "app/api/salary/employees/[id]/worked-hours/batch/route.ts",
+ "app/api/salary/employees/[id]/worked-hours/route.ts",
+ "app/api/salary/employees/route.ts",
+ "app/api/salary/ku/[year]/route.ts",
+ "app/api/salary/payroll-config/[year]/route.ts",
+ "app/api/salary/runs/[id]/agi/submit/route.ts",
+ "app/api/salary/runs/[id]/agi/xml/route.ts",
+ "app/api/salary/runs/[id]/correct/route.ts",
+ "app/api/salary/runs/[id]/employees/[employeeId]/route.ts",
+ "app/api/salary/runs/[id]/employees/route.ts",
+ "app/api/salary/runs/[id]/lines/[lineId]/route.ts",
+ "app/api/salary/runs/[id]/lines/route.ts",
+ "app/api/salary/runs/[id]/payment/bg-lb/route.ts",
+ "app/api/salary/runs/[id]/payment/pain001/route.ts",
+ "app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts",
+ "app/api/salary/runs/[id]/payslips/send/route.ts",
+ "app/api/salary/runs/[id]/preview/route.ts",
+ "app/api/salary/runs/[id]/review/route.ts",
+ "app/api/salary/runs/[id]/route.ts",
+ "app/api/salary/tax-tables/lookup/route.ts",
+ "app/api/salary/tax-tables/status/route.ts",
+ "app/api/settings/api-keys/[id]/route.ts",
+ "app/api/settings/booking-templates/[id]/route.ts",
+ "app/api/settings/booking-templates/[id]/touch/route.ts",
+ "app/api/settings/booking-templates/export/route.ts",
+ "app/api/settings/booking-templates/import/route.ts",
+ "app/api/settings/booking-templates/route.ts",
+ "app/api/settings/counterparty-templates/route.ts",
+ "app/api/settings/logo/route.ts",
+ "app/api/settings/oauth-clients/[id]/route.ts",
+ "app/api/settings/oauth-clients/route.ts",
+ "app/api/settings/route.ts",
+ "app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts",
+ "app/api/skatteverket/tax-payments/[period]/payment-file/route.ts",
+ "app/api/skatteverket/tax-payments/[period]/route.ts",
+ "app/api/supplier-invoices/[id]/route.ts",
+ "app/api/supplier-invoices/[id]/uncredit/route.ts",
+ "app/api/support/contact/route.ts",
+ "app/api/tax-deadlines/generate/route.ts",
+ "app/api/team/accept/route.ts",
+ "app/api/team/members/route.ts",
+ "app/api/transactions/[id]/attach-document/route.ts",
+ "app/api/transactions/[id]/book/route.ts",
+ "app/api/transactions/[id]/ignore/route.ts",
+ "app/api/transactions/[id]/uncategorize/route.ts",
+ "app/api/transactions/batch-match-invoices/route.ts",
+ "app/api/transactions/create-from-document/route.ts",
+ "app/api/transactions/route.ts",
+ "app/api/transactions/suggest-categories/route.ts",
+ "app/api/vat/validate/route.ts"
+ ]
+ },
+ "naiveOreRound": {
+ "count": 668
+ }
+}
diff --git a/scripts/checks/no-new-antipatterns.mjs b/scripts/checks/no-new-antipatterns.mjs
new file mode 100644
index 00000000..6893e05f
--- /dev/null
+++ b/scripts/checks/no-new-antipatterns.mjs
@@ -0,0 +1,164 @@
+#!/usr/bin/env node
+/**
+ * Ratchet guard against post-audit antipatterns.
+ *
+ * The audit found two repository-wide problems that are being remediated in
+ * dedicated campaigns (A1 = route auth/MFA, D1 = money rounding). Those touch
+ * hundreds of sites and won't land in one PR — so this guard makes sure the
+ * count can only go DOWN, never up, while the migrations are in flight.
+ *
+ * Checks:
+ * 1. raw-route-auth — an `app/api/**\/route.ts` that calls
+ * `supabase.auth.getUser()` directly instead of going through
+ * `requireAuth()` / `withRouteContext()` (the only guards that enforce
+ * MFA AAL2 on hosted). Tracked as a file-set so a NEW offending route
+ * fails CI even if an old one was fixed in the same PR.
+ * 2. naive-ore-round — `Math.round(x * 100) / 100`, which is subtly wrong on
+ * exact-half values (see lib/money.ts `roundOre`). Tracked as a count.
+ * The canonical rounding modules are excluded.
+ *
+ * Usage:
+ * node scripts/checks/no-new-antipatterns.mjs # check (CI)
+ * node scripts/checks/no-new-antipatterns.mjs --update # re-baseline after a migration ratchets the count down
+ *
+ * Exit code 1 if either check regressed past its baseline.
+ */
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
+const BASELINE_PATH = path.join(ROOT, 'scripts', 'checks', 'antipatterns-baseline.json')
+
+const IGNORE_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'build', 'coverage'])
+// The sanctioned home of the öre-round implementation — must not count against itself.
+const ROUND_EXEMPT = new Set(['lib/money.ts', 'lib/bokslut/rounding.ts'])
+
+const RAW_AUTH_RE = /\.auth\.getUser\(/
+// Match the guard at its CALL site, not a bare import, so a file that imports
+// withRouteContext but still hand-rolls getUser() on another handler is still
+// flagged. withRouteContext is usually called with a generic (`withRouteContext<…>(`),
+// so accept either `<` or `(` after the name.
+const GUARD_RE = /requireAuth\(|withRouteContext[<(]/
+const NAIVE_ROUND_RE = /Math\.round\([^\n]*\*\s*100\s*\)\s*\/\s*100/
+
+function walk(dir, exts, out = []) {
+ let entries
+ try {
+ entries = fs.readdirSync(dir, { withFileTypes: true })
+ } catch {
+ return out
+ }
+ for (const e of entries) {
+ if (e.name.startsWith('.') && e.name !== '.well-known') continue
+ const full = path.join(dir, e.name)
+ if (e.isDirectory()) {
+ if (!IGNORE_DIRS.has(e.name)) walk(full, exts, out)
+ } else if (exts.some((x) => e.name.endsWith(x))) {
+ out.push(full)
+ }
+ }
+ return out
+}
+
+const rel = (p) => path.relative(ROOT, p).split(path.sep).join('/')
+
+/** Route files that hand-roll auth instead of the MFA-enforcing guard. */
+function findRawRouteAuth() {
+ const apiDir = path.join(ROOT, 'app', 'api')
+ return walk(apiDir, ['route.ts'])
+ .filter((f) => {
+ const src = fs.readFileSync(f, 'utf8')
+ return RAW_AUTH_RE.test(src) && !GUARD_RE.test(src)
+ })
+ .map(rel)
+ .sort()
+}
+
+/** Count of naive Math.round(x*100)/100 occurrences (lines) across source. */
+function countNaiveRound() {
+ const files = [
+ ...walk(path.join(ROOT, 'lib'), ['.ts', '.tsx']),
+ ...walk(path.join(ROOT, 'app'), ['.ts', '.tsx']),
+ ...walk(path.join(ROOT, 'components'), ['.ts', '.tsx']),
+ ...walk(path.join(ROOT, 'extensions'), ['.ts', '.tsx']),
+ ]
+ let count = 0
+ for (const f of files) {
+ if (ROUND_EXEMPT.has(rel(f))) continue
+ for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
+ if (NAIVE_ROUND_RE.test(line)) count++
+ }
+ }
+ return count
+}
+
+const current = {
+ rawRouteAuth: findRawRouteAuth(),
+ naiveOreRound: countNaiveRound(),
+}
+
+const isUpdate = process.argv.includes('--update')
+
+if (isUpdate) {
+ const baseline = {
+ _comment:
+ 'Ratchet baseline for scripts/checks/no-new-antipatterns.mjs. These counts may only decrease. Re-run with --update after a migration lowers them. Goal: both reach 0 (A1 route-auth campaign, D1 rounding codemod).',
+ rawRouteAuth: { count: current.rawRouteAuth.length, files: current.rawRouteAuth },
+ naiveOreRound: { count: current.naiveOreRound },
+ }
+ fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + '\n')
+ console.log(
+ `Baseline written: ${current.rawRouteAuth.length} raw-route-auth files, ${current.naiveOreRound} naive-ore-round occurrences.`,
+ )
+ process.exit(0)
+}
+
+if (!fs.existsSync(BASELINE_PATH)) {
+ console.error('No baseline found. Run: node scripts/checks/no-new-antipatterns.mjs --update')
+ process.exit(1)
+}
+
+const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'))
+let failed = false
+
+// 1. raw-route-auth: any file not in the baseline set is a NEW violation.
+const baselineSet = new Set(baseline.rawRouteAuth.files)
+const newAuthFiles = current.rawRouteAuth.filter((f) => !baselineSet.has(f))
+const fixedAuthFiles = baseline.rawRouteAuth.files.filter((f) => !current.rawRouteAuth.includes(f))
+if (newAuthFiles.length) {
+ failed = true
+ console.error(
+ `\n✗ raw-route-auth: ${newAuthFiles.length} new route(s) call supabase.auth.getUser() directly ` +
+ `instead of requireAuth()/withRouteContext() (skips MFA AAL2 enforcement):`,
+ )
+ newAuthFiles.forEach((f) => console.error(` ${f}`))
+ console.error(' → wrap the route in withRouteContext (or call requireAuth) so MFA is enforced.')
+}
+
+// 2. naive-ore-round: count may not increase.
+if (current.naiveOreRound > baseline.naiveOreRound.count) {
+ failed = true
+ console.error(
+ `\n✗ naive-ore-round: ${current.naiveOreRound} occurrences of Math.round(x*100)/100 ` +
+ `(baseline ${baseline.naiveOreRound.count}, +${current.naiveOreRound - baseline.naiveOreRound.count}).`,
+ )
+ console.error(' → import roundOre from @/lib/money instead.')
+}
+
+// Report ratchet-down progress (informational, never fails).
+if (fixedAuthFiles.length || current.naiveOreRound < baseline.naiveOreRound.count) {
+ console.log('\n✓ Progress since baseline:')
+ if (fixedAuthFiles.length) console.log(` raw-route-auth: -${fixedAuthFiles.length} file(s)`)
+ if (current.naiveOreRound < baseline.naiveOreRound.count)
+ console.log(` naive-ore-round: -${baseline.naiveOreRound.count - current.naiveOreRound} occurrence(s)`)
+ console.log(' Run with --update to ratchet the baseline down and lock in the gains.')
+}
+
+if (failed) {
+ console.error('\nAntipattern guard failed — see above.')
+ process.exit(1)
+}
+console.log(
+ `\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}).`,
+)