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