From 4e14182a00ff827f7ad957a448030bee6e64dbd5 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:22:07 +0200 Subject: [PATCH] fix(salary): declare, book and pay AGI in whole kronor (SKV per-sats computation) (#1611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 10 + app/(dashboard)/salary/runs/[id]/page.tsx | 29 +- .../runs/[id]/preview/__tests__/route.test.ts | 181 +++++++++++ app/api/salary/runs/[id]/preview/route.ts | 138 ++++++++- .../payment-file/__tests__/route.test.ts | 39 +++ .../[period]/payment-file/route.ts | 13 +- .../[id]/__tests__/lifecycle.test.ts | 79 +++++ .../salary-runs/[id]/book/route.ts | 37 ++- components/salary/SalaryOverridePanel.tsx | 6 +- components/salary/TaxPaymentPanel.tsx | 9 +- components/salary/run/RunJournalPreview.tsx | 8 +- lib/__tests__/money.test.ts | 38 ++- lib/api/schemas.ts | 12 +- .../content/cookbook/run-payroll-and-agi.ts | 2 +- lib/money.ts | 21 ++ .../agi-generate-declaration.test.ts | 159 ++++++++++ lib/salary/__tests__/agi-xml.test.ts | 32 +- .../__tests__/declared-avgifter.test.ts | 203 +++++++++++++ lib/salary/__tests__/salary-entries.test.ts | 149 ++++++++- lib/salary/account-mapping.ts | 3 + lib/salary/agi/generate-declaration.ts | 125 ++++---- lib/salary/agi/xml-generator.ts | 7 +- lib/salary/book-run.ts | 22 +- lib/salary/declared-avgifter.ts | 284 ++++++++++++++++++ lib/salary/salary-entries.ts | 134 ++++++++- messages/en.json | 1 + messages/sv.json | 1 + scripts/checks/antipatterns-baseline.json | 2 +- 28 files changed, 1642 insertions(+), 102 deletions(-) create mode 100644 app/api/salary/runs/[id]/preview/__tests__/route.test.ts create mode 100644 lib/salary/__tests__/declared-avgifter.test.ts create mode 100644 lib/salary/declared-avgifter.ts diff --git a/DECISIONS.md b/DECISIONS.md index 1fda5591..6a40e52d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -956,6 +956,16 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-13] PR #1598, compliance findings closed with the rollout after the Swedish accounting review escalated them from follow-up to fix-with-rollout: (a) runReconciliation's >= 0.9 auto-apply now writes 'matched' to payment_match_log (behandlingshistorik, BFNAR 2013:2 kap 8); (b) the three match-route storno-conflict branches no longer storno-reverse a reconciliation-linked verifikat: a reconciliation link points at an independent verifikat that may evidence other affarshandelser, and a wholesale reversal is an over-broad rattelse (BFL 5 kap 5 §). The detach is DEFERRED (round 2, CodeRabbit): nothing is persisted up front; the final transaction update overwrites the pointer and clears reconciliation_method in the same write, so a failure anywhere in the match flow leaves the existing link intact, and the release is logged as 'unmatched' after the commit. [2026-08-13] PR #1598, CodeRabbit findings: confirm-suggestions maxDuration 300; lookbackTouched on the migrator nudge buttons; persistSuggestions on main's post-backfill sweep; sie_sweep stamp errors logged; sandbox keeps the CSV CTA (file import works there); payment_match_log CHECK swap now NOT VALID + VALIDATE (no table-scan under ACCESS EXCLUSIVE); every logMatchEvent call awaited (serverless can freeze unawaited work). [2026-08-13] Historical audit gap quantified on prod (read-only): 762 manual-method links across 52 companies since 2026-03-23 have no payment_match_log row (upper bound: includes linked_to_existing_voucher drops AND older unlogged manual paths). Not backfillable (the inserts never landed); the links themselves are intact on transactions. Recorded here as the explicit ops note the compliance review asked for. +[2026-08-14] AGI whole-krona handling is truncation (oretal bortfaller, SFF 22 kap. 1 par.), never rounding, with the remainder credited to 3740 in the avgifter voucher, Visma-style: rounding inside the salary run (Dooer-style) was declined because it distorts the exact 7510 cost and per-employee amounts. +[2026-08-14] FK487/2731/payment amount follows Skatteverket's own computation (IK587): per avgiftssats on the whole-krona underlag sums, with youth/vaxa cap splits, in lib/salary/declared-avgifter.ts. First attempt truncated the engine's ore-exact sum; the skeptic pass refuted it (diverges kronor from SKV's draw on multi-employee ore rosters). Two accepted 1-kr-scale approximations documented in the module header: trunc(basis) instead of per-FIELD truncation, and per-(category,sats)-cell truncation so the category breakdown cross-foots exactly. Amount overrides (FoU-avdrag) fall back to per-category truncation of the overridden amounts. +[2026-08-14] agi_declarations.total_tax/total_avgifter store the DECLARED whole-krona amounts (was: ore-exact run totals). The payment file pays those for whole-krona rows but keeps paying ore-exact for legacy ore rows: their salary bookings credited 2731 with ore, and truncating the payment would strand the ore on 2731 (skeptic regression finding R3). +[2026-08-14] tax_withheld_override requires whole kronor (schema .int()): an ore override would book 2710 with ore the whole-krona skattekonto draw never clears. +[2026-08-14] SUPERSEDED (same day, by the booked-verifikat entry below): journal preview hidden for booked/corrected salary runs: a preview built by today's booking rules would contradict immutable verifikat booked under earlier rules (skeptic regression finding R2). +[2026-08-14] Amount overrides route all three legs (booked 2731, stored/filed FK487, payment) through the SAME hybrid computation (overridden rows contribute their manual amounts per category; colleagues keep the SKV-exact underlag computation); basis-only overrides are inert on money totals: a basis override never reaches the filed IU fields, so letting it steer FK487/payment would file an FK487 contradicting the declaration's own IUs and underpay the skattekonto (skeptic round-2 finding). The booking detects amount overrides via an explicit flag, never the magnitude band, so a small deliberate adjustment cannot book as fake oresutjamning. +[2026-08-14] No .int() guard on avgifter_amount_override/avgifter_basis_override (unlike tax_withheld_override): the override editors prefill ore-exact effective values (feedback: prefill override editors), so rejecting ore would 400 a saved prefill; the per-category truncation absorbs ore consistently on all legs instead. +[2026-08-14] Regenerating a legacy-booked period's AGI post-deploy flips its stored totals to declared integers, so the payment then leaves the old booking's ore residual visible on 2731 (skeptic finding F3): ACCEPTED. The alternative preserved a false equilibrium (overpaying skattekontot by the ore forever); the residual is real, one-time, and manually clearable against 3740. +[2026-08-14] Booked/corrected salary runs show the ACTUAL posted verifikat (via the preview route) instead of a recomputed projection: today's booking rules would contradict immutable vouchers booked under earlier rules (skeptic finding R2/F2: gating the preview away entirely removed the page's only voucher view). +[2026-08-14] v1 REST book route gets full override parity with book-run (tax/net/avgifter coalescing + the override flag): the skeptic showed a run with review overrides booked via v1 would diverge from the AGI by the full override delta. F-skatt rows ignore avgifter overrides on BOTH booking surfaces, mirroring the AGI's isFSkattRow invariant (an override on an F-skatt row must never book social charges the declaration excludes). [2026-08-13] MCP account_override (custom accounts via categorize) implemented as a new shared helper lib/bookkeeping/account-override.ts used by the MCP staging preview and categorize-core commit path, mirroring v1 REST semantics (business-side replacement, class-2 VAT drop with 2610-2649 exception) plus a same-account degenerate guard v1 lacks; the v1/internal REST routes keep their inline copies untouched: refactoring them into the helper would widen a scoped fix into a three-surface regression risk. Divergence from REST: MCP rejects account_override + category 'private' explicitly instead of silently ignoring it (agent surfaces get deterministic errors, not silent drops). [2026-08-13] gnubok_create_voucher staging gate softened to engine semantics inline (already-fetched chart rows + getBASReference) instead of calling findUnresolvableAccounts: identical verdicts, zero extra queries, and the preview gains will_activate_accounts + BAS-name fallback so the approver sees the auto-activation side-effect. [2026-08-13] suggest_categories rules on accounts outside the fixed maps now surface as expense_other/income_other with the rule's own account and a neutral Swedish match_reason (no MCP jargon: the same suggestion renders in the web UI); previously such rules were silently dropped (agent-reported as "4020 not available"). diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx index 28a49838..3a8840a3 100644 --- a/app/(dashboard)/salary/runs/[id]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/page.tsx @@ -66,6 +66,8 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string // company_settings.dimensions_enabled UI gate as the voucher form. const [dimensionsEnabled, setDimensionsEnabled] = useState(false) const [taxPayment, setTaxPayment] = useState<{ + total_tax: number + total_avgifter: number tax_payment_file_generated_at: string | null tax_paid_at: string | null } | null>(null) @@ -92,7 +94,9 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string void fetch(`/api/skatteverket/tax-payments/${period}`) .then(async (txRes) => (txRes.ok ? txRes.json() : null)) .then((tx) => { - if (tx) setTaxPayment(tx.data) + // Clear on failure too: a stale record from a prior period must + // not keep feeding the panel outdated declared totals. + setTaxPayment(tx?.data ?? null) }) .catch(() => setTaxPayment(null)) .finally(() => setTaxPaymentLoading(false)) @@ -142,11 +146,13 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]) - // Auto-load the journal preview once the run is calculated, so the - // "Bokföring (förhandsgranskning)" box renders beside Beräkningsdetaljer - // without a manual Förhandsgranska click. Re-runs when the calculated totals - // change (e.g. after Beräkna om) so the preview stays in sync; clears while - // the run isn't calculated yet. + // Auto-load the journal view once the run is calculated, so the voucher box + // renders beside Beräkningsdetaljer without a manual Förhandsgranska click. + // Re-runs when the calculated totals change (e.g. after Beräkna om) so the + // preview stays in sync; clears while the run isn't calculated yet. For + // booked/corrected runs the route returns the ACTUAL posted verifikat + // (voucher numbers included) instead of a recomputed projection, which + // could contradict vouchers booked under earlier rules. const isCalculatedForPreview = run?.calculation_params != null useEffect(() => { if (!isCalculatedForPreview) { @@ -163,7 +169,9 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string return () => { cancelled = true } - }, [id, isCalculatedForPreview, run?.total_gross, run?.total_tax, run?.total_avgifter]) + // run?.status: after Bokför, the box must swap the projection for the + // posted verifikat (voucher numbers included) without a manual reload. + }, [id, isCalculatedForPreview, run?.status, run?.total_gross, run?.total_tax, run?.total_avgifter]) // Every handler below releases actionLoading in a finally: the flag gates the // header button, the progress rail and the employee table, so a rejected @@ -830,8 +838,11 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string ) : ( ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +import { GET } from '../route' + +const CALCULATED_RUN = { + id: 'run-1', + company_id: 'company-1', + status: 'review', + period_year: 2026, + period_month: 7, + calculation_params: { slpRate: 0.2426, avgifterTotal: 0.3142 }, +} + +const EMPLOYEE_ROW = { + employee_id: 'emp-1', + employee: { employment_type: 'employee' }, + gross_salary: 51158, + tax_withheld: 12268, + net_salary: 38890, + avgifter_amount: 16073.84, + avgifter_amount_override: null, + avgifter_basis: 51158, + avgifter_rate: 0.3142, + avgifter_category: 'standard', + vacation_accrual: 0, + vacation_accrual_avgifter: 0, + line_items: [], +} + +describe('GET /api/salary/runs/[id]/preview', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await GET( + createMockRequest('/api/salary/runs/run-1/preview'), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(401) + }) + + it('returns 404 for an unknown run', async () => { + enqueue({ data: null, error: { message: 'not found' } }) + const response = await GET( + createMockRequest('/api/salary/runs/run-1/preview'), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(404) + }) + + it('returns 400 when the run has no calculated employees', async () => { + enqueue({ data: CALCULATED_RUN }) // salary_runs + enqueue({ data: [] }) // salary_run_employees: nothing calculated yet + const response = await GET( + createMockRequest('/api/salary/runs/run-1/preview'), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(400) + }) + + it('returns 500 when the posted-voucher lookup fails for a booked run', async () => { + enqueue({ + data: { ...CALCULATED_RUN, status: 'booked', salary_entry_id: 'je-1' }, + }) // salary_runs + enqueue({ data: null, error: { message: 'rls denied' } }) // journal_entries + const response = await GET( + createMockRequest('/api/salary/runs/run-1/preview'), + createMockRouteParams({ id: 'run-1' }), + ) + // A failed lookup must not masquerade as "booked run with no vouchers". + expect(response.status).toBe(500) + }) + + it('previews the whole-krona 2731/3740 split for a calculated run', async () => { + enqueue({ data: CALCULATED_RUN }) // salary_runs + enqueue({ data: [EMPLOYEE_ROW] }) // salary_run_employees + + const response = await GET( + createMockRequest('/api/salary/runs/run-1/preview'), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(200) + const { data } = await response.json() + + expect(data.booked).toBeUndefined() + const lines = data.avgifterEntry.lines as Array<{ + account_number: string + debit_amount: number + credit_amount: number + }> + expect(lines.find((l) => l.account_number === '7510')?.debit_amount).toBe(16073.84) + expect(lines.find((l) => l.account_number === '2731')?.credit_amount).toBe(16073) + expect(lines.find((l) => l.account_number === '3740')?.credit_amount).toBe(0.84) + }) + + it('returns the ACTUAL posted verifikat for a booked run, voucher labels included', async () => { + enqueue({ + data: { + ...CALCULATED_RUN, + status: 'booked', + salary_entry_id: 'je-1', + avgifter_entry_id: 'je-2', + vacation_entry_id: null, + pension_entry_id: null, + }, + }) // salary_runs + enqueue({ + data: [ + { + id: 'je-1', + description: 'Lön 2026-07', + voucher_series: 'A', + voucher_number: 214, + lines: [ + { account_number: '7210', line_description: 'Lön', debit_amount: 51158, credit_amount: 0 }, + { account_number: '2710', line_description: 'Personalskatt', debit_amount: 0, credit_amount: 12268 }, + { account_number: '1930', line_description: 'Nettolön', debit_amount: 0, credit_amount: 38890 }, + ], + }, + { + id: 'je-2', + description: 'Lön 2026-07: Arbetsgivaravgifter', + voucher_series: 'A', + voucher_number: 215, + // A pre-whole-krona legacy voucher: the view must show the posted + // öre-exact lines, never a recomputed projection with 3740. + lines: [ + { account_number: '7510', line_description: 'Arbetsgivaravgifter', debit_amount: 16073.84, credit_amount: 0 }, + { account_number: '2731', line_description: 'Arbetsgivaravgifter', debit_amount: 0, credit_amount: 16073.84 }, + ], + }, + ], + }) // journal_entries + + const response = await GET( + createMockRequest('/api/salary/runs/run-1/preview'), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(200) + const { data } = await response.json() + + expect(data.booked).toBe(true) + expect(data.salaryEntry.description).toBe('Lön 2026-07 (A-214)') + expect(data.avgifterEntry.description).toBe('Lön 2026-07: Arbetsgivaravgifter (A-215)') + const avgifterLines = data.avgifterEntry.lines as Array<{ account_number: string; credit_amount: number }> + expect(avgifterLines.find((l) => l.account_number === '2731')?.credit_amount).toBe(16073.84) + expect(avgifterLines.some((l) => l.account_number === '3740')).toBe(false) + expect(data.vacationEntry).toBeNull() + expect(data.pensionEntry).toBeNull() + }) +}) diff --git a/app/api/salary/runs/[id]/preview/route.ts b/app/api/salary/runs/[id]/preview/route.ts index 9e85e3b0..ddd20d64 100644 --- a/app/api/salary/runs/[id]/preview/route.ts +++ b/app/api/salary/runs/[id]/preview/route.ts @@ -2,6 +2,8 @@ import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { withRouteContext } from '@/lib/api/with-route-context' import { SALARY_ACCOUNTS, getLineItemAccount } from '@/lib/salary/account-mapping' +import { splitAvgifterLiability } from '@/lib/salary/salary-entries' +import { isFSkattStatus } from '@/lib/salary/declared-avgifter' import { roundOre } from '@/lib/money' import type { CreateJournalEntryLineInput } from '@/types' @@ -28,10 +30,78 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) } + // Booked/corrected runs return the ACTUAL posted verifikat instead of a + // recomputed preview: a preview built by today's booking rules would + // contradict an immutable voucher booked under earlier rules (e.g. the + // 2731/3740 whole-krona split) exactly where users reconcile. Same + // response shape, entries keyed by the run's entry ids, voucher labels + // folded into the description. + if (run.status === 'booked' || run.status === 'corrected') { + const { data: posted, error: postedError } = await supabase + .from('journal_entries') + .select( + 'id, description, voucher_series, voucher_number, lines:journal_entry_lines(account_number, line_description, debit_amount, credit_amount)', + ) + .eq('company_id', companyId) + .eq('source_type', 'salary_payment') + .eq('source_id', id) + + // A failed lookup must not masquerade as "booked run with no vouchers". + if (postedError) { + return NextResponse.json( + { error: 'Kunde inte läsa lönekörningens bokförda verifikat' }, + { status: 500 }, + ) + } + + const byId = new Map( + ((posted ?? []) as Array<{ id: string }>).map((e) => [e.id, e] as const), + ) + const toEntry = (entryId: unknown) => { + const entry = entryId ? (byId.get(entryId as string) as + | { + description: string + voucher_series: string | null + voucher_number: number | null + lines: Array<{ + account_number: string + line_description: string | null + debit_amount: number | null + credit_amount: number | null + }> + } + | undefined) : undefined + if (!entry) return null + const voucher = + entry.voucher_number != null + ? ` (${entry.voucher_series ?? ''}${entry.voucher_series ? '-' : ''}${entry.voucher_number})` + : '' + return { + description: `${entry.description}${voucher}`, + lines: entry.lines.map((l) => ({ + account_number: l.account_number, + line_description: l.line_description ?? '', + debit_amount: l.debit_amount, + credit_amount: l.credit_amount, + })), + } + } + + return NextResponse.json({ + data: { + booked: true, + salaryEntry: toEntry(run.salary_entry_id), + avgifterEntry: toEntry(run.avgifter_entry_id), + vacationEntry: toEntry(run.vacation_entry_id), + pensionEntry: toEntry(run.pension_entry_id), + }, + }) + } + // Load employees with line items const { data: employees } = await supabase .from('salary_run_employees') - .select('*, employee:employees(employment_type), line_items:salary_line_items(*)') + .select('*, employee:employees(employment_type, f_skatt_status), line_items:salary_line_items(*)') .eq('salary_run_id', id) if (!employees || employees.length === 0) { @@ -107,8 +177,44 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( // mirroring the vacation/pension guards below. The bookkeeping engine never // posts an all-zero 7510/2731 voucher (see book/route.ts nollkörning path), // so previewing one would falsely imply a verifikat that is never created. - const totalAvgifter = employees.reduce((sum, e) => sum + e.avgifter_amount, 0) - const roundedAvgifter = Math.round(totalAvgifter * 100) / 100 + // Override-coalesced, like the booking (book-run.ts): the preview must + // project the voucher that would actually post. F-skatt rows ignore + // avgifter overrides and carry no underlag, matching book-run and the + // AGI's isFSkattRow invariant. + const isFSkattRow = (e: { employee?: { f_skatt_status?: string | null } | null }) => + isFSkattStatus(e.employee?.f_skatt_status) + const totalAvgifter = employees.reduce( + (sum, e) => + sum + + ((isFSkattRow(e) ? e.avgifter_amount : e.avgifter_amount_override ?? e.avgifter_amount) || + 0), + 0, + ) + const roundedAvgifter = roundOre(totalAvgifter) + // Identical split to createAvgifterEntry (shared function): 2731 gets the + // whole-krona amount Skatteverket computes from the underlag, the + // remainder goes to 3740; the 7510 cost side stays exact. + const { liabilityAvgifter, oresutjamning } = splitAvgifterLiability( + { + employees: (employees as Array>).map((sre) => { + const fSkatt = isFSkattRow(sre as never) + return { + avgifter_amount: + ((fSkatt + ? (sre.avgifter_amount as number) + : (sre.avgifter_amount_override as number | null) ?? + (sre.avgifter_amount as number)) || 0), + avgifter_basis: fSkatt ? 0 : (sre.avgifter_basis as number | undefined), + avgifter_rate: sre.avgifter_rate as number, + avgifter_category: (sre.avgifter_category as string | null) ?? null, + avgifter_amount_overridden: + !fSkatt && (sre.avgifter_amount_override as number | null) != null, + } + }), + calculation_params: run.calculation_params as Record | null, + }, + roundedAvgifter, + ) const avgifterLines: CreateJournalEntryLineInput[] = roundedAvgifter !== 0 ? [ { @@ -117,12 +223,26 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( credit_amount: 0, line_description: `${desc}: Arbetsgivaravgifter`, }, - { - account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY, - debit_amount: 0, - credit_amount: roundedAvgifter, - line_description: `${desc}: Arbetsgivaravgifter`, - }, + ...(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, + ] + : []), ] : [] diff --git a/app/api/skatteverket/tax-payments/[period]/payment-file/__tests__/route.test.ts b/app/api/skatteverket/tax-payments/[period]/payment-file/__tests__/route.test.ts index 0ec8d10a..3ed3f65a 100644 --- a/app/api/skatteverket/tax-payments/[period]/payment-file/__tests__/route.test.ts +++ b/app/api/skatteverket/tax-payments/[period]/payment-file/__tests__/route.test.ts @@ -94,5 +94,44 @@ describe('GET /api/skatteverket/tax-payments/[period]/payment-file', () => { expect(response.headers.get('Content-Type')).toBe('text/plain; charset=iso-8859-1') expect(response.headers.get('Content-Disposition')).toContain('skatt-2026-04.txt') expect(mockGenerateBgLb).toHaveBeenCalledTimes(1) + expect(mockGenerateBgLb.mock.calls[0][1]).toMatchObject({ amount: 1500 }) + }) + + it('pays the declared whole-krona totals as-is for new-era declarations', async () => { + // Declarations generated since the whole-krona change store the declared + // integers (what Skatteverket computes from the underlag and draws), and + // the matching salary booking credited 2731 with the same number: the + // payment must be exactly their sum. + enqueue({ data: { id: 'agi-1', total_tax: 12268, total_avgifter: 16073 } }) // agi + enqueue({ data: { name: 'Test AB', org_number: '5566778899' } }) // companies + enqueue({ data: { bankgiro: '123-4567' } }) // company_settings + enqueue({ data: null, error: null }) // update tax_payment_file_generated_at + + const response = await GET( + createMockRequest('/api/skatteverket/tax-payments/2026-04/payment-file'), + createMockRouteParams({ period: '2026-04' }), + ) + + expect(response.status).toBe(200) + expect(mockGenerateBgLb.mock.calls[0][1]).toMatchObject({ amount: 28341 }) + }) + + it('keeps paying öre-exact for legacy öre-bearing declarations', async () => { + // Legacy rows predate the whole-krona storage: their salary bookings + // credited 2731 with the öre, so the payment keeps clearing 2731 in full + // (the öre parks as a small skattekonto överskott, the pre-existing + // equilibrium). Truncating here would strand the öre on 2731 instead. + enqueue({ data: { id: 'agi-1', total_tax: 12268, total_avgifter: 16073.84 } }) // agi + enqueue({ data: { name: 'Test AB', org_number: '5566778899' } }) // companies + enqueue({ data: { bankgiro: '123-4567' } }) // company_settings + enqueue({ data: null, error: null }) // update tax_payment_file_generated_at + + const response = await GET( + createMockRequest('/api/skatteverket/tax-payments/2026-04/payment-file'), + createMockRouteParams({ period: '2026-04' }), + ) + + expect(response.status).toBe(200) + expect(mockGenerateBgLb.mock.calls[0][1]).toMatchObject({ amount: 28341.84 }) }) }) diff --git a/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts b/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts index bec19724..52f536ba 100644 --- a/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts +++ b/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts @@ -5,6 +5,7 @@ import { getErrorMessage } from '@/lib/errors/get-error-message' import { generateBankgiroPaymentBgLb } from '@/lib/salary/payment/bg-lb-generator' import { generateSkattekontoOcr, SKATTEKONTO_BANKGIRO } from '@/lib/skatteverket/skattekonto-ocr' import { validateBankgiroNumber } from '@/lib/bankgiro/luhn' +import { roundOre } from '@/lib/money' ensureInitialized() @@ -51,7 +52,17 @@ export const GET = withRouteContext<{ params: Promise<{ period: string }> }>( ) } - const totalAmount = Math.round((agi.total_tax + agi.total_avgifter) * 100) / 100 + // Declarations generated since the whole-krona change store the declared + // amounts (what Skatteverket computes from the underlag and draws): pay + // exactly those. Legacy öre-bearing rows predate that storage; their + // salary bookings credited 2731 with the öre, so keep paying öre-exact as + // before: the öre lands as a small skattekonto överskott (the pre-existing + // equilibrium) instead of stranding on 2731 with no counterpart. + const declaredWholeKronor = + Number.isInteger(agi.total_tax) && Number.isInteger(agi.total_avgifter) + const totalAmount = declaredWholeKronor + ? agi.total_tax + agi.total_avgifter + : roundOre(agi.total_tax + agi.total_avgifter) if (totalAmount <= 0) { return NextResponse.json( { error: `Inget belopp att betala för perioden ${period}.` }, diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/__tests__/lifecycle.test.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/__tests__/lifecycle.test.ts index 70ae9f96..9ebd7583 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/__tests__/lifecycle.test.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/__tests__/lifecycle.test.ts @@ -487,6 +487,85 @@ describe('POST /salary-runs/:id/book', () => { ) }) + it('applies review overrides with book-run parity (tax/net reconciled, F-skatt avgifter override ignored)', async () => { + // Overrides set during dashboard review must reach the ledger the same + // way no matter which surface books the run: v1 previously ignored them, + // so the booked 2710/2731 diverged from the AGI by the override delta. + const overriddenRow = { + ...employeeRow, + tax_withheld_override: 9000, + avgifter_amount_override: 10000, + avgifter_basis: 35000, + avgifter_category: 'standard', + } + const fSkattRow = { + ...employeeRow, + employee_id: 'emp_2', + employee: { employment_type: 'employee', f_skatt_status: 'f_skatt' }, + gross_salary: 15000, + tax_withheld: 0, + net_salary: 15000, + avgifter_amount: 0, + // An avgifter override on an F-skatt row must be ignored (the AGI's + // isFSkattRow invariant), and the underlag zeroed. + avgifter_amount_override: 500, + avgifter_basis: 15000, + avgifter_category: 'standard', + } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: [ + { data: paidRun, error: null }, + { + data: { + id: RUN_ID, status: 'booked', + booked_at: '2026-05-26T09:15:00Z', booked_by: USER_ID, + salary_entry_id: 'je_salary', avgifter_entry_id: 'je_avg', + vacation_entry_id: null, pension_entry_id: null, + }, + error: null, + }, + ], + salary_run_employees: { data: [overriddenRow, fSkattRow], error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + mocks.checkPeriodLock.mockResolvedValue({ locked: false }) + mocks.createSalaryRunEntries.mockResolvedValue({ + salaryEntry: { id: 'je_salary', voucher_number: 'L2026-0024' }, + avgifterEntry: { id: 'je_avg' }, + vacationEntry: null, + pensionEntry: null, + }) + + const res = await book( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/book`, { + method: 'POST', + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(200) + const payload = mocks.createSalaryRunEntries.mock.calls[0][3] as { + employees: Array> + } + const [regular, fSkatt] = payload.employees + expect(regular).toMatchObject({ + tax_withheld: 9000, + // net reconciles by the withheld difference: 25 500 + (9 500 - 9 000). + net_salary: 26000, + avgifter_amount: 10000, + avgifter_amount_overridden: true, + avgifter_basis: 35000, + }) + expect(fSkatt).toMatchObject({ + avgifter_amount: 0, + avgifter_amount_overridden: false, + avgifter_basis: 0, + }) + }) + it('returns PERIOD_LOCKED before invoking the engine when payment_date is locked', async () => { mockServiceClient.mockReturnValue( makeFlexibleSupabase({ diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts index a106e935..181f07de 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts @@ -34,6 +34,7 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { checkPeriodLock } from '@/lib/api/v1/check-period-lock' import { createSalaryRunEntries } from '@/lib/salary/salary-entries' +import { isFSkattStatus } from '@/lib/salary/declared-avgifter' import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger' import { isBookkeepingError } from '@/lib/bookkeeping/errors' import { eventBus } from '@/lib/events' @@ -156,7 +157,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string // 3. Load run + employees + line items for the engine. const { data: employees, error: empErr } = await ctx.supabase .from('salary_run_employees') - .select('*, employee:employees(employment_type, default_dimensions), line_items:salary_line_items(*)') + .select('*, employee:employees(employment_type, default_dimensions, f_skatt_status), line_items:salary_line_items(*)') .eq('salary_run_id', salaryRunId) if (empErr) { return v1ErrorResponse(empErr, ctx.log, { requestId: ctx.requestId }) @@ -210,12 +211,20 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string // 4. Engine call. Strict-mode: any throw aborts before status flip. type EmpRow = { employee_id: string - employee: { employment_type: string; default_dimensions?: Record } | null + employee: { + employment_type: string + default_dimensions?: Record + f_skatt_status?: string | null + } | null gross_salary: number tax_withheld: number + tax_withheld_override: number | null net_salary: number avgifter_amount: number + avgifter_amount_override: number | null avgifter_rate: number + avgifter_basis: number + avgifter_category: string | null vacation_accrual: number vacation_accrual_avgifter: number line_items: Array<{ @@ -247,10 +256,28 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string employee_id: sre.employee_id, employment_type: sre.employee?.employment_type || 'employee', gross_salary: sre.gross_salary, - tax_withheld: sre.tax_withheld, - net_salary: sre.net_salary, - avgifter_amount: sre.avgifter_amount, + // Override parity with book-run.ts: review overrides must reach + // the ledger identically no matter which surface books the run, + // or the booked 2731/2710 diverge from the AGI totals by the full + // override delta. F-skatt rows ignore avgifter overrides (the AGI + // hard-excludes them via isFSkattRow). + tax_withheld: sre.tax_withheld_override ?? sre.tax_withheld, + net_salary: + sre.net_salary + (sre.tax_withheld - (sre.tax_withheld_override ?? sre.tax_withheld)), + avgifter_amount: + isFSkattStatus(sre.employee?.f_skatt_status) + ? sre.avgifter_amount + : sre.avgifter_amount_override ?? sre.avgifter_amount, avgifter_rate: sre.avgifter_rate, + // Declared-avgifter inputs: 2731 books the whole-krona amount + // Skatteverket computes from the underlag (declared-avgifter.ts). + // Zeroed for F-skatt rows, matching book-run and the AGI's + // isFSkattRow invariant. + avgifter_basis: + isFSkattStatus(sre.employee?.f_skatt_status) ? 0 : sre.avgifter_basis, + avgifter_category: sre.avgifter_category ?? null, + avgifter_amount_overridden: + !isFSkattStatus(sre.employee?.f_skatt_status) && sre.avgifter_amount_override != null, vacation_accrual: sre.vacation_accrual, vacation_accrual_avgifter: sre.vacation_accrual_avgifter, // Dimensions PR8: read-at-book from the employee row. diff --git a/components/salary/SalaryOverridePanel.tsx b/components/salary/SalaryOverridePanel.tsx index 796b103d..8ff28879 100644 --- a/components/salary/SalaryOverridePanel.tsx +++ b/components/salary/SalaryOverridePanel.tsx @@ -60,8 +60,12 @@ export function SalaryOverridePanel(props: SalaryOverridePanelProps) { async function handleSave() { setSaving(true) try { + // Skatteavdrag is stated in whole kronor (öretal bortfaller): the + // schema rejects öre, so drop them here instead of bouncing the save + // with a 400 when someone types a decimal. + const taxOverride = num(taxStr) const body = { - tax_withheld_override: num(taxStr), + tax_withheld_override: taxOverride === null ? null : Math.trunc(taxOverride), avgifter_amount_override: num(avgStr), avgifter_basis_override: num(basisStr), reason: reason.trim() || null, diff --git a/components/salary/TaxPaymentPanel.tsx b/components/salary/TaxPaymentPanel.tsx index 7ce7b76c..60109bc2 100644 --- a/components/salary/TaxPaymentPanel.tsx +++ b/components/salary/TaxPaymentPanel.tsx @@ -11,6 +11,7 @@ import { postAction } from '@/lib/browser/post-action' import { failureDescription } from '@/lib/browser/action-failure' import type { ErrorLocale } from '@/lib/errors/get-error-message' import { formatCurrency } from '@/lib/utils' +import { roundOre } from '@/lib/money' interface TaxPaymentPanelProps { /** YYYY-MM */ @@ -54,7 +55,13 @@ export function TaxPaymentPanel({ setPaymentDeadline(`${dlYear}-${String(dlMonth).padStart(2, '0')}-12`) }, [period]) - const totalAmount = Math.round((totalTax + totalAvgifter) * 100) / 100 + // The page passes the AGI declaration's stored totals when the AGI exists + // (whole kronor for declarations generated since the whole-krona change: + // exactly what the payment file pays and Skatteverket draws), falling back + // to run totals. Display what will actually be paid: no reformatting here, + // so legacy öre declarations still show the öre-exact amount their + // payment file pays. + const totalAmount = roundOre(totalTax + totalAvgifter) const handleDownload = useCallback(async () => { // Both buttons are disabled while either is in flight; this guard closes the diff --git a/components/salary/run/RunJournalPreview.tsx b/components/salary/run/RunJournalPreview.tsx index 30eb73a5..ff257626 100644 --- a/components/salary/run/RunJournalPreview.tsx +++ b/components/salary/run/RunJournalPreview.tsx @@ -19,6 +19,10 @@ export interface EntryPreview { } export interface PreviewData { + // True when the entries are the ACTUAL posted verifikat of a booked run + // (the preview route returns those instead of a recomputed projection, + // which could contradict vouchers booked under earlier rules). + booked?: boolean salaryEntry: EntryPreview | null avgifterEntry: EntryPreview | null vacationEntry: EntryPreview | null @@ -46,7 +50,9 @@ export function RunJournalPreview({ preview, onRecalculate, recalculating }: Run return ( - {t('journal_preview_title')} + + {preview.booked ? t('journal_booked_title') : t('journal_preview_title')} + {onRecalculate && (