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:
+21
-14
@@ -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/`)
|
||||
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}</>
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}).`,
|
||||
)
|
||||
Reference in New Issue
Block a user