Files
accounted/lib/salary/declared-avgifter.ts
T
Mattsson 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

285 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
}