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
parent c74b19df1b
commit 0b86901a2b
28 changed files with 1102 additions and 380 deletions
+21 -14
View File
@@ -7,33 +7,40 @@ paths:
Use the `/erp-api-route` skill when scaffolding new endpoints.
**Default: wrap every cookie-session route in `withRouteContext`** (`lib/api/with-route-context.ts`). It is the only path that enforces MFA (AAL2) on hosted — it calls `requireAuth()`, resolves the active `companyId`, optionally gates non-viewer role (`requireWrite: true`), and converts thrown errors into the canonical envelope. **Never hand-roll `supabase.auth.getUser()` in a route** — that skips MFA. CI enforces this via the ratchet guard (`npm run check:guards`); a new route calling `getUser()` directly fails the build.
```typescript
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { MySchema } from '@/lib/api/schemas'
ensureInitialized() // Module-level — loads extensions for event emission
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
// Dynamic route: pass the params type as the generic.
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'resource.action',
async (request, { supabase, companyId, user, log }, { params }) => {
const { id } = await params
const validation = await validateBody(request, MySchema)
if (!validation.success) return validation.response
const result = await validateBody(request, MySchema)
if (!result.success) return result.response
// Business logic... always filter by company_id (defense in depth alongside RLS)
return NextResponse.json({ data: result })
}
// Business logic... always filter by company_id (defense in depth alongside RLS).
// Throw typed domain errors (e.g. lib/bookkeeping/errors) — the wrapper maps
// them to the right status + canonical { error: { code, message, message_en } }.
return NextResponse.json({ data: result })
},
{ requireWrite: true }, // omit for read-only routes
)
```
- Dynamic route params: `{ params }: { params: Promise<{ id: string }> }` (Next.js 16 — params are async).
- Response shapes: `{ data }` for success, `{ error }` for failures.
- Dynamic route params: `{ params }: { params: Promise<{ id: string }> }` (Next.js 16 — params are async). With `withRouteContext`, pass that shape as the generic and destructure `params` from the 3rd handler arg.
- Response shapes: `{ data }` for success; failures are the canonical `{ error: { code, message, message_en?, requestId? } }` envelope (thrown errors → `errorResponse`). Don't hand-build `{ error: 'string' }`.
- Zod schemas in `lib/api/schemas.ts` — 100+ schemas with shared primitives (uuid, isoDate, accountNumber, nonNegativeAmount).
- Routes that emit events must call `ensureInitialized()` at module level.
- API-key auth uses `createServiceClientNoCookies()`; every query still filters by `company_id`.
- Opt out of `withRouteContext` only when the route genuinely can't guarantee a company context (e.g. onboarding) — then call `requireAuth()` directly so MFA is still enforced.
- API-key auth (`/api/v1/*`) uses `createServiceClientNoCookies()` + `v1ErrorResponse`; every query still filters by `company_id`.
## Endpoint map (`app/api/`)
+6
View File
@@ -21,6 +21,12 @@ jobs:
- run: npm run setup:extensions
- run: npm run build
- run: npm test
- name: Antipattern ratchet (no new MFA-bypassing routes / naive öre-rounding)
# Fails only if a PR ADDS a route that hand-rolls supabase.auth.getUser()
# instead of the MFA-enforcing guard, or a new Math.round(x*100)/100.
# Baseline lives in scripts/checks/antipatterns-baseline.json and ratchets
# down as the A1 (route auth) and D1 (rounding) migrations land.
run: npm run check:guards
- name: Check no core imports from extensions
run: |
VIOLATIONS=$(grep -r "from '@/extensions/" lib/ app/api/ components/ --include="*.ts" --include="*.tsx" \
@@ -1,10 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
parseJsonResponse,
createMockRouteParams,
makeJournalEntry,
} from '@/tests/helpers'
import { JournalEntryNotBalancedError } from '@/lib/bookkeeping/errors'
const mockCreateClient = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
@@ -20,8 +22,9 @@ vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
const mockCommitEntry = vi.fn()
@@ -48,6 +51,7 @@ describe('POST /api/bookkeeping/journal-entries/[id]/commit', () => {
mockCreateClient.mockResolvedValue({
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) },
})
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when not authenticated', async () => {
@@ -65,6 +69,25 @@ describe('POST /api/bookkeeping/journal-entries/[id]/commit', () => {
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 403 when the caller lacks write permission (role/MFA write gate)', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json(
{ error: 'Du har endast läsbehörighet i detta företag.' },
{ status: 403 },
),
})
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/commit', {
method: 'POST',
})
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(403)
expect(mockCommitEntry).not.toHaveBeenCalled()
})
it('returns posted entry on success', async () => {
const postedEntry = makeJournalEntry({
id: 'entry-1',
@@ -91,16 +114,18 @@ describe('POST /api/bookkeeping/journal-entries/[id]/commit', () => {
)
})
it('returns 400 when engine throws', async () => {
mockCommitEntry.mockRejectedValue(new Error('Entry not balanced'))
it('maps a typed engine error to the canonical structured envelope', async () => {
// commitEntry throws typed bookkeeping errors; the wrapper routes them
// through errorResponse() → registry status + { error: { code, ... } }.
mockCommitEntry.mockRejectedValue(new JournalEntryNotBalancedError(1000, 900))
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/commit', {
method: 'POST',
})
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
const { status, body } = await parseJsonResponse<{ error: { code: string; message_en?: string } }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Entry not balanced')
expect(body.error.code).toBe('JOURNAL_ENTRY_NOT_BALANCED')
})
})
@@ -1,49 +1,16 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { commitEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { createLogger } from '@/lib/logger'
import { withRouteContext } from '@/lib/api/with-route-context'
ensureInitialized()
const log = createLogger('api.bookkeeping.commit')
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
try {
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'bookkeeping.journal-entry.commit',
async (_request, { supabase, companyId, user }, { params }) => {
const { id } = await params
const posted = await commitEntry(supabase, companyId, user.id, id, 'user_accept')
return NextResponse.json({ data: posted })
} catch (err) {
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
// Untyped error path: engine logging didn't fire, so log here.
log.error('commit endpoint failed (untyped)', err as Error, {
companyId,
userId: user.id,
entityType: 'journal_entry',
entityId: id,
})
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to commit entry' },
{ status: 400 }
)
}
}
},
{ requireWrite: true },
)
@@ -5,6 +5,10 @@ import {
createMockRouteParams,
makeJournalEntry,
} from '@/tests/helpers'
import {
JournalEntryNotBalancedError,
CannotCorrectNonPostedError,
} from '@/lib/bookkeeping/errors'
const mockCreateClient = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
@@ -112,10 +116,8 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => {
expect(mockCorrectEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'entry-1', lines)
})
it('returns 400 when correctEntry throws for unbalanced lines', async () => {
mockCorrectEntry.mockRejectedValue(
new Error('Corrected entry is not balanced: debits (1000) != credits (500)')
)
it('maps an unbalanced-correction engine error to the canonical envelope (400)', async () => {
mockCorrectEntry.mockRejectedValue(new JournalEntryNotBalancedError(1000, 500, 'correction'))
const lines = [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
@@ -127,14 +129,14 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => {
body: { lines },
})
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error).toContain('not balanced')
expect(body.error.code).toBe('JOURNAL_ENTRY_NOT_BALANCED')
})
it('returns 400 when entry is not found or not posted', async () => {
mockCorrectEntry.mockRejectedValue(new Error('Can only correct posted entries'))
it('maps a not-posted engine error to the canonical envelope (400)', async () => {
mockCorrectEntry.mockRejectedValue(new CannotCorrectNonPostedError('draft'))
const lines = [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
@@ -146,9 +148,9 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => {
body: { lines },
})
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Can only correct posted entries')
expect(body.error.code).toBe('CANNOT_CORRECT_NON_POSTED')
})
})
@@ -1,45 +1,20 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { correctEntry } from '@/lib/core/bookkeeping/storno-service'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { CorrectJournalEntrySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { withRouteContext } from '@/lib/api/with-route-context'
ensureInitialized()
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, CorrectJournalEntrySchema)
if (!validation.success) return validation.response
const body = validation.data
try {
const result = await correctEntry(supabase, companyId, user.id, id, body.lines)
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'bookkeeping.journal-entry.correct',
async (request, { supabase, companyId, user }, { params }) => {
const { id } = await params
const validation = await validateBody(request, CorrectJournalEntrySchema)
if (!validation.success) return validation.response
const result = await correctEntry(supabase, companyId, user.id, id, validation.data.lines)
return NextResponse.json({ data: result })
} catch (err) {
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to correct entry' },
{ status: 400 }
)
}
}
},
{ requireWrite: true },
)
@@ -1,47 +1,20 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { recordateEntry } from '@/lib/core/bookkeeping/storno-service'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { RecordateJournalEntrySchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { withRouteContext } from '@/lib/api/with-route-context'
ensureInitialized()
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
const validation = await validateBody(request, RecordateJournalEntrySchema)
if (!validation.success) return validation.response
const body = validation.data
try {
const result = await recordateEntry(supabase, companyId, user.id, id, body.new_entry_date)
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'bookkeeping.journal-entry.recordate',
async (request, { supabase, companyId, user }, { params }) => {
const { id } = await params
const validation = await validateBody(request, RecordateJournalEntrySchema)
if (!validation.success) return validation.response
const result = await recordateEntry(supabase, companyId, user.id, id, validation.data.new_entry_date)
return NextResponse.json({ data: result })
} catch (err) {
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
// Not a recognized domain error — an unexpected server fault, not a client
// error, so surface it as 500.
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to move entry' },
{ status: 500 }
)
}
}
},
{ requireWrite: true },
)
@@ -5,6 +5,7 @@ import {
createMockRouteParams,
makeJournalEntry,
} from '@/tests/helpers'
import { EntryAlreadyReversedError } from '@/lib/bookkeeping/errors'
// Mock dependencies before imports
const mockCreateClient = vi.fn()
@@ -76,16 +77,18 @@ describe('POST /api/bookkeeping/journal-entries/[id]/reverse', () => {
expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'entry-1')
})
it('returns 400 when engine throws', async () => {
mockReverseEntry.mockRejectedValue(new Error('Entry already reversed'))
it('maps a typed concurrent-reversal error to the canonical envelope (409)', async () => {
// reverseEntry throws the typed error on a concurrent storno; the wrapper
// routes it through errorResponse() → 409 + { error: { code, ... } }.
mockReverseEntry.mockRejectedValue(new EntryAlreadyReversedError())
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/reverse', {
method: 'POST',
})
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Entry already reversed')
expect(status).toBe(409)
expect(body.error.code).toBe('ENTRY_ALREADY_REVERSED')
})
})
@@ -1,39 +1,16 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { withRouteContext } from '@/lib/api/with-route-context'
ensureInitialized()
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
try {
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'bookkeeping.journal-entry.reverse',
async (_request, { supabase, companyId, user }, { params }) => {
const { id } = await params
const reversalEntry = await reverseEntry(supabase, companyId, user.id, id)
return NextResponse.json({ data: reversalEntry })
} catch (err) {
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to reverse entry' },
{ status: 400 }
)
}
}
},
{ requireWrite: true },
)
+79 -88
View File
@@ -1,106 +1,97 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { withRouteContext } from '@/lib/api/with-route-context'
import { eventBus } from '@/lib/events'
ensureInitialized()
/** review → approved (authorization recorded, with pre-approve validation) */
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.run.approve',
async (_request, { supabase, companyId, user }, { params }) => {
const { id } = await params
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
// Verify run exists and is in review status
const { data: run, error: runError } = await supabase
.from('salary_runs')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'review')
.single()
const companyId = await requireCompanyId(supabase, user.id)
// Verify run exists and is in review status
const { data: run, error: runError } = await supabase
.from('salary_runs')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'review')
.single()
if (runError || !run) {
return NextResponse.json({ error: 'Lönekörningen måste vara i granskningsstatus' }, { status: 400 })
}
// Load all employees in this run for validation
const { data: runEmployees } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(first_name, last_name, clearing_number, bank_account_number, email)')
.eq('salary_run_id', id)
const validationErrors: string[] = []
const warnings: string[] = []
for (const sre of runEmployees || []) {
const emp = sre.employee as {
first_name: string
last_name: string
clearing_number: string | null
bank_account_number: string | null
email: string | null
} | null
if (!emp) continue
const name = `${emp.first_name} ${emp.last_name}`
// Bank details required for payment
if (!emp.clearing_number || !emp.bank_account_number) {
validationErrors.push(`${name}: Bankuppgifter saknas (clearingnummer och/eller kontonummer)`)
if (runError || !run) {
return NextResponse.json({ error: 'Lönekörningen måste vara i granskningsstatus' }, { status: 400 })
}
// Must have been calculated (calculation_breakdown exists)
if (!sre.calculation_breakdown) {
validationErrors.push(`${name}: Beräkning saknas — kör beräkning först`)
// Load all employees in this run for validation
const { data: runEmployees } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(first_name, last_name, clearing_number, bank_account_number, email)')
.eq('salary_run_id', id)
const validationErrors: string[] = []
const warnings: string[] = []
for (const sre of runEmployees || []) {
const emp = sre.employee as {
first_name: string
last_name: string
clearing_number: string | null
bank_account_number: string | null
email: string | null
} | null
if (!emp) continue
const name = `${emp.first_name} ${emp.last_name}`
// Bank details required for payment
if (!emp.clearing_number || !emp.bank_account_number) {
validationErrors.push(`${name}: Bankuppgifter saknas (clearingnummer och/eller kontonummer)`)
}
// Must have been calculated (calculation_breakdown exists)
if (!sre.calculation_breakdown) {
validationErrors.push(`${name}: Beräkning saknas — kör beräkning först`)
}
// Warning: no email means pay slip cannot be sent
if (!emp.email) {
warnings.push(`${name}: E-post saknas — lönebesked kan inte skickas`)
}
}
// Warning: no email means pay slip cannot be sent
if (!emp.email) {
warnings.push(`${name}: E-post saknas — lönebesked kan inte skickas`)
if (validationErrors.length > 0) {
return NextResponse.json({
error: 'Valideringsfel — korrigera innan godkännande',
details: validationErrors,
warnings,
}, { status: 400 })
}
}
if (validationErrors.length > 0) {
return NextResponse.json({
error: 'Valideringsfel — korrigera innan godkännande',
details: validationErrors,
warnings,
}, { status: 400 })
}
// All validation passed — approve
const { data: updatedRun, error } = await supabase
.from('salary_runs')
.update({
status: 'approved',
approved_by: user.id,
approved_at: new Date().toISOString(),
})
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'review')
.select()
.single()
// All validation passed — approve
const { data: updatedRun, error } = await supabase
.from('salary_runs')
.update({
status: 'approved',
approved_by: user.id,
approved_at: new Date().toISOString(),
if (error || !updatedRun) {
return NextResponse.json({ error: 'Kunde inte godkänna lönekörningen' }, { status: 500 })
}
await eventBus.emit({
type: 'salary_run.approved',
payload: { salaryRunId: id, approvedBy: user.id, userId: user.id, companyId },
})
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'review')
.select()
.single()
if (error || !updatedRun) {
return NextResponse.json({ error: 'Kunde inte godkänna lönekörningen' }, { status: 500 })
}
await eventBus.emit({
type: 'salary_run.approved',
payload: { salaryRunId: id, approvedBy: user.id, userId: user.id, companyId },
})
return NextResponse.json({ data: updatedRun, warnings })
}
return NextResponse.json({ data: updatedRun, warnings })
},
{ requireWrite: true },
)
+23 -32
View File
@@ -1,41 +1,32 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { withRouteContext } from '@/lib/api/with-route-context'
ensureInitialized()
/** approved → paid (payment confirmation) */
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.run.paid',
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const { data: run, error } = await supabase
.from('salary_runs')
.update({
status: 'paid',
paid_at: new Date().toISOString(),
})
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'approved')
.select()
.single()
const companyId = await requireCompanyId(supabase, user.id)
if (error || !run) {
return NextResponse.json({ error: 'Lönekörningen måste vara godkänd' }, { status: 400 })
}
const { data: run, error } = await supabase
.from('salary_runs')
.update({
status: 'paid',
paid_at: new Date().toISOString(),
})
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'approved')
.select()
.single()
if (error || !run) {
return NextResponse.json({ error: 'Lönekörningen måste vara godkänd' }, { status: 400 })
}
return NextResponse.json({ data: run })
}
return NextResponse.json({ data: run })
},
{ requireWrite: true },
)
+20 -29
View File
@@ -1,38 +1,29 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { withRouteContext } from '@/lib/api/with-route-context'
ensureInitialized()
/** review → draft (unlock for editing) */
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.run.revert',
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const { data: run, error } = await supabase
.from('salary_runs')
.update({ status: 'draft' })
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'review')
.select()
.single()
const companyId = await requireCompanyId(supabase, user.id)
if (error || !run) {
return NextResponse.json({ error: 'Lönekörningen måste vara i granskningsstatus' }, { status: 400 })
}
const { data: run, error } = await supabase
.from('salary_runs')
.update({ status: 'draft' })
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'review')
.select()
.single()
if (error || !run) {
return NextResponse.json({ error: 'Lönekörningen måste vara i granskningsstatus' }, { status: 400 })
}
return NextResponse.json({ data: run })
}
return NextResponse.json({ data: run })
},
{ requireWrite: true },
)
+15 -7
View File
@@ -1,6 +1,6 @@
import crypto from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createClient } from '@/lib/supabase/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { NextResponse } from 'next/server'
import { getActiveCompanyId } from '@/lib/company/context'
import { createLogger } from '@/lib/logger'
@@ -43,12 +43,20 @@ export async function POST(request: Request) {
})
if (!rl.ok) return rl.response!
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized', requestId }, { status: 401 })
}
// Can't use withRouteContext (see above — no company yet), so call requireAuth
// directly: the documented stopgap that still enforces MFA. A no-op for the
// anonymous users this route serves (they have no second factor), but keeps
// the route on the same auth path as the rest of the API.
//
// GDPR Art.32 compensating controls for this anonymous, low-auth write path:
// (1) anonymous-only — authenticated users are rejected below (403); (2) the
// /24 rate limit above (5/h); (3) all seeded data is synthetic demo content
// (fabricated names, example.com emails, documentation-reserved org numbers),
// not real personal data; (4) writes are scoped to the caller's own freshly
// created sandbox company, RLS-isolated from every other tenant.
const auth = await requireAuth()
if (auth.error) return auth.error
const { user, supabase } = auth
if (!user.is_anonymous) {
return NextResponse.json(
+97
View File
@@ -0,0 +1,97 @@
'use client'
import * as React from 'react'
import { AlertCircle } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
interface DataStateProps {
loading: boolean
error: string | null
/** When true (and not loading/error), render `empty` instead of `children`. */
isEmpty?: boolean
/** Retry handler — typically `useFetch().refetch`. Shows a retry button. */
onRetry?: () => void
/** Loading placeholder. Defaults to three skeleton rows. */
skeleton?: React.ReactNode
/** Shown when `isEmpty`. Pass an `EmptyState` / preset (e.g. `<EmptyInvoices />`). */
empty?: React.ReactNode
children: React.ReactNode
className?: string
}
/**
* Renders the loading / error / empty / ready states for a data-driven section,
* so callers stop hand-rolling that branch every time.
*
* Pairs with `useFetch`:
*
* @example
* const { data, loading, error, refetch } = useFetch<Account[]>(url, { select: b => b.data })
* return (
* <DataState
* loading={loading}
* error={error}
* onRetry={refetch}
* isEmpty={!data?.length}
* empty={<EmptyState title={t('none_title')} description={t('none_desc')} />}
* >
* <AccountsTable accounts={data!} />
* </DataState>
* )
*
* 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 (
<div className={className}>
{skeleton ?? (
<div className="space-y-3" aria-busy="true" aria-live="polite">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-3/4" />
</div>
)}
</div>
)
}
if (error) {
return (
<div
role="alert"
className={cn('flex flex-col items-center justify-center py-12 px-4 text-center', className)}
>
<AlertCircle className="h-8 w-8 text-destructive mb-3" aria-hidden="true" />
<p className="text-sm font-medium mb-1">{t('load_error')}</p>
<p className="text-sm text-muted-foreground max-w-sm mb-6 text-balance">{error}</p>
{onRetry && (
<Button variant="outline" onClick={onRetry}>
{t('retry')}
</Button>
)}
</div>
)
}
if (isEmpty) {
return <div className={className}>{empty}</div>
}
return <>{children}</>
}
+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.
+2
View File
@@ -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",
+2
View File
@@ -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",
+1
View File
@@ -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"
},
+182
View File
@@ -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
}
}
+164
View File
@@ -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}).`,
)