Files
accounted/lib/money.ts
T
Jakob WennbergandClaude Opus 4.8 0b86901a2b 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>
2026-06-03 15:34:58 +02:00

72 lines
2.7 KiB
TypeScript

/**
* 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))
}