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>
This commit is contained in:
Mattsson
2026-08-14 02:22:07 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent fbe4e18730
commit 4e14182a00
28 changed files with 1642 additions and 102 deletions
+37 -1
View File
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest'
import { roundOre, ORE_TOLERANCE, equalOre, isZeroOre, sumOre } from '@/lib/money'
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', () => {
@@ -32,6 +39,35 @@ describe('roundOre', () => {
})
})
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)
+11 -1
View File
@@ -3276,7 +3276,17 @@ export const SalaryEmployeeOverrideSchema = z
// override: it sets the base the engine uses for this month only and does
// not require a reason. The route gates this field to `draft` status.
monthly_salary: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).optional(),
tax_withheld_override: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).nullable().optional(),
// Skatteavdrag is stated in whole kronor (öretal bortfaller, SFF
// 2011:1261 22 kap. 1 §) and the engine's own values already are: an
// öre-bearing override would book 2710 with öre that the whole-krona
// skattekonto draw never clears.
tax_withheld_override: z
.number()
.int('Skatteavdrag anges i hela kronor (öretal bortfaller)')
.nonnegative()
.max(SALARY_OVERRIDE_MAX)
.nullable()
.optional(),
avgifter_amount_override: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).nullable().optional(),
avgifter_basis_override: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).nullable().optional(),
reason: z.string().min(1).max(500).nullable().optional(),
@@ -115,7 +115,7 @@ This step records the payment event but does NOT post the journal entry yet: tha
\`POST /salary-runs/{id}/book\` is the engine-touching step. It posts **2-4 verifikationer** atomically — always the salary and avgifter entries, plus a semesterlöneskuld-accrual entry and/or a löneväxling-pension entry when those apply:
- Verifikation 1: Bruttolön: debit → 7210 / 7220 / 7240 (Löner tjänstemän / företagsledare / styrelsearvoden, by \`employment_type\`), credit → 2710 (Personalskatt, avdragen skatt) + 1930 (utbetalning)
- Verifikation 2: Arbetsgivaravgifter: debit → 7510 (Lagstadgade sociala avgifter), credit → 2731 (Avräkning sociala avgifter: payable to Skatteverket, cleared when arbetsgivardeklarationen is paid)
- Verifikation 2: Arbetsgivaravgifter: debit → 7510 (Lagstadgade sociala avgifter, exact öre), credit → 2731 (Avräkning sociala avgifter: whole kronor, the amount Skatteverket computes from the declared underlag and draws) + 3740 (Öres- och kronutjämning: the remainder, when the exact cost differs)
- Verifikation 3 (if semesterlöneskuld): debit → 7290 (Förändring semesterlöneskuld) + 7519 (sociala avgifter på semester), credit → 2920 (Upplupna semesterlöner) + 2940 (Upplupna sociala avgifter)
- Verifikation 4 (if löneväxling): debit → 7410 (Pensionsförsäkringspremier) + 7533 (Särskild löneskatt på pensionskostnader), credit → 2740 (Skuld pensionsförsäkringar) + 2514 (Beräknad särskild löneskatt); pension = löneväxling × 1.058
+21
View File
@@ -34,6 +34,27 @@ export function roundOre(n: number): number {
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.
*
@@ -245,3 +245,162 @@ describe('generateAgiDeclaration: avgifter overrides on an F-skatt row are ignor
expect(fSkattIu).not.toContain('faltkod="011"')
})
})
describe('generateAgiDeclaration: whole-krona amounts (öretal bortfaller)', () => {
// Öre-bearing roster: hourly-wage taxes and 31,42 % avgifter rarely land on
// whole kronor. AGI amounts are declared in whole kronor with the öre
// dropped (SFF 2011:1261 22 kap. 1 §).
const ORE_ROW_1 = {
...REGULAR_ROW,
gross_salary: 51158,
tax_withheld: 12268.6,
avgifter_basis: 51158,
avgifter_amount: 16073.84,
}
const ORE_ROW_2 = {
...REGULAR_ROW,
employee_id: '22222222-2222-4222-8222-222222222222',
gross_salary: 10000.9,
tax_withheld: 4000.6,
avgifter_basis: 10000.9,
avgifter_amount: 3141.93,
employee: {
personnummer: 'emp2_encrypted',
specification_number: 2,
f_skatt_status: 'a_skatt',
},
}
it('declares FK497 as the sum of per-IU truncated taxes, not the truncated sum', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueHappyPath(enqueueMany, [ORE_ROW_1, ORE_ROW_2])
const result = await generateAgiDeclaration({ supabase: supabase as never, ...ARGS })
expect(result.ok).toBe(true)
if (!result.ok) return
// Per-IU FK001 truncates each employee's tax: 12 268 and 4 000.
expect(iuBlockFor(result.xml, '199001011234')).toContain(
'<gem:AvdrPrelSkatt faltkod="001">12268</gem:AvdrPrelSkatt>',
)
expect(iuBlockFor(result.xml, '198506159876')).toContain(
'<gem:AvdrPrelSkatt faltkod="001">4000</gem:AvdrPrelSkatt>',
)
// FK497 must equal the sum of the truncated FK001 values (16 268), NOT
// the truncated öre-exact sum (trunc(16 269,20) = 16 269): the HU total
// has to agree with what the IUs actually declare.
expect(result.totals.totalTax).toBe(16268)
expect(result.xml).toContain(
'<gem:SummaSkatteavdr faltkod="497">16268</gem:SummaSkatteavdr>',
)
})
it('declares FK487 and per-IU underlag in whole kronor', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueHappyPath(enqueueMany, [ORE_ROW_1, ORE_ROW_2])
const result = await generateAgiDeclaration({ supabase: supabase as never, ...ARGS })
expect(result.ok).toBe(true)
if (!result.ok) return
// FK011 truncates the öre-bearing gross: 10 000,90 declares as 10 000.
expect(iuBlockFor(result.xml, '198506159876')).toContain(
'<gem:KontantErsattningUlagAG faltkod="011">10000</gem:KontantErsattningUlagAG>',
)
// FK487 is Skatteverket's own computation (IK587): per sats on the
// whole-krona underlag sum. trunc((51 158 + 10 000) × 31,42 %) =
// trunc(19 215,84) = 19 215. This is the same number the salary booking
// credits on 2731 and the skattekonto draw settles.
expect(result.totals.totalAvgifterBasis).toBe(61158)
expect(result.totals.totalAvgifterAmount).toBe(19215)
expect(result.xml).toContain(
'<gem:SummaArbAvgSlf faltkod="487">19215</gem:SummaArbAvgSlf>',
)
})
it('computes FK487 per sats on summed underlag, not by truncating the öre-exact sum', async () => {
// Two employees at 30 000,99 kr: öre-exact avgifter are 9 426,51 each
// (18 853,02 in total), but Skatteverket declares 30 000 per IU and
// computes trunc(60 000 × 31,42 %) = 18 852: one whole krona below the
// truncated öre-exact sum. The filed FK487 must be Skatteverket's
// number, or kontroll B_006 flags the filing and the skattekonto draw
// diverges from the booked 2731.
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueHappyPath(enqueueMany, [
{ ...ORE_ROW_1, gross_salary: 30000.99, avgifter_basis: 30000.99, avgifter_amount: 9426.51, tax_withheld: 9000 },
{ ...ORE_ROW_2, gross_salary: 30000.99, avgifter_basis: 30000.99, avgifter_amount: 9426.51, tax_withheld: 9000 },
])
const result = await generateAgiDeclaration({ supabase: supabase as never, ...ARGS })
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.totals.totalAvgifterBasis).toBe(60000)
expect(result.totals.totalAvgifterAmount).toBe(18852)
expect(result.xml).toContain(
'<gem:SummaArbAvgSlf faltkod="487">18852</gem:SummaArbAvgSlf>',
)
// The category breakdown cross-foots exactly against the total.
expect(result.totals.avgifterByCategory).toEqual({
standard: { basis: 60000, amount: 18852 },
})
})
it('keeps colleagues SKV-exact when one employee carries an amount override', async () => {
// FoU-style override on E1 must not cost E2 its per-sats declared
// amount: E2 declares trunc(30 000 × 31,42 %) = 9 426 from its filed
// underlag while E1 contributes its manual 7 855.
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueHappyPath(enqueueMany, [
{ ...ORE_ROW_1, avgifter_amount_override: 7855 },
{ ...ORE_ROW_2, gross_salary: 30000.99, avgifter_basis: 30000.99, avgifter_amount: 9426.51 },
])
const result = await generateAgiDeclaration({ supabase: supabase as never, ...ARGS })
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.totals.totalAvgifterAmount).toBe(7855 + 9426)
expect(result.xml).toContain(
`<gem:SummaArbAvgSlf faltkod="487">${7855 + 9426}</gem:SummaArbAvgSlf>`,
)
const catSum = Object.values(result.totals.avgifterByCategory).reduce(
(s, c) => s + (c?.amount ?? 0),
0,
)
expect(catSum).toBe(result.totals.totalAvgifterAmount)
})
it('a basis-only override is inert on FK487 and the stored totals (filed underlag rules)', async () => {
// An avgifter_basis_override never reaches the filed IU fields: FK011
// stays the un-overridden gross, and Skatteverket computes IK587 from
// that. Letting the override steer FK487 would file 12 568 against IUs
// that prove 16 073 and underpay the skattekonto by 3 505 kr.
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueHappyPath(enqueueMany, [
{
...ORE_ROW_1,
avgifter_basis_override: 40000,
avgifter_amount_override: null,
},
])
const result = await generateAgiDeclaration({ supabase: supabase as never, ...ARGS })
expect(result.ok).toBe(true)
if (!result.ok) return
expect(iuBlockFor(result.xml, '199001011234')).toContain(
'<gem:KontantErsattningUlagAG faltkod="011">51158</gem:KontantErsattningUlagAG>',
)
expect(result.totals.totalAvgifterBasis).toBe(51158)
expect(result.totals.totalAvgifterAmount).toBe(16073)
expect(result.xml).toContain(
'<gem:SummaArbAvgSlf faltkod="487">16073</gem:SummaArbAvgSlf>',
)
})
})
+30 -2
View File
@@ -137,9 +137,37 @@ describe('generateAGIXml: Huvuduppgift (HU)', () => {
expect(xml).not.toMatch(/<gem:HU>[\s\S]*<AvdragenSkatt[\s\S]*<\/gem:HU>/)
})
it('emits total employer contributions as SummaArbAvgSlf FK487', () => {
it('emits total employer contributions as SummaArbAvgSlf FK487 with öre truncated', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:SummaArbAvgSlf faltkod="487">24076</gem:SummaArbAvgSlf>')
// 24 075,50 declares as 24 075: öretal bortfaller (SFF 2011:1261
// 22 kap. 1 §), truncation, never rounding. Math.round would declare
// 1 kr more than Skatteverket draws from the skattekonto.
expect(xml).toContain('<gem:SummaArbAvgSlf faltkod="487">24075</gem:SummaArbAvgSlf>')
})
it('truncates öre on every HU amount, even at ,99', () => {
const oreTotals: AGITotals = {
...totals,
totalTax: 12268,
totalAvgifterAmount: 16073.84,
totalSjuklonekostnad: 1234.99,
}
const xml = generateAGIXml(company, employees, oreTotals)
expect(xml).toContain('<gem:SummaSkatteavdr faltkod="497">12268</gem:SummaSkatteavdr>')
expect(xml).toContain('<gem:SummaArbAvgSlf faltkod="487">16073</gem:SummaArbAvgSlf>')
expect(xml).toContain('<gem:TotalSjuklonekostnad faltkod="499">1234</gem:TotalSjuklonekostnad>')
})
it('does not let IEEE drift under an exact krona lose it in truncation', () => {
// 51 158 × 0,3142 = 16 073.843600000001 in floats; a raw Math.trunc of
// a value like 16 074 stored as 16 073.999999999 would drop a whole
// krona. truncateToWholeKronor rounds to öre first.
const driftTotals: AGITotals = {
...totals,
totalAvgifterAmount: 16073.999999999998,
}
const xml = generateAGIXml(company, employees, driftTotals)
expect(xml).toContain('<gem:SummaArbAvgSlf faltkod="487">16074</gem:SummaArbAvgSlf>')
})
it('does NOT emit FK060/061/062: those field codes do not exist in HU', () => {
@@ -0,0 +1,203 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events'
import {
computeDeclaredAvgifter,
computeDeclaredAvgifterWithOverrides,
declaredAvgifterByCategory,
reportingCategory,
resolveDeclaredAvgifterParams,
} from '../declared-avgifter'
const PARAMS = { standardRate: 0.3142, youthCap: 25000, vaxaCap: 35000 }
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('computeDeclaredAvgifter', () => {
it('reproduces Skatteverket: per-sats on whole-krona underlag sums, not per-employee öre sums', () => {
// The refuting counterexample from the skeptic pass: 4 hourly employees
// at 30 000,99 kr. Per-employee öre math gives 4 × 9 426,31 = 37 705,24
// → 37 705 truncated once, but Skatteverket declares 30 000 per IU and
// computes trunc(120 000 × 31,42 %) = trunc(37 704,00) = 37 704.
const rows = Array.from({ length: 4 }, () => ({
basis: 30000.99,
rate: 0.3142,
category: 'standard',
}))
const declared = computeDeclaredAvgifter(rows, PARAMS)
expect(declared.totalUnderlag).toBe(120000)
expect(declared.totalAmount).toBe(37704)
})
it('splits a youth above the monthly cap between the reduced and full sats', () => {
// Underlag 30 000, youth cap 25 000: trunc(25 000 × 20,81 %) = 5 202
// (öretal bortfaller on 5 202,50) + trunc(5 000 × 31,42 %) = 1 571.
const declared = computeDeclaredAvgifter(
[{ basis: 30000, rate: 0.2081, category: 'youth' }],
PARAMS,
)
expect(declared.totalAmount).toBe(5202 + 1571)
const byCategory = declaredAvgifterByCategory(declared)
expect(byCategory.youth).toEqual({ basis: 25000, amount: 5202 })
expect(byCategory.standard).toEqual({ basis: 5000, amount: 1571 })
})
it('splits växa-stöd above the cap and reports both parts under standard', () => {
// Underlag 40 000, växa cap 35 000: trunc(35 000 × 10,21 %) = 3 573
// (öretal bortfaller on 3 573,50) + trunc(5 000 × 31,42 %) = 1 571.
// Växa-stöd has no own category in the AGI map (FK062/FK063 are IU
// flags), so everything folds into standard.
const declared = computeDeclaredAvgifter(
[{ basis: 40000, rate: 0.1021, category: 'vaxa_stod' }],
PARAMS,
)
expect(declared.totalAmount).toBe(3573 + 1571)
expect(declaredAvgifterByCategory(declared)).toEqual({
standard: { basis: 40000, amount: 5144 },
})
})
it('caps a legacy null-category row that the rate heuristic classifies as youth', () => {
// Legacy rows predate the avgifter_category column: the rate heuristic
// resolves them, and the youth cap must key on that RESOLVED category or
// a legacy youth above the cap gets the reduced sats on the full
// underlag (1 041 kr too little declared on this fixture).
const declared = computeDeclaredAvgifter(
[{ basis: 30000, rate: 0.2081, category: null }],
PARAMS,
)
expect(declared.totalAmount).toBe(5202 + 1571)
})
it('applies no cap split when the cap is null or the category is uncapped', () => {
const noCaps = computeDeclaredAvgifter(
[{ basis: 30000, rate: 0.2081, category: 'youth' }],
{ standardRate: 0.3142, youthCap: null, vaxaCap: null },
)
expect(noCaps.totalAmount).toBe(Math.trunc((30000 * 2081) / 10000))
const reduced = computeDeclaredAvgifter(
[{ basis: 20000.5, rate: 0.1021, category: 'reduced_65plus' }],
PARAMS,
)
expect(reduced.totalUnderlag).toBe(20000)
expect(reduced.totalAmount).toBe(2042)
expect(declaredAvgifterByCategory(reduced)).toEqual({
reduced65plus: { basis: 20000, amount: 2042 },
})
})
it('skips F-skatt/exempt rows (zero basis or zero rate)', () => {
const declared = computeDeclaredAvgifter(
[
{ basis: 0, rate: 0.3142, category: 'standard' },
{ basis: 15000, rate: 0, category: 'exempt' },
{ basis: 10000, rate: 0.3142, category: 'standard' },
],
PARAMS,
)
expect(declared.totalUnderlag).toBe(10000)
expect(declared.totalAmount).toBe(3142)
})
it('always cross-foots: the category breakdown sums exactly to the total', () => {
const declared = computeDeclaredAvgifter(
[
{ basis: 30000.99, rate: 0.3142, category: 'standard' },
{ basis: 28000.45, rate: 0.2081, category: 'youth' },
{ basis: 12345.67, rate: 0.1021, category: 'reduced_65plus' },
{ basis: 41000.01, rate: 0.1021, category: 'vaxa_stod' },
],
PARAMS,
)
const byCategory = declaredAvgifterByCategory(declared)
const catSum = Object.values(byCategory).reduce((s, c) => s + (c?.amount ?? 0), 0)
expect(catSum).toBe(declared.totalAmount)
const basisSum = Object.values(byCategory).reduce((s, c) => s + (c?.basis ?? 0), 0)
expect(basisSum).toBe(declared.totalUnderlag)
expect(Number.isInteger(declared.totalAmount)).toBe(true)
expect(Number.isInteger(declared.totalUnderlag)).toBe(true)
})
})
describe('computeDeclaredAvgifterWithOverrides', () => {
it('keeps colleagues SKV-exact when one employee carries an amount override', () => {
// FoU-avdrag override 7 855 on E1; E2+E3 are ordinary öre-wage rows.
// The colleagues must still declare trunc(60 000 × 31,42 %) = 18 852
// (per-sats on summed whole-krona underlag), NOT a truncation of their
// öre-exact charges: total = 18 852 + 7 855.
const declared = computeDeclaredAvgifterWithOverrides(
[
{ basis: 51158, rate: 0.3142, category: 'standard', overrideAmount: 7855 },
{ basis: 30000.99, rate: 0.3142, category: 'standard' },
{ basis: 30000.99, rate: 0.3142, category: 'standard' },
],
PARAMS,
)
expect(declared.totalAmount).toBe(18852 + 7855)
const catSum = Object.values(declared.byCategory).reduce((s, c) => s + (c?.amount ?? 0), 0)
expect(catSum).toBe(declared.totalAmount)
})
it('degenerates to the pure underlag computation when nothing is overridden', () => {
const rows = [
{ basis: 30000.99, rate: 0.3142, category: 'standard' },
{ basis: 28000, rate: 0.2081, category: 'youth' },
]
const hybrid = computeDeclaredAvgifterWithOverrides(rows, PARAMS)
const pure = computeDeclaredAvgifter(rows, PARAMS)
expect(hybrid.totalAmount).toBe(pure.totalAmount)
expect(hybrid.totalUnderlag).toBe(pure.totalUnderlag)
})
it('truncates override amounts per category and cross-foots', () => {
const declared = computeDeclaredAvgifterWithOverrides(
[
{ basis: 51158, rate: 0.3142, category: 'standard', overrideAmount: 16075.9 },
],
PARAMS,
)
expect(declared.totalAmount).toBe(16075)
expect(declared.byCategory.standard?.amount).toBe(16075)
expect(declared.byCategory.standard?.basis).toBe(51158)
})
})
describe('reportingCategory', () => {
it('maps DB categories and falls back to the rate heuristic for legacy nulls', () => {
expect(reportingCategory({ rate: 0.3142, category: 'standard' })).toBe('standard')
expect(reportingCategory({ rate: 0.1021, category: 'reduced_65plus' })).toBe('reduced65plus')
expect(reportingCategory({ rate: 0.1021, category: 'vaxa_stod' })).toBe('standard')
expect(reportingCategory({ rate: 0.2081, category: 'youth' })).toBe('youth')
expect(reportingCategory({ rate: 0.3142, category: null })).toBe('standard')
expect(reportingCategory({ rate: 0.1021, category: null })).toBe('reduced65plus')
expect(reportingCategory({ rate: 0.2081, category: null })).toBe('youth')
})
})
describe('resolveDeclaredAvgifterParams', () => {
it('reads the frozen payroll-config snapshot', () => {
expect(
resolveDeclaredAvgifterParams({
avgifterTotal: 0.3142,
avgifterYouthSalaryCap: 25000,
avgifterVaxaStodCap: 35000,
}),
).toEqual({ standardRate: 0.3142, youthCap: 25000, vaxaCap: 35000 })
})
it('falls back to the statutory 31,42 % and no caps for legacy runs', () => {
expect(resolveDeclaredAvgifterParams(null)).toEqual({
standardRate: 0.3142,
youthCap: null,
vaxaCap: null,
})
expect(resolveDeclaredAvgifterParams({ avgifterYouthSalaryCap: 'bogus' })).toEqual({
standardRate: 0.3142,
youthCap: null,
vaxaCap: null,
})
})
})
+145 -4
View File
@@ -41,6 +41,9 @@ interface EmployeeOverrides {
tax_withheld?: number
net_salary?: number
avgifter_amount?: number
avgifter_basis?: number
avgifter_category?: string | null
avgifter_amount_overridden?: boolean
vacation_accrual?: number
vacation_accrual_avgifter?: number
default_dimensions?: Record<string, string>
@@ -64,6 +67,8 @@ function makeEmployee(overrides: EmployeeOverrides = {}) {
net_salary: 23000,
avgifter_amount: 9426,
avgifter_rate: 0.3142,
avgifter_basis: 30000,
avgifter_category: 'standard',
vacation_accrual: 0,
vacation_accrual_avgifter: 0,
line_items: [],
@@ -382,9 +387,12 @@ describe('salary entries: dimensions propagation (PR8)', () => {
})
it('splits avgifter per bag with a single aggregated 2731 liability', async () => {
// avgifter_basis undefined = legacy caller shape: the declared-avgifter
// split is skipped and 2731 takes the full öre-exact liability, which is
// what this test asserts below.
const run = makeRun([
makeEmployee({ employee_id: 'a', avgifter_amount: 9426.505, default_dimensions: { '1': 'KS01' } }),
makeEmployee({ employee_id: 'b', avgifter_amount: 9426.505, default_dimensions: { '1': 'KS02' } }),
makeEmployee({ employee_id: 'a', avgifter_amount: 9426.505, avgifter_basis: undefined, default_dimensions: { '1': 'KS01' } }),
makeEmployee({ employee_id: 'b', avgifter_amount: 9426.505, avgifter_basis: undefined, default_dimensions: { '1': 'KS02' } }),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const avgifter = entryByDescription('Arbetsgivaravgifter')
@@ -396,11 +404,144 @@ describe('salary entries: dimensions propagation (PR8)', () => {
const liability = linesOn(avgifter, '2731')
expect(liability).toHaveLength(1)
expect(liability[0].dimensions).toBeUndefined()
// Balance by construction: credit equals the sum of the ROUNDED debits,
// even when the partition rounds differently from the raw total.
// Balance by construction: without avgifter_basis (legacy caller shape)
// 2731 carries the full sum of the ROUNDED debits, even when the
// partition rounds differently from the raw total, and no 3740 appears.
expect(liability[0].credit_amount).toBe(
Math.round(expense.reduce((s, l) => s + l.debit_amount, 0) * 100) / 100,
)
expect(linesOn(avgifter, '3740')).toHaveLength(0)
assertBalanced(avgifter)
})
it('books 2731 in whole kronor and the öre remainder on 3740', async () => {
// The reported first-lönekörning case: 51 158 kr gross at 31,42 % gives
// avgifter 16 073,8436 → 16 073,84 booked cost. Skatteverket computes
// trunc(51 158 × 31,42 %) = 16 073 from the declared underlag and draws
// that, so the liability must be 16 073 and the 84 öre settle as
// öresutjämning: crediting 2731 with the öre would leave a perpetual
// residual after the whole-krona skattekonto draw.
const run = makeRun([
makeEmployee({
gross_salary: 51158,
tax_withheld: 12268,
net_salary: 38890,
avgifter_amount: 16073.84,
avgifter_basis: 51158,
}),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const avgifter = entryByDescription('Arbetsgivaravgifter')
expect(linesOn(avgifter, '7510')[0].debit_amount).toBe(16073.84)
expect(linesOn(avgifter, '2731')[0].credit_amount).toBe(16073)
const utjamning = linesOn(avgifter, '3740')
expect(utjamning).toHaveLength(1)
expect(utjamning[0].credit_amount).toBe(0.84)
expect(utjamning[0].debit_amount).toBe(0)
expect(utjamning[0].line_description).toContain('Öres- och kronutjämning')
assertBalanced(avgifter)
})
it('books the declared per-sats amount on 2731, kronor of utjämning included (öre wages)', async () => {
// Skatteverket sums the whole-krona per-IU underlag before applying the
// sats: two employees at 30 000,99 kr declare 30 000 each, so SKV draws
// trunc(60 000 × 31,42 %) = 18 852 while the öre-exact cost is
// 2 × 9 426,51 = 18 853,02. The 1,02 kr difference is real utjämning:
// truncating the öre-exact sum (18 853) would leave 1 kr stuck on 2731.
const run = makeRun([
makeEmployee({
employee_id: 'a',
avgifter_amount: 9426.51,
avgifter_basis: 30000.99,
}),
makeEmployee({
employee_id: 'b',
avgifter_amount: 9426.51,
avgifter_basis: 30000.99,
}),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const avgifter = entryByDescription('Arbetsgivaravgifter')
expect(linesOn(avgifter, '2731')[0].credit_amount).toBe(18852)
expect(linesOn(avgifter, '3740')[0].credit_amount).toBe(1.02)
assertBalanced(avgifter)
})
it('falls back to the öre-exact liability when unflagged amounts diverge from the underlag', async () => {
// No override flag but the stored amount is unrelated to basis × sats
// (corrupt or legacy data): the magnitude band rejects the declared
// split, so no utjämning is manufactured and 2731 takes the full amount.
const run = makeRun([
makeEmployee({ avgifter_amount: 25000.5, avgifter_basis: 30000 }),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const avgifter = entryByDescription('Arbetsgivaravgifter')
expect(linesOn(avgifter, '2731')[0].credit_amount).toBe(25000.5)
expect(linesOn(avgifter, '3740')).toHaveLength(0)
assertBalanced(avgifter)
})
it('keeps a flagged amount override on 2731 and books only the truncation remainder to 3740', async () => {
// A small upward override (16 075,90 against declared-from-basis 16 073)
// sits INSIDE the magnitude band: without the explicit flag, the split
// would book 2731 = 16 073 and launder the operator's +2,06 kr
// adjustment as öresutjämning. The flag switches to the override mirror
// (per-category truncation, the same number the AGI stores and the
// payment pays): 2731 = 16 075, and only 90 öre book as utjämning.
const run = makeRun([
makeEmployee({
avgifter_amount: 16075.9,
avgifter_basis: 51158,
avgifter_amount_overridden: true,
}),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const avgifter = entryByDescription('Arbetsgivaravgifter')
expect(linesOn(avgifter, '2731')[0].credit_amount).toBe(16075)
expect(linesOn(avgifter, '3740')[0].credit_amount).toBe(0.9)
assertBalanced(avgifter)
})
it('mixes an overridden employee with computed colleagues without stranding öre (FoU case)', async () => {
// Downward FoU-avdrag override 7 855 next to a colleague's computed
// 16 073,84: the colleague keeps the SKV-exact declared amount
// (trunc(51 158 × 31,42 %) = 16 073) and the override contributes 7 855
// → 2731 = 23 928 (what the AGI stores and the payment pays), 84 öre to
// 3740. Booking, declaration and payment stay one number.
const run = makeRun([
makeEmployee({
employee_id: 'a',
avgifter_amount: 7855,
avgifter_basis: 51158,
avgifter_amount_overridden: true,
}),
makeEmployee({
employee_id: 'b',
avgifter_amount: 16073.84,
avgifter_basis: 51158,
}),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const avgifter = entryByDescription('Arbetsgivaravgifter')
expect(linesOn(avgifter, '2731')[0].credit_amount).toBe(23928)
expect(linesOn(avgifter, '3740')[0].credit_amount).toBe(0.84)
assertBalanced(avgifter)
})
it('emits no 3740 line when the avgifter total is already whole kronor', async () => {
const run = makeRun([
// 30 000 × 0,3142 = 9 426,00 exactly.
makeEmployee({ avgifter_amount: 9426 }),
])
await createSalaryRunEntries(makeSupabase(), 'company-1', 'user-1', run)
const avgifter = entryByDescription('Arbetsgivaravgifter')
expect(linesOn(avgifter, '2731')[0].credit_amount).toBe(9426)
expect(linesOn(avgifter, '3740')).toHaveLength(0)
assertBalanced(avgifter)
})
+3
View File
@@ -107,6 +107,9 @@ export const SALARY_ACCOUNTS = {
// Employer contributions
AVGIFTER_EXPENSE: '7510', // Lagstadgade sociala avgifter (debit)
AVGIFTER_LIABILITY: '2731', // Avräkning sociala avgifter (credit)
// Whole-krona remainder: 2731 holds what Skatteverket actually draws
// (hela kronor, öretal bortfaller), the öre difference lands here.
ORESUTJAMNING: '3740', // Öres- och kronutjämning (credit)
// Vacation accrual
VACATION_ACCRUAL_EXPENSE: '7290', // Förändring semesterlöneskuld (debit)
+70 -55
View File
@@ -31,6 +31,12 @@ import {
} from './xml-generator'
import type { AGIEmployeeData, AGICompanyData, AGITotals } from './xml-generator'
import { eventBus } from '@/lib/events'
import { truncateToWholeKronor } from '@/lib/money'
import {
computeDeclaredAvgifterWithOverrides,
isFSkattStatus,
resolveDeclaredAvgifterParams,
} from '../declared-avgifter'
import type { Logger } from '@/lib/logger'
// Strict runtime validation of the joined salary_run_employees row. Without
@@ -135,7 +141,7 @@ function sumLineItemAmounts(
// override must not resurrect them, or the filing would claim social charges
// on pay whose IU simultaneously asserts FK131 (not subject to them).
function isFSkattRow(sre: SalaryRunEmployeeRow): boolean {
return sre.employee?.f_skatt_status === 'f_skatt'
return isFSkattStatus(sre.employee?.f_skatt_status)
}
export async function generateAgiDeclaration(
@@ -397,37 +403,51 @@ export async function generateAgiDeclaration(
(e.absenceEvents?.length ?? 0) > 0,
)
// 5. Build totals: avgifter by category (with rate-heuristic fallback for legacy runs).
// 5. Build totals: whole-krona declared avgifter (öretal bortfaller, SFF
// 2011:1261 22 kap. 1 §). Skatteverket does not use the filed FK487 for
// the beslut: it recomputes the avgift from the declared per-IU underlag,
// per sats on the whole-krona sums (IK587, kontroll B_006), and draws that
// amount from the skattekonto. computeDeclaredAvgifter mirrors the
// computation, and the category map folds from the same cells so the
// breakdown always cross-foots exactly against the total.
// Removed-from-AGI rows (FK205 borttag) are tombstones: they must not
// contribute to FK497/FK487/FK499 because the prior submission's amounts
// remain on file at Skatteverket; the borttag just removes the IU itself.
const activeEmployees = parsedRows.filter((sre) => !sre.removed_from_agi)
const avgifterByCategory: AGITotals['avgifterByCategory'] = {}
for (const sre of activeEmployees) {
const dbCategory = sre.avgifter_category ?? null
const category = dbCategory
? dbCategory === 'reduced_65plus'
? 'reduced65plus'
: dbCategory === 'vaxa_stod'
? 'standard'
: dbCategory
: sre.avgifter_rate <= 0.1022
? 'reduced65plus'
: sre.avgifter_rate <= 0.2082
? 'youth'
: 'standard'
const cat = (avgifterByCategory as Record<string, { basis: number; amount: number }>)[
category
] || { basis: 0, amount: 0 }
// F-skatt rows contribute 0 regardless of overrides (see isFSkattRow).
cat.basis += isFSkattRow(sre) ? 0 : sre.avgifter_basis_override ?? sre.avgifter_basis
cat.amount += isFSkattRow(sre) ? 0 : sre.avgifter_amount_override ?? sre.avgifter_amount
;(avgifterByCategory as Record<string, { basis: number; amount: number }>)[category] = cat
}
const totalAvgifterAmount = Object.values(avgifterByCategory).reduce(
(sum, cat) => sum + (cat?.amount ?? 0),
0,
// F-skatt rows contribute 0 regardless of overrides (see isFSkattRow).
const effectiveBasis = (sre: SalaryRunEmployeeRow): number =>
isFSkattRow(sre) ? 0 : (sre.avgifter_basis_override ?? sre.avgifter_basis) || 0
// One computation for every roster shape (computeDeclaredAvgifterWithOverrides):
// rows WITHOUT an avgifter_amount_override run Skatteverket's underlag
// computation on the FILED basis (never basis overrides: those don't reach
// the IU fields, so Skatteverket computes from the filed underlag
// regardless, and letting them steer FK487 or the payment would file an
// FK487 contradicting the declaration's own IUs and underpay the
// skattekonto). Rows WITH an amount override (FoU-avdrag and other manual
// adjustments) contribute their manual amounts per category instead: a
// manual adjustment on one employee must not cost the colleagues their
// SKV-exact declared amounts. The salary booking's split runs the same
// function, so booked 2731 == filed FK487 == stored == paid.
const declared = computeDeclaredAvgifterWithOverrides(
activeEmployees.map((sre) => {
const overridden = !isFSkattRow(sre) && sre.avgifter_amount_override != null
return {
// Overridden rows report their effective (override-coalesced) basis;
// computing rows use the FILED basis.
basis: overridden ? effectiveBasis(sre) : isFSkattRow(sre) ? 0 : sre.avgifter_basis || 0,
rate: sre.avgifter_rate,
category: sre.avgifter_category ?? null,
overrideAmount: overridden ? sre.avgifter_amount_override : null,
}
}),
resolveDeclaredAvgifterParams(
(run.calculation_params as Record<string, unknown> | null) ?? null,
),
)
const avgifterByCategory = declared.byCategory as AGITotals['avgifterByCategory']
const totalAvgifterAmount = declared.totalAmount
const totalAvgifterBasis = declared.totalUnderlag
// FK499 sjuklönekostnad: sum of paid sjuklön (days 2-14) across all
// employees. Day 1 is karens (unpaid); day 15+ is Försäkringskassan.
@@ -454,20 +474,22 @@ export async function generateAgiDeclaration(
// run.total_tax, which includes removed rows). Same for FK487.
// Coalesce override → computed so manual jämkning/FoU adjustments flow
// into the filed declaration.
//
// AGI amounts are whole kronor (öretal bortfaller, SFF 2011:1261
// 22 kap. 1 §). Each IU serialises FK001 truncated, so the HU total must
// be the sum of the per-IU truncated values: truncating the öre-exact sum
// instead could land 1 kr above what the IUs actually declare.
const totalTax = activeEmployees.reduce(
(sum, sre) => sum + ((sre.tax_withheld_override ?? sre.tax_withheld) || 0),
(sum, sre) =>
sum + truncateToWholeKronor((sre.tax_withheld_override ?? sre.tax_withheld) || 0),
0,
)
const totals: AGITotals = {
totalTax: Math.round(totalTax * 100) / 100,
// F-skatt rows contribute 0 regardless of overrides (see isFSkattRow).
totalAvgifterBasis: activeEmployees.reduce(
(s, e) => s + (isFSkattRow(e) ? 0 : (e.avgifter_basis_override ?? e.avgifter_basis) || 0),
0,
),
totalAvgifterAmount: Math.round(totalAvgifterAmount * 100) / 100,
totalSjuklonekostnad: Math.round(totalSjuklonekostnad * 100) / 100,
totalTax,
totalAvgifterBasis,
totalAvgifterAmount,
totalSjuklonekostnad: truncateToWholeKronor(totalSjuklonekostnad),
avgifterByCategory,
}
@@ -549,13 +571,14 @@ export async function generateAgiDeclaration(
xml_content: xml,
individuppgifter,
total_gross: run.total_gross,
total_tax: run.total_tax,
// Declared whole-krona totals, exactly as serialised into the XML
// (FK497/FK487): the amounts Skatteverket computes from the declared
// underlag and draws from the skattekonto (modulo the two documented
// krona-scale approximations in declared-avgifter.ts). This is what
// agi-tax-settlement matches the draw against. run.total_tax would
// drift: it keeps öre and includes removed rows.
total_tax: totals.totalTax,
total_avgifter_basis: totals.totalAvgifterBasis,
// Use the per-category sum that drives the XML rather than the
// run-level denormalised total. Both should agree, but a
// round-then-sum vs sum-then-round can produce öre drift; the
// agi_declarations row should align with what was actually
// serialised into the XML (which Skatteverket sees).
total_avgifter: totals.totalAvgifterAmount,
employee_count: employeeData.length,
is_correction: true,
@@ -578,13 +601,9 @@ export async function generateAgiDeclaration(
xml_content: xml,
individuppgifter,
total_gross: run.total_gross,
total_tax: run.total_tax,
// Declared whole-krona totals: see the update branch above.
total_tax: totals.totalTax,
total_avgifter_basis: totals.totalAvgifterBasis,
// Use the per-category sum that drives the XML rather than the
// run-level denormalised total. Both should agree, but a
// round-then-sum vs sum-then-round can produce öre drift; the
// agi_declarations row should align with what was actually
// serialised into the XML (which Skatteverket sees).
total_avgifter: totals.totalAvgifterAmount,
employee_count: employeeData.length,
})
@@ -616,14 +635,10 @@ export async function generateAgiDeclaration(
xml_content: xml,
individuppgifter,
total_gross: run.total_gross,
total_tax: run.total_tax,
// Declared whole-krona totals: see the update branch above.
total_tax: totals.totalTax,
total_avgifter_basis: totals.totalAvgifterBasis,
// Use the per-category sum that drives the XML rather than the
// run-level denormalised total. Both should agree, but a
// round-then-sum vs sum-then-round can produce öre drift; the
// agi_declarations row should align with what was actually
// serialised into the XML (which Skatteverket sees).
total_avgifter: totals.totalAvgifterAmount,
total_avgifter: totals.totalAvgifterAmount,
employee_count: employeeData.length,
is_correction: true,
salary_run_id: run.id,
+6 -1
View File
@@ -1,5 +1,6 @@
import { decryptPersonnummer } from '../personnummer'
import { isOrgNumberShaped } from '@/lib/invariants/org-number'
import { truncateToWholeKronor } from '@/lib/money'
/**
* AGI XML generator: Arbetsgivardeklaration på individnivå.
@@ -692,8 +693,12 @@ function escapeXml(str: string): string {
.replace(/'/g, '&apos;')
}
// AGI amounts are stated in whole kronor with the öre dropped (öretal
// bortfaller, SFF 2011:1261 22 kap. 1 §): truncation, never rounding.
// Math.round here would declare 16 074 kr for an underlag-computed
// 16 073,84 kr while Skatteverket draws 16 073 kr from the skattekonto.
function formatAmount(amount: number): string {
return Math.round(amount).toString()
return truncateToWholeKronor(amount).toString()
}
/**
+21 -1
View File
@@ -25,6 +25,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Logger } from '@/lib/logger'
import { isFSkattStatus } from '@/lib/salary/declared-avgifter'
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
import { effectiveNetPayout } from '@/lib/salary/payment/effective-net'
@@ -165,9 +166,28 @@ async function bookLoadedRun(
(sre.net_salary as number) +
((sre.tax_withheld as number) -
((sre.tax_withheld_override as number | null) ?? (sre.tax_withheld as number))),
// F-skatt payees form no underlag for arbetsgivaravgifter: the AGI
// hard-ignores avgifter overrides on such rows (isFSkattRow), so the
// booking must too, or the ledger would carry social charges the
// declaration provably excludes.
avgifter_amount:
(sre.avgifter_amount_override as number | null) ?? (sre.avgifter_amount as number),
isFSkattStatus(sre.employee?.f_skatt_status)
? (sre.avgifter_amount as number)
: (sre.avgifter_amount_override as number | null) ?? (sre.avgifter_amount as number),
avgifter_rate: sre.avgifter_rate as number,
// Declared-avgifter inputs: the 2731 liability books the whole-krona
// amount Skatteverket computes from the underlag (declared-avgifter.ts).
// Deliberately the UN-overridden basis: a basis override never
// reaches the filed IU fields, so Skatteverket computes from these
// values regardless. Zeroed for F-skatt rows (the AGI's isFSkattRow
// invariant: their pay forms no underlag). An amount override is
// flagged instead: the split then mirrors the AGI's override path.
avgifter_basis:
isFSkattStatus(sre.employee?.f_skatt_status) ? 0 : (sre.avgifter_basis as number),
avgifter_category: (sre.avgifter_category as string | null) ?? null,
avgifter_amount_overridden:
!isFSkattStatus(sre.employee?.f_skatt_status) &&
(sre.avgifter_amount_override as number | null) != null,
vacation_accrual: sre.vacation_accrual as number,
vacation_accrual_avgifter: sre.vacation_accrual_avgifter as number,
// Dimensions PR8: read-at-book from the employee row, the run
+284
View File
@@ -0,0 +1,284 @@
import { truncateToWholeKronor } from '@/lib/money'
/**
* Declared arbetsgivaravgifter: the whole-krona amount Skatteverket computes
* from an AGI and draws from the skattekonto.
*
* Skatteverket does not use the filed FK487 for the beslut: it recomputes
* the avgift (IK587, kontroll B_006 in Teknisk beskrivning §11.6.1) from the
* declared per-IU underlag: per-IU underlag in whole kronor (öretal
* bortfaller, SFF 2011:1261 22 kap. 1 §), summed per avgiftssats, the avgift
* computed per sats on that sum with öretal dropped per sats, then summed.
* An öre-exact per-employee sum truncated once at the end (or rounded, as
* the old code did) drifts kronor away from that on any roster with
* öre-bearing wages: 4 hourly employees at 30 000,99 kr give
* 4 × roundOre(30 000,99 × 0,3142) = 37 705,24 → 37 705, while Skatteverket
* computes trunc(4 × 30 000 × 0,3142) = 37 704 and draws 37 704.
*
* This module reproduces that computation so the AGI (FK487), the booked
* 2731 liability, the stored declaration totals and the payment file all
* carry the same number Skatteverket will draw.
*
* Two deliberate approximations, both öre/krona-scale and documented:
* - Per-IU underlag is truncateToWholeKronor(avgifter_basis) (one
* truncation of the summed basis) rather than the sum of the per-FIELD
* truncated FK011/FK012/… values Skatteverket sums. The two differ only
* when one employee has SEVERAL öre-bearing underlag components in the
* same month (e.g. örelön + bilförmån with öre), by at most k-1 kr for
* k öre-bearing components.
* - Cells are truncated per (category, sats) cell rather than per global
* sats. They differ by at most 1 kr, only when two categories share a
* sats with fractional products (e.g. växa-stöd reduced parts next to
* 65+-reduced rows). In exchange the category breakdown always cross-foots
* exactly against the total.
*/
/**
* F-skatt payees receive no skatteavdrag and form no underlag for
* arbetsgivaravgifter (the AGI's isFSkattRow invariant). The single source
* for that check: every booking/preview surface keys the exclusion on this
* helper so the booked 2731/7510 split, the AGI's IU exclusion and the
* previewed voucher cannot silently diverge for F-skatt employees.
*/
export function isFSkattStatus(status: string | null | undefined): boolean {
return status === 'f_skatt'
}
/** One roster row, as stored on salary_run_employees. */
export interface DeclaredAvgifterRow {
/**
* Effective avgiftsunderlag in SEK (öre-exact): override-coalesced where
* overrides apply, 0 for F-skatt rows (the engine already stores 0).
*/
basis: number
/** Stored avgifter_rate: the employee's (possibly reduced) sats. */
rate: number
/**
* DB avgifter_category ('standard' | 'reduced_65plus' | 'youth' |
* 'vaxa_stod' | 'exempt'), or null for legacy rows calculated before the
* column existed (resolved by the same rate heuristic the AGI uses).
*/
category: string | null
}
export interface DeclaredAvgifterParams {
/** Full arbetsgivaravgift sats for above-cap parts (avgifterTotal). */
standardRate: number
/** Youth reduced-rate monthly cap (avgifterYouthSalaryCap), null = no cap. */
youthCap: number | null
/** Växa-stöd reduced-rate monthly cap (avgifterVaxaStodCap), null = no cap. */
vaxaCap: number | null
}
/** AGI reporting category (växa-stöd and above-cap parts fold into standard). */
export type DeclaredAvgifterCategory = 'standard' | 'reduced65plus' | 'youth'
export interface DeclaredAvgifterCell {
category: DeclaredAvgifterCategory
/** Sats in hundredths of a percent (31,42 % = 3142). */
rateHundredths: number
/** Whole-krona underlag summed into this cell. */
underlag: number
/** Whole-krona avgift: trunc(underlag × sats). */
amount: number
}
export interface DeclaredAvgifter {
cells: DeclaredAvgifterCell[]
/** Whole kronor: sum of cell underlag. */
totalUnderlag: number
/** Whole kronor: sum of cell amounts. This is what Skatteverket draws. */
totalAmount: number
}
/**
* Resolve the sats/cap parameters from a salary run's frozen
* calculation_params snapshot (serializePayrollConfig shape). The 31,42 %
* fallback only matters for pre-snapshot legacy runs.
*/
export function resolveDeclaredAvgifterParams(
calculationParams: Record<string, unknown> | null | undefined,
): DeclaredAvgifterParams {
const p = calculationParams ?? {}
const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
return {
standardRate: num(p['avgifterTotal']) ?? 0.3142,
youthCap: num(p['avgifterYouthSalaryCap']),
vaxaCap: num(p['avgifterVaxaStodCap']),
}
}
/**
* AGI reporting category for a roster row. Legacy rows (category null)
* resolve by rate: at/below the 10,21/10,22 band → 65+-reduced, at/below
* the 20,81/20,82 band → youth, else standard. Växa-stöd reports under
* standard (its FK062/FK063 flags live on the IU, not the category map).
*/
export function reportingCategory(row: Pick<DeclaredAvgifterRow, 'rate' | 'category'>): DeclaredAvgifterCategory {
switch (row.category) {
case 'reduced_65plus':
return 'reduced65plus'
case 'youth':
return 'youth'
case 'vaxa_stod':
case 'standard':
// Exempt rows (born 1937 or earlier) carry rate 0 and never produce an
// avgift; the explicit case only keeps the bucket label independent of
// the rate heuristic.
case 'exempt':
return 'standard'
default:
return row.rate <= 0.1022 ? 'reduced65plus' : row.rate <= 0.2082 ? 'youth' : 'standard'
}
}
function toRateHundredths(rate: number): number {
return Math.round(rate * 10000)
}
/**
* trunc(underlag × sats) in exact integer arithmetic: whole-krona underlag ×
* sats-hundredths stays far below 2^53, so no float can shave a krona
* (1000 × 0.007-style noise) or grant one.
*/
function truncatedAvgift(underlag: number, rateHundredths: number): number {
return Math.trunc((underlag * rateHundredths) / 10_000)
}
export function computeDeclaredAvgifter(
rows: DeclaredAvgifterRow[],
params: DeclaredAvgifterParams,
): DeclaredAvgifter {
// (category, sats) → underlag sum, in whole kronor.
const cellUnderlag = new Map<string, { category: DeclaredAvgifterCategory; rateHundredths: number; underlag: number }>()
const add = (category: DeclaredAvgifterCategory, rateHundredths: number, underlag: number) => {
if (underlag <= 0 || rateHundredths <= 0) return
const key = `${category}:${rateHundredths}`
const cell = cellUnderlag.get(key) ?? { category, rateHundredths, underlag: 0 }
cell.underlag += underlag
cellUnderlag.set(key, cell)
}
const standardHundredths = toRateHundredths(params.standardRate)
for (const row of rows) {
const underlag = truncateToWholeKronor(row.basis)
if (underlag <= 0) continue
const rateHundredths = toRateHundredths(row.rate)
if (rateHundredths <= 0) continue
// Salary caps: the reduced sats applies up to the monthly cap, the
// remainder is charged at the full sats: mirrors the engine's
// youth/växa-stöd blend (calculation-engine.ts step 8), applied on the
// declared whole-krona underlag the way Skatteverket applies it.
// The youth cap keys on the RESOLVED category so a legacy null-category
// row classified as youth by the rate heuristic still gets capped; växa
// keys on the raw category (it resolves to 'standard' for reporting).
const category = reportingCategory(row)
const cap =
category === 'youth' && params.youthCap !== null
? Math.trunc(params.youthCap)
: row.category === 'vaxa_stod' && params.vaxaCap !== null
? Math.trunc(params.vaxaCap)
: null
if (cap !== null && underlag > cap) {
add(category, rateHundredths, cap)
add('standard', standardHundredths, underlag - cap)
} else {
add(category, rateHundredths, underlag)
}
}
const cells: DeclaredAvgifterCell[] = [...cellUnderlag.values()].map((c) => ({
category: c.category,
rateHundredths: c.rateHundredths,
underlag: c.underlag,
amount: truncatedAvgift(c.underlag, c.rateHundredths),
}))
return {
cells,
totalUnderlag: cells.reduce((s, c) => s + c.underlag, 0),
totalAmount: cells.reduce((s, c) => s + c.amount, 0),
}
}
/**
* Fold cells into the AGITotals.avgifterByCategory shape. Whole-krona values
* that cross-foot exactly: sum of category amounts === totalAmount.
*/
export function declaredAvgifterByCategory(declared: DeclaredAvgifter): Partial<
Record<DeclaredAvgifterCategory, { basis: number; amount: number }>
> {
const byCategory: Partial<Record<DeclaredAvgifterCategory, { basis: number; amount: number }>> = {}
for (const cell of declared.cells) {
const entry = byCategory[cell.category] ?? { basis: 0, amount: 0 }
entry.basis += cell.underlag
entry.amount += cell.amount
byCategory[cell.category] = entry
}
return byCategory
}
export interface DeclaredAvgifterHybridRow extends DeclaredAvgifterRow {
/**
* Öre-exact manual avgifter amount (avgifter_amount_override). When set,
* this row bypasses the underlag computation entirely: the operator's
* number is declared, booked and paid. Rows without it compute from
* `basis` (the FILED underlag) like computeDeclaredAvgifter.
*/
overrideAmount?: number | null
}
export interface DeclaredAvgifterWithOverrides {
/** Whole kronor. What is filed as FK487, booked on 2731, and paid. */
totalAmount: number
/** Whole kronor. */
totalUnderlag: number
byCategory: Partial<Record<DeclaredAvgifterCategory, { basis: number; amount: number }>>
}
/**
* The single declared-avgifter computation both the AGI generator and the
* salary booking use, so the filed FK487, the stored declaration totals, the
* booked 2731 liability and the payment are ONE number by construction.
*
* Rows without an override run Skatteverket's underlag computation
* (computeDeclaredAvgifter): a manual adjustment on one employee must not
* cost the rest of the roster its SKV-exact declared amount. Overridden rows
* contribute their manual amounts summed per reporting category and
* truncated per category. The category breakdown cross-foots exactly against
* the total on every path.
*/
export function computeDeclaredAvgifterWithOverrides(
rows: DeclaredAvgifterHybridRow[],
params: DeclaredAvgifterParams,
): DeclaredAvgifterWithOverrides {
const declared = computeDeclaredAvgifter(
rows.filter((r) => r.overrideAmount == null),
params,
)
const byCategory = declaredAvgifterByCategory(declared)
let totalAmount = declared.totalAmount
let totalUnderlag = declared.totalUnderlag
const oreAmount = new Map<DeclaredAvgifterCategory, number>()
const oreBasis = new Map<DeclaredAvgifterCategory, number>()
for (const row of rows) {
if (row.overrideAmount == null) continue
const category = reportingCategory(row)
oreAmount.set(category, (oreAmount.get(category) ?? 0) + row.overrideAmount)
oreBasis.set(category, (oreBasis.get(category) ?? 0) + row.basis)
}
for (const [category, amountSum] of oreAmount) {
const amount = truncateToWholeKronor(amountSum)
const basis = truncateToWholeKronor(oreBasis.get(category) ?? 0)
const entry = byCategory[category] ?? { basis: 0, amount: 0 }
entry.amount += amount
entry.basis += basis
byCategory[category] = entry
totalAmount += amount
totalUnderlag += basis
}
return { totalAmount, totalUnderlag, byCategory }
}
+126 -8
View File
@@ -8,6 +8,10 @@ import {
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import { SALARY_ACCOUNTS, getLineItemAccount } from './account-mapping'
import {
computeDeclaredAvgifterWithOverrides,
resolveDeclaredAvgifterParams,
} from './declared-avgifter'
import { calculateLoneVaxlingPensionProvision } from './lonevaxling'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
@@ -26,6 +30,18 @@ interface SalaryRunEmployee {
net_salary: number
avgifter_amount: number
avgifter_rate: number
// Declared-avgifter inputs (see lib/salary/declared-avgifter.ts): the
// whole-krona 2731 liability is computed Skatteverket's way from the
// FILED underlag (never basis overrides: those don't reach the IUs), not
// by truncating the öre-exact cost. Optional: rows from legacy callers
// without them fall back to the öre-exact liability.
avgifter_basis?: number
avgifter_category?: string | null
// True when avgifter_amount carries a manual review override: the split
// then mirrors the AGI's override path (per-category truncation of the
// overridden amounts) instead of the underlag computation, so the
// operator's adjustment stays on 2731 and never books as fake utjämning.
avgifter_amount_overridden?: boolean
vacation_accrual: number
vacation_accrual_avgifter: number
// Dimensions PR8: the employee's default bag ({sie_dim_no: code}), read
@@ -356,11 +372,95 @@ function bucketByEmployeeDimensions(
.filter((b) => b.amount !== 0)
}
/**
* Split the öre-exact avgifter total into the whole-krona 2731 liability and
* the 3740 utjämning remainder.
*
* The liability is the DECLARED amount (computeDeclaredAvgifter: Skatteverket's
* per-sats computation on whole-krona underlag), which on öre-bearing rosters
* sits kronor, not just öre, below the exact cost: 4 employees at 30 000,99 kr
* cost 37 705,24 exactly while Skatteverket draws 37 704, so 1,24 kr books to
* 3740. The remainder is bounded by ~1 kr per employee (per-IU truncation)
* plus per-sats truncation; a remainder outside [0, employees + 2) means the
* roster's stored amounts diverge from its underlag (legacy rows without
* basis columns, corrupt data), and the entry falls back to the legacy
* öre-exact liability rather than manufacturing a fake utjämning.
*
* Manual avgifter_amount overrides (flagged EXPLICITLY: a small override
* inside the magnitude band would otherwise book the operator's deliberate
* adjustment as rounding income on 3740) contribute their manual amounts per
* category instead of the underlag computation: the identical hybrid the AGI
* generator files and the payment file pays
* (computeDeclaredAvgifterWithOverrides), so the booked 2731, the
* declaration and the payment stay one number, and colleagues of an
* overridden employee keep their SKV-exact declared amounts.
*
* Exported so the journal preview route computes the identical split.
*/
export function splitAvgifterLiability(
run: {
employees: Array<
Pick<
SalaryRunEmployee,
| 'avgifter_amount'
| 'avgifter_basis'
| 'avgifter_rate'
| 'avgifter_category'
| 'avgifter_amount_overridden'
>
>
calculation_params?: Record<string, unknown> | null
},
roundedAvgifter: number,
): { liabilityAvgifter: number; oresutjamning: number } {
if (roundedAvgifter <= 0) {
return { liabilityAvgifter: roundedAvgifter, oresutjamning: 0 }
}
// Non-overridden rows need the underlag; overridden rows carry their own
// amount. A roster from a legacy caller without basis columns falls back.
const haveInputs = run.employees.every(
(e) => e.avgifter_amount_overridden === true || typeof e.avgifter_basis === 'number',
)
if (!haveInputs) {
return { liabilityAvgifter: roundedAvgifter, oresutjamning: 0 }
}
const declared = computeDeclaredAvgifterWithOverrides(
run.employees.map((e) => ({
basis: e.avgifter_basis ?? 0,
rate: e.avgifter_rate,
category: e.avgifter_category ?? null,
overrideAmount: e.avgifter_amount_overridden === true ? e.avgifter_amount : null,
})),
resolveDeclaredAvgifterParams(run.calculation_params),
)
const remainder = roundOre(roundedAvgifter - declared.totalAmount)
// Per-IU truncation loses under 1 kr per employee and each truncation cell
// strictly under 1 kr more; a remainder outside this band means the
// roster's stored amounts diverge from its underlag (corrupt or legacy
// data), and the entry keeps the öre-exact liability rather than
// manufacturing a fake utjämning.
const maxTruncationDrift = run.employees.length + 2
if (remainder < 0 || remainder >= maxTruncationDrift) {
return { liabilityAvgifter: roundedAvgifter, oresutjamning: 0 }
}
return { liabilityAvgifter: declared.totalAmount, oresutjamning: remainder }
}
/**
* Entry 2: Arbetsgivaravgifter.
*
* Debit: 7510 Lagstadgade sociala avgifter (per dimensions bucket)
* Credit: 2731 Avräkning sociala avgifter (single aggregated liability)
* Debit: 7510 Lagstadgade sociala avgifter (per dimensions bucket, exact öre)
* Credit: 2731 Avräkning sociala avgifter (whole kronor: the amount
* Skatteverket computes from the declared underlag and draws)
* Credit: 3740 Öres- och kronutjämning (the remainder)
*
* 2731 holds the declared amount (computeDeclaredAvgifter: per-sats on
* whole-krona underlag, the same number the AGI's FK487 carries): crediting
* the öre-exact cost would leave a residual on 2731 after the whole-krona
* skattekonto draw. The 7510 cost side stays exact: the difference is a
* settlement artifact, not a cost reduction. Post-booking AGI edits
* (borttag, overrides set during review) still require a storno + rebook:
* this alignment covers the booking as calculated.
*/
async function createAvgifterEntry(
supabase: SupabaseClient,
@@ -375,6 +475,7 @@ async function createAvgifterEntry(
// single untagged debit line, exactly as before the dimension split.
const buckets = dimBuckets.length > 0 ? dimBuckets : [{ dimensions: undefined, amount: 0 }]
const roundedAvgifter = roundOre(buckets.reduce((sum, b) => sum + b.amount, 0))
const { liabilityAvgifter, oresutjamning } = splitAvgifterLiability(run, roundedAvgifter)
const lines: CreateJournalEntryLineInput[] = [
...buckets.map((bucket): CreateJournalEntryLineInput => ({
@@ -384,12 +485,29 @@ async function createAvgifterEntry(
line_description: `${desc}: Arbetsgivaravgifter`,
dimensions: bucket.dimensions,
})),
{
account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY,
debit_amount: 0,
credit_amount: roundedAvgifter,
line_description: `${desc}: Arbetsgivaravgifter`,
},
// Skip the liability line only when the utjämning carries the whole
// (sub-1-krona) amount: a 0/0 line is verifikat noise. The zero-total
// parity shape (nollrun) keeps its single 0-credit line as before.
...(liabilityAvgifter !== 0 || oresutjamning === 0
? [
{
account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY,
debit_amount: 0,
credit_amount: liabilityAvgifter,
line_description: `${desc}: Arbetsgivaravgifter`,
} satisfies CreateJournalEntryLineInput,
]
: []),
...(oresutjamning > 0
? [
{
account_number: SALARY_ACCOUNTS.ORESUTJAMNING,
debit_amount: 0,
credit_amount: oresutjamning,
line_description: `${desc}: Öres- och kronutjämning`,
} satisfies CreateJournalEntryLineInput,
]
: []),
]
const input: CreateJournalEntryInput = {