Files
Mattsson 4e14182a00 fix(salary): declare, book and pay AGI in whole kronor (SKV per-sats computation) (#1611)
* fix(salary): declare, book and pay AGI in whole kronor (SKV per-sats computation)

A user's first lönekörning surfaced öre amounts in the AGI payable while
Skatteverket deals in whole kronor. Three connected defects:

- the AGI XML rounded amounts (Math.round); öretal bortfaller (SFF
  2011:1261 22 kap. 1 §) requires truncation, and FK487 must be
  Skatteverket's own per-sats computation on the whole-krona underlag sums
  (IK587, kontroll B_006), not a truncation of the öre-exact engine sum
- the salary booking credited 2731 with exact öre, leaving a residual
  after the whole-krona skattekonto draw; 2731 now carries the declared
  amount with the remainder on 3740 (Öres- och kronutjämning)
- the LB payment file and TaxPaymentPanel paid/showed öre; they now use
  the declared whole-krona totals stored on agi_declarations (which also
  lets skattekonto auto-settlement match the draw); legacy öre rows keep
  paying öre-exact so pre-deploy bookings still clear 2731

New lib/salary/declared-avgifter.ts implements the SKV computation (per-IU
whole-krona underlag, per-sats sums, youth/växa cap splits, exact integer
math) shared by the AGI generator, the booking split and the preview.
Review overrides route all legs through the same per-category truncation;
basis overrides are inert on money totals (they never reach the filed
IUs); the v1 book route gains override parity with book-run; F-skatt rows
ignore avgifter overrides on every surface. Booked runs show their posted
verifikat instead of a recomputed projection. tax_withheld_override
requires whole kronor. Adversarially verified over three /skeptic rounds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: merge origin/main and re-ratchet the öre-round baseline

The merge brought #1609 (net-pay öresavrundning) whose two new
Math.round(x*100)/100 occurrences are counted against the baseline this
branch had tightened from 637 to 629; 631 keeps the net -6 improvement
without policing already-merged code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): address PR review (hybrid override computation, legacy youth cap, robustness)

CodeRabbit round on #1611, all findings in one pass:

- computeDeclaredAvgifterWithOverrides: one shared hybrid for the AGI
  generator AND the booking split. Overridden rows contribute their manual
  amounts per category; colleagues keep the SKV-exact per-sats underlag
  computation (a FoU override on one employee no longer costs the rest of
  the roster kronor of declared accuracy)
- youth cap keys on the RESOLVED category so legacy null-category rows
  classified as youth by the rate heuristic still get the 25k split
- F-skatt rows zero their avgifter_basis on both booking surfaces and in
  the preview, matching the AGI's isFSkattRow invariant
- preview route: posted-voucher lookup errors return 500 instead of
  masquerading as a booked run with no vouchers; 400/500 tests added
- run page clears stale AGI totals when the tax-payment fetch fails
- SalaryOverridePanel truncates the tax override to whole kronor so the
  schema's .int() cannot bounce a decimal input with a 400
- v1 book route override parity pinned by a lifecycle test
- DECISIONS.md format fixes + superseded entry marked; exempt category
  mapped explicitly; unified truncation-drift band with rationale

Declined (recorded): dating the decision entries 2026-08-13 (bot assumed
UTC; the decisions were made after midnight local time).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): round-2 review nits (shared F-skatt helper, test hygiene)

- isFSkattStatus in declared-avgifter.ts: single source for the F-skatt
  exclusion, consumed by book-run, the v1 book route, the preview route and
  the AGI generator, per the Swedish review's drift-risk finding
- declared-avgifter test suite gets the standard beforeEach cleanup

Declined (recorded for the summary): auto-generated correction voucher for
regenerated legacy periods (data-repair follow-up needing Emil's go); SFF
22 kap. 1 par. citation doubt (verified against lagen.nu and already shipped
in tax-tables.ts); 3740 scope doubt (BAS generic utjamning account, Visma
praxis, matches the user's reference voucher).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 02:22:07 +02:00

108 lines
4.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
}
/**
* Truncate a SEK amount to whole kronor, dropping the öre (öretal bortfaller:
* the whole-krona rule in SFF 2011:1261 22 kap. 1 §).
*
* This is the amount rule for everything Skatteverket-bound: AGI XML fields,
* the declared totals stored on agi_declarations, the skattekonto payment,
* and the 2731 liability booked at salary time (whose öre remainder goes to
* 3740 Öres- och kronutjämning). Truncation, not rounding: 16 073,84 kr is
* declared and drawn as 16 073 kr.
*
* Runs through `roundOre` first so IEEE drift just below an integer
* (16 073,9999999… for a true 16 074,00) cannot lose a whole krona.
* Math.trunc, not Math.floor: dropping öre truncates toward zero, and a
* negative amount must not gain an extra negative krona. The -0 that
* Math.trunc leaves on small negatives is normalized to 0.
*/
export function truncateToWholeKronor(n: number): number {
const whole = Math.trunc(roundOre(n))
return whole === 0 ? 0 : whole
}
/**
* 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
/**
* Maximum |bank payment invoice remaining| (in SEK) that is treated as
* öresavrundning: booked to BAS 3740 (Öres- och kronutjämning) so the invoice
* settles fully: rather than left as a genuine partial payment.
*
* Swedish whole-krona settlements (Bankgiro, Swish, kort) pay an öre-bearing
* invoice total rounded to the nearest krona, so the residual is always strictly
* under 1 krona. A real shortfall is ≥ 1 krona, so this band can never hide one.
*
* NOTE: deliberately looser than `ORE_TOLERANCE` (0,005). That constant is
* float-equalisation; this is an accounting policy band. Keep them distinct:
* never reuse `ORE_TOLERANCE` for settlement rounding.
*/
export const ORE_ROUNDING_SETTLEMENT_MAX = 1.0
/**
* 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))
}