Files
accounted/lib/salary/__tests__/agi-generate-declaration.test.ts
T
MattssonandClaude Fable 5 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

407 lines
15 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.
/**
* Data-layer tests for generateAgiDeclaration (issue #315).
*
* An F-skatt payee's cash compensation must reach the AGI as FK131
* (KontantErsattningEjUlagSA) ONLY. Before the fix, grossSalary was passed
* through unconditionally, so the same payment was double-reported as
* FK011 (underlag arbetsgivaravgifter) AND FK131 in the same IU.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { generateAgiDeclaration } from '../agi/generate-declaration'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
import type { Logger } from '@/lib/logger'
vi.mock('../personnummer', () => ({
decryptPersonnummer: (encrypted: string) => {
if (encrypted === 'emp1_encrypted') return '199001011234'
if (encrypted === 'emp2_encrypted') return '198506159876'
return '000000000000'
},
}))
const log = {
child: () => log,
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as unknown as Logger
const RUN = {
id: 'run-1',
company_id: 'company-1',
status: 'approved',
period_year: 2026,
period_month: 6,
total_gross: 55000,
total_tax: 12000,
calculation_params: {},
}
const COMPANY = { name: 'Test AB', org_number: '556123-4567' }
const SETTINGS = {
company_name: 'Test AB',
org_number: '556123-4567',
phone: '0701234567',
email: 'agi@test.se',
}
const PROFILE = { full_name: 'Anna Admin', email: 'anna@test.se' }
const REGULAR_ROW = {
employee_id: '11111111-1111-4111-8111-111111111111',
monthly_salary: 40000,
gross_salary: 40000,
tax_withheld: 12000,
avgifter_basis: 40000,
avgifter_amount: 12568,
avgifter_rate: 0.3142,
avgifter_category: 'standard',
employee: {
personnummer: 'emp1_encrypted',
specification_number: 1,
f_skatt_status: 'a_skatt',
},
line_items: [],
}
// Mirrors calculation-engine output for f_skatt: avgifter_basis is already 0
// (the payment forms no underlag for arbetsgivaravgifter) and no tax is
// withheld. gross_salary still carries the paid amount.
const F_SKATT_ROW = {
employee_id: '22222222-2222-4222-8222-222222222222',
monthly_salary: null,
gross_salary: 15000,
tax_withheld: 0,
avgifter_basis: 0,
avgifter_amount: 0,
avgifter_rate: 0.3142,
avgifter_category: 'standard',
employee: {
personnummer: 'emp2_encrypted',
specification_number: 2,
f_skatt_status: 'f_skatt',
},
line_items: [],
}
function enqueueHappyPath(
enqueueMany: (results: { data?: unknown; error?: unknown }[]) => void,
roster: unknown[],
) {
enqueueMany([
{ data: RUN }, // salary_runs select
{ data: COMPANY }, // companies select
{ data: SETTINGS }, // company_settings select
{ data: PROFILE }, // profiles select
{ data: roster }, // salary_run_employees select
{ data: [] }, // salary_absence_days select
{ data: null }, // agi_declarations maybeSingle (first generation)
{ data: { id: 'agi-1' } }, // agi_declarations insert
{ data: null }, // salary_runs update (agi_generated_at stamp)
])
}
function iuBlockFor(xml: string, personnummer: string): string {
const block = xml
.split('<gem:IU>')
.slice(1)
.find((b) => b.includes(personnummer))
expect(block, `IU for ${personnummer} should exist`).toBeDefined()
return block as string
}
const ARGS = {
companyId: 'company-1',
userId: 'user-1',
userEmail: 'anna@test.se',
salaryRunId: 'run-1',
log,
requestId: 'req-1',
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('generateAgiDeclaration: F-skatt payee (FK131 only, issue #315)', () => {
it('reports F-skatt cash on FK131 only, never FK011/FK001, in a mixed roster', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueHappyPath(enqueueMany, [REGULAR_ROW, F_SKATT_ROW])
const result = await generateAgiDeclaration({ supabase: supabase as never, ...ARGS })
expect(result.ok).toBe(true)
if (!result.ok) return
// The F-skatt IU is kept (not dropped by the empty-IU filter).
expect(result.employeeCount).toBe(2)
const fSkattIu = iuBlockFor(result.xml, '198506159876')
expect(fSkattIu).toContain(
'<gem:KontantErsattningEjUlagSA faltkod="131">15000</gem:KontantErsattningEjUlagSA>',
)
expect(fSkattIu).not.toContain('faltkod="011"')
expect(fSkattIu).not.toContain('faltkod="001"')
})
it('leaves the regular employee IU unchanged (FK011 + FK001, no FK131)', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueHappyPath(enqueueMany, [REGULAR_ROW, F_SKATT_ROW])
const result = await generateAgiDeclaration({ supabase: supabase as never, ...ARGS })
expect(result.ok).toBe(true)
if (!result.ok) return
const regularIu = iuBlockFor(result.xml, '199001011234')
expect(regularIu).toContain(
'<gem:KontantErsattningUlagAG faltkod="011">40000</gem:KontantErsattningUlagAG>',
)
expect(regularIu).toContain('<gem:AvdrPrelSkatt faltkod="001">12000</gem:AvdrPrelSkatt>')
expect(regularIu).not.toContain('faltkod="131"')
})
it('excludes the F-skatt payment from FK487/avgifter totals', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueHappyPath(enqueueMany, [REGULAR_ROW, F_SKATT_ROW])
const result = await generateAgiDeclaration({ supabase: supabase as never, ...ARGS })
expect(result.ok).toBe(true)
if (!result.ok) return
// Only the regular employee contributes to the avgifter basis and amount.
expect(result.totals.totalAvgifterBasis).toBe(40000)
expect(result.totals.totalAvgifterAmount).toBe(12568)
expect(result.xml).toContain(
'<gem:SummaArbAvgSlf faltkod="487">12568</gem:SummaArbAvgSlf>',
)
expect(result.xml).toContain(
'<gem:SummaSkatteavdr faltkod="497">12000</gem:SummaSkatteavdr>',
)
})
it('keeps an F-skatt-only roster as a real IU declaration, not a nolldeklaration', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueHappyPath(enqueueMany, [F_SKATT_ROW])
const result = await generateAgiDeclaration({ supabase: supabase as never, ...ARGS })
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.employeeCount).toBe(1)
expect(result.xml).toContain(
'<gem:KontantErsattningEjUlagSA faltkod="131">15000</gem:KontantErsattningEjUlagSA>',
)
// Nothing on this declaration reports the payment as AG underlag.
expect(result.xml).not.toContain('faltkod="011"')
})
})
describe('generateAgiDeclaration: avgifter overrides on an F-skatt row are ignored', () => {
// Regression for the CodeRabbit finding on #1402: computed avgifter are
// already 0 for f_skatt rows, but advanced-mode overrides used to coalesce
// past that (override ?? computed) at three aggregation points, restoring
// social charges for pay whose IU simultaneously asserts FK131.
const REGULAR_OVERRIDE_ROW = {
...REGULAR_ROW,
avgifter_basis_override: 30000,
avgifter_amount_override: 9426,
}
const F_SKATT_OVERRIDE_ROW = {
...F_SKATT_ROW,
avgifter_basis_override: 15000,
avgifter_amount_override: 4713,
}
it('excludes F-skatt overrides from FK487, totals and avgifterByCategory while regular overrides still apply', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
enqueueHappyPath(enqueueMany, [REGULAR_OVERRIDE_ROW, F_SKATT_OVERRIDE_ROW])
const result = await generateAgiDeclaration({ supabase: supabase as never, ...ARGS })
expect(result.ok).toBe(true)
if (!result.ok) return
// The regular employee's override flows through (the override mechanism
// itself must keep working); the F-skatt row's override is ignored.
expect(result.totals.totalAvgifterBasis).toBe(30000)
expect(result.totals.totalAvgifterAmount).toBe(9426)
expect(result.totals.avgifterByCategory).toEqual({
standard: { basis: 30000, amount: 9426 },
})
expect(result.xml).toContain(
'<gem:SummaArbAvgSlf faltkod="487">9426</gem:SummaArbAvgSlf>',
)
// The F-skatt IU itself is unchanged: FK131 with the payment, no FK011.
const fSkattIu = iuBlockFor(result.xml, '198506159876')
expect(fSkattIu).toContain(
'<gem:KontantErsattningEjUlagSA faltkod="131">15000</gem:KontantErsattningEjUlagSA>',
)
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>',
)
})
})