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:
co-authored by
Claude Fable 5
parent
fbe4e18730
commit
4e14182a00
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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>',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, ''')
|
||||
}
|
||||
|
||||
// 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
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user