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:
co-authored by
Claude Opus 4.8
parent
c74b19df1b
commit
0b86901a2b
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user