From 97bbba323f2cd71e5e7fda80e9a64151ee01579a Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 4 Aug 2026 19:33:36 +0200 Subject: [PATCH] fix(salary): report F-skatt compensation on FK131 only, not FK011 too (#1402) * fix(salary): report F-skatt compensation on FK131 only, not FK011 too An F-skatt payee's cash compensation was passed through as grossSalary unconditionally while also being routed to fSkattPayment, so the same payment was double-reported in the AGI individuppgift as both FK011 (KontantErsattningUlagAG, underlag for arbetsgivaravgifter) and FK131 (KontantErsattningEjUlagSA). Per Skatteverket's AGI spec these fields are mutually exclusive for the same payment stream: F-skatt payments form no underlag for arbetsgivaravgifter. Fix at the data layer: generateAgiDeclaration now zeroes grossSalary for f_skatt payees so FK011 is never emitted for that payment, while fSkattPayment keeps carrying it to FK131. A generator-side guard was deliberately not used because an IU can legitimately carry both fields for genuinely mixed payments. The empty-IU filter already keeps f_skatt rows via fSkattPayment, so no individuppgift is dropped; FK487/avgifter totals were already correct (calculation engine sets avgifter_basis 0 for f_skatt) and are covered by a regression test. Both the dashboard route and the v1 public route call this helper, so both are fixed. Co-Authored-By: Claude Fable 5 * fix(salary): exclude f-skatt rows from avgifter override aggregation (#315) Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .../agi-generate-declaration.test.ts | 247 ++++++++++++++++++ lib/salary/__tests__/agi-xml.test.ts | 27 ++ lib/salary/agi/generate-declaration.ts | 34 ++- 3 files changed, 299 insertions(+), 9 deletions(-) create mode 100644 lib/salary/__tests__/agi-generate-declaration.test.ts diff --git a/lib/salary/__tests__/agi-generate-declaration.test.ts b/lib/salary/__tests__/agi-generate-declaration.test.ts new file mode 100644 index 00000000..9be9b131 --- /dev/null +++ b/lib/salary/__tests__/agi-generate-declaration.test.ts @@ -0,0 +1,247 @@ +/** + * 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('') + .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( + '15000', + ) + 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( + '40000', + ) + expect(regularIu).toContain('12000') + 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( + '12568', + ) + expect(result.xml).toContain( + '12000', + ) + }) + + 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( + '15000', + ) + // 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( + '9426', + ) + + // The F-skatt IU itself is unchanged: FK131 with the payment, no FK011. + const fSkattIu = iuBlockFor(result.xml, '198506159876') + expect(fSkattIu).toContain( + '15000', + ) + expect(fSkattIu).not.toContain('faltkod="011"') + }) +}) diff --git a/lib/salary/__tests__/agi-xml.test.ts b/lib/salary/__tests__/agi-xml.test.ts index 781e02a5..565bd63e 100644 --- a/lib/salary/__tests__/agi-xml.test.ts +++ b/lib/salary/__tests__/agi-xml.test.ts @@ -243,6 +243,33 @@ describe('generateAGIXml: Individuppgift (IU)', () => { }) }) +describe('generateAGIXml: F-skatt payee IU (FK131)', () => { + // Data layer (generate-declaration.ts) zeroes grossSalary for F-skatt + // payees and routes the cash to fSkattPayment: the same payment must + // never appear as both FK011 (underlag AG) and FK131 (ej underlag SA). + const fSkattEmployee: AGIEmployeeData = { + personnummer: 'emp2_encrypted', + specificationNumber: 3, + grossSalary: 0, + taxWithheld: 0, + avgifterBasis: 0, + fSkattPayment: 15000, + } + + it('emits KontantErsattningEjUlagSA FK131 with the payment amount', () => { + const xml = generateAGIXml(company, [fSkattEmployee], totals) + expect(xml).toContain( + '15000' + ) + }) + + it('does not emit FK011 or FK001 for the F-skatt payment', () => { + const xml = generateAGIXml(company, [fSkattEmployee], totals) + expect(xml).not.toContain('faltkod="011"') + expect(xml).not.toContain('faltkod="001"') + }) +}) + describe('generateAGIXml: fail-fast on missing data', () => { it('throws AGIIncompleteDataError when org number is missing', () => { const bad = { ...company, orgNumber: '' } diff --git a/lib/salary/agi/generate-declaration.ts b/lib/salary/agi/generate-declaration.ts index f122a42f..1d85f486 100644 --- a/lib/salary/agi/generate-declaration.ts +++ b/lib/salary/agi/generate-declaration.ts @@ -128,6 +128,16 @@ function sumLineItemAmounts( .reduce((sum, li) => sum + ((li.amount as number) || 0), 0) } +// Invariant: F-skatt compensation never contributes to the avgifter +// aggregates (per-IU basis, FK061-series categories, FK487, HU totals), +// overrides included. The calculation engine already stores +// avgifter_basis/avgifter_amount = 0 for these rows; a manual advanced-mode +// 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' +} + export async function generateAgiDeclaration( args: GenerateAgiDeclarationArgs, ): Promise { @@ -330,21 +340,25 @@ export async function generateAgiDeclaration( ) } - const isFSkatt = emp?.f_skatt_status === 'f_skatt' + const isFSkatt = isFSkattRow(sre) // Honor advanced-mode per-employee overrides set during review. + // F-skatt rows ignore avgifter overrides (see isFSkattRow invariant). const effectiveTax = sre.tax_withheld_override ?? sre.tax_withheld - const effectiveAvgifterBasis = sre.avgifter_basis_override ?? sre.avgifter_basis + const effectiveAvgifterBasis = isFSkatt + ? 0 + : sre.avgifter_basis_override ?? sre.avgifter_basis return { personnummer: emp?.personnummer ?? '', specificationNumber: emp?.specification_number ?? 0, removed: Boolean(sre.removed_from_agi), - grossSalary: sre.gross_salary, + grossSalary: isFSkatt ? 0 : sre.gross_salary, taxWithheld: effectiveTax, avgifterBasis: effectiveAvgifterBasis, fSkattPayment: isFSkatt ? sre.gross_salary : undefined, - // F-skatt payees: cash goes to FK131 and benefits to the ej-UlagSA - // variants (FK132/FK133/FK134/FK137/FK138/FK139). Regular employees - // get FK011 + FK012/FK013/FK015/FK018/FK041/FK043. + // F-skatt payees: cash goes to FK131 ONLY (grossSalary is zeroed so + // FK011 is never emitted for the same payment) and benefits to the + // ej-UlagSA variants (FK132/FK133/FK134/FK137/FK138/FK139). Regular + // employees get FK011 + FK012/FK013/FK015/FK018/FK041/FK043. benefitsExcludedFromSAUnderlag: isFSkatt ? true : undefined, benefitCar: benefitCar > 0 ? benefitCar : undefined, benefitFuel: benefitFuel > 0 ? benefitFuel : undefined, @@ -405,8 +419,9 @@ export async function generateAgiDeclaration( const cat = (avgifterByCategory as Record)[ category ] || { basis: 0, amount: 0 } - cat.basis += sre.avgifter_basis_override ?? sre.avgifter_basis - cat.amount += sre.avgifter_amount_override ?? sre.avgifter_amount + // 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)[category] = cat } const totalAvgifterAmount = Object.values(avgifterByCategory).reduce( @@ -446,8 +461,9 @@ export async function generateAgiDeclaration( 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 + ((e.avgifter_basis_override ?? e.avgifter_basis) || 0), + (s, e) => s + (isFSkattRow(e) ? 0 : (e.avgifter_basis_override ?? e.avgifter_basis) || 0), 0, ), totalAvgifterAmount: Math.round(totalAvgifterAmount * 100) / 100,