From 0b86901a2b4d67b8f290dba465a7f2869459174a Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:34:58 +0200 Subject: [PATCH] Enforce MFA on critical mutation routes + post-audit foundation (A1) (#646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .claude/rules/api-routes.md | 35 ++-- .github/workflows/core-build.yml | 6 + .../[id]/commit/__tests__/route.test.ts | 35 +++- .../journal-entries/[id]/commit/route.ts | 49 +---- .../[id]/correct/__tests__/route.test.ts | 22 ++- .../journal-entries/[id]/correct/route.ts | 47 ++--- .../journal-entries/[id]/recordate/route.ts | 49 ++--- .../[id]/reverse/__tests__/route.test.ts | 13 +- .../journal-entries/[id]/reverse/route.ts | 39 +--- app/api/salary/runs/[id]/approve/route.ts | 167 ++++++++-------- app/api/salary/runs/[id]/paid/route.ts | 55 +++--- app/api/salary/runs/[id]/revert/route.ts | 49 ++--- app/api/sandbox/seed/route.ts | 22 ++- components/common/DataState.tsx | 97 ++++++++++ lib/__tests__/format.test.ts | 43 +++++ lib/__tests__/money.test.ts | 69 +++++++ lib/api/with-route-context.ts | 13 +- lib/bokslut/rounding.ts | 49 +---- lib/errors/get-structured-error.ts | 16 ++ lib/errors/structured-errors.ts | 31 +++ lib/hooks/use-fetch.ts | 112 +++++++++++ lib/money.ts | 71 +++++++ lib/utils.ts | 42 ++++ messages/en.json | 2 + messages/sv.json | 2 + package.json | 1 + scripts/checks/antipatterns-baseline.json | 182 ++++++++++++++++++ scripts/checks/no-new-antipatterns.mjs | 164 ++++++++++++++++ 28 files changed, 1102 insertions(+), 380 deletions(-) create mode 100644 components/common/DataState.tsx create mode 100644 lib/__tests__/format.test.ts create mode 100644 lib/__tests__/money.test.ts create mode 100644 lib/hooks/use-fetch.ts create mode 100644 lib/money.ts create mode 100644 scripts/checks/antipatterns-baseline.json create mode 100644 scripts/checks/no-new-antipatterns.mjs diff --git a/.claude/rules/api-routes.md b/.claude/rules/api-routes.md index 62be9c68..348ad736 100644 --- a/.claude/rules/api-routes.md +++ b/.claude/rules/api-routes.md @@ -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/`) diff --git a/.github/workflows/core-build.yml b/.github/workflows/core-build.yml index e8531a4e..608b8703 100644 --- a/.github/workflows/core-build.yml +++ b/.github/workflows/core-build.yml @@ -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" \ diff --git a/app/api/bookkeeping/journal-entries/[id]/commit/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/commit/__tests__/route.test.ts index 194a4419..291b47b8 100644 --- a/app/api/bookkeeping/journal-entries/[id]/commit/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/[id]/commit/__tests__/route.test.ts @@ -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') }) }) diff --git a/app/api/bookkeeping/journal-entries/[id]/commit/route.ts b/app/api/bookkeeping/journal-entries/[id]/commit/route.ts index c063cc02..e1f67683 100644 --- a/app/api/bookkeeping/journal-entries/[id]/commit/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/commit/route.ts @@ -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 }, +) diff --git a/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts index 0da2f518..a7e14827 100644 --- a/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts @@ -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') }) }) diff --git a/app/api/bookkeeping/journal-entries/[id]/correct/route.ts b/app/api/bookkeeping/journal-entries/[id]/correct/route.ts index 7b065b0f..47ca8873 100644 --- a/app/api/bookkeeping/journal-entries/[id]/correct/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/correct/route.ts @@ -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 }, +) diff --git a/app/api/bookkeeping/journal-entries/[id]/recordate/route.ts b/app/api/bookkeeping/journal-entries/[id]/recordate/route.ts index fc7d3a89..d48f60c1 100644 --- a/app/api/bookkeeping/journal-entries/[id]/recordate/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/recordate/route.ts @@ -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 }, +) diff --git a/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts index cbc3d328..a3fddd27 100644 --- a/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts @@ -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') }) }) diff --git a/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts b/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts index df3aafc3..ce3a5dca 100644 --- a/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/reverse/route.ts @@ -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 }, +) diff --git a/app/api/salary/runs/[id]/approve/route.ts b/app/api/salary/runs/[id]/approve/route.ts index bcfc6371..3ddda10c 100644 --- a/app/api/salary/runs/[id]/approve/route.ts +++ b/app/api/salary/runs/[id]/approve/route.ts @@ -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 }, +) diff --git a/app/api/salary/runs/[id]/paid/route.ts b/app/api/salary/runs/[id]/paid/route.ts index cd3350d1..20ab9440 100644 --- a/app/api/salary/runs/[id]/paid/route.ts +++ b/app/api/salary/runs/[id]/paid/route.ts @@ -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 }, +) diff --git a/app/api/salary/runs/[id]/revert/route.ts b/app/api/salary/runs/[id]/revert/route.ts index 37cef5ed..0b16320d 100644 --- a/app/api/salary/runs/[id]/revert/route.ts +++ b/app/api/salary/runs/[id]/revert/route.ts @@ -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 }, +) diff --git a/app/api/sandbox/seed/route.ts b/app/api/sandbox/seed/route.ts index b9d26174..ce5fb417 100644 --- a/app/api/sandbox/seed/route.ts +++ b/app/api/sandbox/seed/route.ts @@ -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( diff --git a/components/common/DataState.tsx b/components/common/DataState.tsx new file mode 100644 index 00000000..8a6d6445 --- /dev/null +++ b/components/common/DataState.tsx @@ -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. ``). */ + 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(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 ( +
+
+ ) + } + + 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}).`, +)