Files
accounted/app/api/salary/runs/[id]/preview/route.ts
T
MattssonandClaude Fable 5 4e14182a00 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>
2026-08-14 02:22:07 +02:00

337 lines
14 KiB
TypeScript

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'
ensureInitialized()
/**
* Preview the journal entries that would be created when booking this salary run.
* Shows exact BAS accounts and amounts: this is a key differentiator.
*/
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.run.preview',
async (_request, ctx, { params }) => {
const { id } = await params
const { supabase, companyId } = ctx
const { data: run, error: runError } = await supabase
.from('salary_runs')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (runError || !run) {
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, f_skatt_status), line_items:salary_line_items(*)')
.eq('salary_run_id', id)
if (!employees || employees.length === 0) {
return NextResponse.json({ error: 'Inga beräknade resultat: kör beräkning först' }, { status: 400 })
}
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
const desc = `Lön ${periodLabel}`
// Build salary entry preview
const salaryLines: CreateJournalEntryLineInput[] = []
const expenseByAccount = new Map<string, number>()
// Net deductions book as settlement lines on their mapped liability or
// receivable account, mirroring createSalaryEntry; they must not merge
// into the 7xxx expense buckets.
const netDeductionByAccount = new Map<string, number>()
for (const sre of employees) {
for (const li of sre.line_items || []) {
if (li.is_net_deduction) {
const account = li.account_number || getLineItemAccount(li.item_type, sre.employee?.employment_type || 'employee')
netDeductionByAccount.set(account, (netDeductionByAccount.get(account) || 0) + li.amount)
continue
}
if (li.is_gross_deduction) continue
const account = li.account_number || getLineItemAccount(li.item_type, sre.employee?.employment_type || 'employee')
expenseByAccount.set(account, (expenseByAccount.get(account) || 0) + li.amount)
}
}
for (const [account, amount] of expenseByAccount) {
if (amount === 0) continue
salaryLines.push({
account_number: account,
debit_amount: amount > 0 ? Math.round(amount * 100) / 100 : 0,
credit_amount: amount < 0 ? Math.round(Math.abs(amount) * 100) / 100 : 0,
line_description: `${desc}`,
})
}
for (const [account, amount] of netDeductionByAccount) {
const rounded = roundOre(Math.abs(amount))
if (rounded === 0) continue
salaryLines.push({
account_number: account,
debit_amount: amount > 0 ? rounded : 0,
credit_amount: amount < 0 ? rounded : 0,
line_description: `${desc}`,
})
}
const totalTax = employees.reduce((sum, e) => sum + e.tax_withheld, 0)
if (totalTax > 0) {
salaryLines.push({
account_number: SALARY_ACCOUNTS.TAX_WITHHELD,
debit_amount: 0,
credit_amount: Math.round(totalTax * 100) / 100,
line_description: `${desc}: Personalskatt`,
})
}
const totalNet = employees.reduce((sum, e) => sum + e.net_salary, 0)
if (totalNet > 0) {
salaryLines.push({
account_number: SALARY_ACCOUNTS.BANK,
debit_amount: 0,
credit_amount: Math.round(totalNet * 100) / 100,
line_description: `${desc}: Nettolön`,
})
}
// Build avgifter entry preview: skipped for a nollkörning (0 avgifter),
// 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.
// 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
? [
{
account_number: SALARY_ACCOUNTS.AVGIFTER_EXPENSE,
debit_amount: roundedAvgifter,
credit_amount: 0,
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,
]
: []),
]
: []
// Build vacation entry preview
const totalVacation = employees.reduce((sum, e) => sum + e.vacation_accrual, 0)
const totalVacationAvgifter = employees.reduce((sum, e) => sum + e.vacation_accrual_avgifter, 0)
const vacationLines: CreateJournalEntryLineInput[] = []
if (totalVacation > 0) {
vacationLines.push(
{
account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_EXPENSE,
debit_amount: Math.round(totalVacation * 100) / 100,
credit_amount: 0,
line_description: `${desc}: Semesteravsättning`,
},
{
account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_LIABILITY,
debit_amount: 0,
credit_amount: Math.round(totalVacation * 100) / 100,
line_description: `${desc}: Semesteravsättning`,
}
)
}
if (totalVacationAvgifter > 0) {
vacationLines.push(
{
account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_EXPENSE,
debit_amount: Math.round(totalVacationAvgifter * 100) / 100,
credit_amount: 0,
line_description: `${desc}: Sociala avgifter semester`,
},
{
account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_LIABILITY,
debit_amount: 0,
credit_amount: Math.round(totalVacationAvgifter * 100) / 100,
line_description: `${desc}: Sociala avgifter semester`,
}
)
}
// Build pension entry preview (löneväxling, per deductions-lonevaxling.md)
// This would be populated from salary_line_items with type 'gross_deduction_pension'
// For now, pension preview is shown when pension line items exist
const pensionLineItems = employees.flatMap(e =>
((e.line_items || []) as Array<Record<string, unknown>>)
.filter(li => li.item_type === 'gross_deduction_pension')
)
const pensionLines: CreateJournalEntryLineInput[] = []
if (pensionLineItems.length > 0) {
const totalPensionDeduction = Math.abs(pensionLineItems.reduce((s, li) => s + ((li.amount as number) || 0), 0))
const pensionContribution = Math.round(totalPensionDeduction * 1.058 * 100) / 100
const slp = Math.round(pensionContribution * 0.2426 * 100) / 100
if (pensionContribution > 0) {
pensionLines.push(
{ account_number: '7410', debit_amount: pensionContribution, credit_amount: 0, line_description: `${desc}: Pensionsförsäkringspremier` },
{ account_number: '2740', debit_amount: 0, credit_amount: pensionContribution, line_description: `${desc}: Pensionsförsäkringspremier` },
)
if (slp > 0) {
pensionLines.push(
{ account_number: '7533', debit_amount: slp, credit_amount: 0, line_description: `${desc}: Särskild löneskatt 24,26%` },
{ account_number: '2514', debit_amount: 0, credit_amount: slp, line_description: `${desc}: Särskild löneskatt 24,26%` },
)
}
}
}
return NextResponse.json({
data: {
// Each entry is null when it has no lines: a nollkörning posts nothing,
// so the salary and avgifter entries fall away just like vacation/pension
// already do, and the UI can simply skip the null ones.
salaryEntry: salaryLines.length > 0 ? {
description: desc,
lines: salaryLines,
} : null,
avgifterEntry: avgifterLines.length > 0 ? {
description: `${desc}: Arbetsgivaravgifter`,
lines: avgifterLines,
} : null,
vacationEntry: vacationLines.length > 0 ? {
description: `${desc}: Semesteravsättning`,
lines: vacationLines,
} : null,
pensionEntry: pensionLines.length > 0 ? {
description: `${desc}: Pensionsavsättning`,
lines: pensionLines,
} : null,
},
})
},
)