fix(salary): declare, book and pay AGI in whole kronor (SKV per-sats computation) (#1611)

* fix(salary): declare, book and pay AGI in whole kronor (SKV per-sats computation)

A user's first lönekörning surfaced öre amounts in the AGI payable while
Skatteverket deals in whole kronor. Three connected defects:

- the AGI XML rounded amounts (Math.round); öretal bortfaller (SFF
  2011:1261 22 kap. 1 §) requires truncation, and FK487 must be
  Skatteverket's own per-sats computation on the whole-krona underlag sums
  (IK587, kontroll B_006), not a truncation of the öre-exact engine sum
- the salary booking credited 2731 with exact öre, leaving a residual
  after the whole-krona skattekonto draw; 2731 now carries the declared
  amount with the remainder on 3740 (Öres- och kronutjämning)
- the LB payment file and TaxPaymentPanel paid/showed öre; they now use
  the declared whole-krona totals stored on agi_declarations (which also
  lets skattekonto auto-settlement match the draw); legacy öre rows keep
  paying öre-exact so pre-deploy bookings still clear 2731

New lib/salary/declared-avgifter.ts implements the SKV computation (per-IU
whole-krona underlag, per-sats sums, youth/växa cap splits, exact integer
math) shared by the AGI generator, the booking split and the preview.
Review overrides route all legs through the same per-category truncation;
basis overrides are inert on money totals (they never reach the filed
IUs); the v1 book route gains override parity with book-run; F-skatt rows
ignore avgifter overrides on every surface. Booked runs show their posted
verifikat instead of a recomputed projection. tax_withheld_override
requires whole kronor. Adversarially verified over three /skeptic rounds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: merge origin/main and re-ratchet the öre-round baseline

The merge brought #1609 (net-pay öresavrundning) whose two new
Math.round(x*100)/100 occurrences are counted against the baseline this
branch had tightened from 637 to 629; 631 keeps the net -6 improvement
without policing already-merged code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): address PR review (hybrid override computation, legacy youth cap, robustness)

CodeRabbit round on #1611, all findings in one pass:

- computeDeclaredAvgifterWithOverrides: one shared hybrid for the AGI
  generator AND the booking split. Overridden rows contribute their manual
  amounts per category; colleagues keep the SKV-exact per-sats underlag
  computation (a FoU override on one employee no longer costs the rest of
  the roster kronor of declared accuracy)
- youth cap keys on the RESOLVED category so legacy null-category rows
  classified as youth by the rate heuristic still get the 25k split
- F-skatt rows zero their avgifter_basis on both booking surfaces and in
  the preview, matching the AGI's isFSkattRow invariant
- preview route: posted-voucher lookup errors return 500 instead of
  masquerading as a booked run with no vouchers; 400/500 tests added
- run page clears stale AGI totals when the tax-payment fetch fails
- SalaryOverridePanel truncates the tax override to whole kronor so the
  schema's .int() cannot bounce a decimal input with a 400
- v1 book route override parity pinned by a lifecycle test
- DECISIONS.md format fixes + superseded entry marked; exempt category
  mapped explicitly; unified truncation-drift band with rationale

Declined (recorded): dating the decision entries 2026-08-13 (bot assumed
UTC; the decisions were made after midnight local time).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): round-2 review nits (shared F-skatt helper, test hygiene)

- isFSkattStatus in declared-avgifter.ts: single source for the F-skatt
  exclusion, consumed by book-run, the v1 book route, the preview route and
  the AGI generator, per the Swedish review's drift-risk finding
- declared-avgifter test suite gets the standard beforeEach cleanup

Declined (recorded for the summary): auto-generated correction voucher for
regenerated legacy periods (data-repair follow-up needing Emil's go); SFF
22 kap. 1 par. citation doubt (verified against lagen.nu and already shipped
in tax-tables.ts); 3740 scope doubt (BAS generic utjamning account, Visma
praxis, matches the user's reference voucher).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

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