Files
accounted/lib/__tests__/money.test.ts
T
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

106 lines
3.5 KiB
TypeScript
Raw 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.
import { describe, it, expect } from 'vitest'
import {
roundOre,
truncateToWholeKronor,
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('truncateToWholeKronor', () => {
it('drops the öre entirely: truncation, never rounding', () => {
// öretal bortfaller (SFF 2011:1261 22 kap. 1 §): 16 073,84 declares and
// draws as 16 073, and even ,99 never rounds up.
expect(truncateToWholeKronor(16073.84)).toBe(16073)
expect(truncateToWholeKronor(16073.99)).toBe(16073)
expect(truncateToWholeKronor(16073.5)).toBe(16073)
expect(truncateToWholeKronor(0.84)).toBe(0)
})
it('leaves whole-krona amounts untouched', () => {
expect(truncateToWholeKronor(16073)).toBe(16073)
expect(truncateToWholeKronor(0)).toBe(0)
})
it('does not lose a krona to IEEE drift just below an integer', () => {
// 51 158 × 0,3142 style float noise: a true 16 074,00 stored as
// 16 073,999999999998 must not truncate to 16 073.
expect(truncateToWholeKronor(16073.999999999998)).toBe(16074)
expect(truncateToWholeKronor(6.999999999999999)).toBe(7)
})
it('truncates negative amounts toward zero and normalizes -0', () => {
expect(truncateToWholeKronor(-5.99)).toBe(-5)
expect(truncateToWholeKronor(-0.84)).toBe(0)
expect(Object.is(truncateToWholeKronor(-0.84), -0)).toBe(false)
})
})
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)
})
})