Files
accounted/lib/salary/book-run.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

418 lines
15 KiB
TypeScript

/**
* Shared salary-run booking orchestration.
*
* `bookPaidSalaryRun` is the booking core extracted from the dashboard's
* `POST /api/salary/runs/{id}/book` route: load the paid run + roster,
* handle the nollkörning branch (zero-amount runs post nothing: the engine
* forbids zero vouchers), otherwise post 2-4 verifikationer via
* `createSalaryRunEntries()`, advance `paid` → `booked`, emit
* `salary_run.booked`, and sync the vacation ledger (non-fatal).
*
* `advanceAndBookSalaryRun` is the pending-operation executor path for the
* MCP tool `gnubok_book_salary_run`: the human approval of the staged
* operation is the authorization act, so it walks a calculated run through
* the remaining statuses (draft → review → approved → paid) with the same
* validations the dashboard routes apply, then books. Missing bank details
* surface as warnings rather than blockers (mirroring the dashboard's
* force-approve path): the payment-file generators hard-block on them where
* it actually matters.
*
* Bookkeeping-engine errors (period locks, unbalanced entries) THROW out of
* both functions: callers map them via their own envelope, exactly like the
* route did before extraction. The v1 route keeps its own strict-mode mirror
* (optimistic locking, period pre-check) on purpose.
*/
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'
import { eventBus } from '@/lib/events'
export type BookRunResult<T> =
| { ok: true; data: T }
| { ok: false; code: string; details?: Record<string, unknown>; dbError?: unknown }
export interface BookedRunData {
run: Record<string, unknown>
entryIds: string[]
nollkorning: boolean
}
interface BookRunArgs {
companyId: string
userId: string
salaryRunId: string
log: Logger
}
const ROSTER_SELECT =
'*, employee:employees(first_name, last_name, employment_type, default_dimensions, f_skatt_status, clearing_number, bank_account_number, email)'
type RosterRow = Record<string, unknown> & {
employee_id: string
net_salary: number
tax_withheld: number
tax_withheld_override: number | null
employee: {
first_name: string
last_name: string
employment_type: string | null
default_dimensions: Record<string, string> | null
f_skatt_status: string | null
clearing_number: string | null
bank_account_number: string | null
email: string | null
} | null
line_items: Array<Record<string, unknown>> | null
}
async function loadRoster(
supabase: SupabaseClient,
salaryRunId: string,
): Promise<BookRunResult<RosterRow[]>> {
const { data, error } = await supabase
.from('salary_run_employees')
.select(`${ROSTER_SELECT}, line_items:salary_line_items(*)`)
.eq('salary_run_id', salaryRunId)
if (error) {
return { ok: false, code: 'SALARY_RUN_BOOK_FAILED', dbError: error }
}
return { ok: true, data: (data ?? []) as RosterRow[] }
}
async function bookLoadedRun(
supabase: SupabaseClient,
{ companyId, userId, salaryRunId, log }: BookRunArgs,
run: Record<string, unknown>,
roster: RosterRow[],
): Promise<BookRunResult<BookedRunData>> {
// Nollkörning: a run with no monetary effect (employees set to 0 kr, or no
// roster at all) has nothing to post. The bookkeeping engine forbids
// zero-amount vouchers (every entry must balance with debit & credit > 0),
// so we skip journal-entry creation entirely and just advance to 'booked'.
// The AGI nolldeklaration is then the only artefact for the period.
const nothingToBook =
Math.round(((run.total_gross as number) ?? 0) * 100) === 0 &&
Math.round(((run.total_tax as number) ?? 0) * 100) === 0 &&
Math.round(((run.total_avgifter as number) ?? 0) * 100) === 0 &&
Math.round(((run.total_vacation_accrual as number) ?? 0) * 100) === 0
if (nothingToBook) {
const { data: bookedRun, error: updateError } = await supabase
.from('salary_runs')
.update({
status: 'booked',
booked_at: new Date().toISOString(),
booked_by: userId,
})
.eq('id', salaryRunId)
.eq('company_id', companyId)
.select()
.single()
if (updateError) {
return { ok: false, code: 'SALARY_RUN_BOOK_FAILED', dbError: updateError }
}
await eventBus.emit({
type: 'salary_run.booked',
payload: { salaryRunId, entryIds: [], userId, companyId },
})
// Vacation ledger sync (non-fatal: the ledger recomputes and self-heals
// on the next booking; a sync bug must never block a booking).
const nollSync = await syncVacationLedgerForEmployees(
supabase,
companyId,
roster.map((sre) => sre.employee_id),
)
if (!nollSync.ok) {
log.warn('vacation ledger sync failed after nollkörning booking', { message: nollSync.message })
}
log.info('salary run booked as nollkörning (no journal entries)', { salaryRunId })
return { ok: true, data: { run: bookedRun, entryIds: [], nollkorning: true } }
}
const { salaryEntry, avgifterEntry, vacationEntry, pensionEntry } = await createSalaryRunEntries(
supabase,
companyId,
userId,
{
id: run.id as string,
period_year: run.period_year as number,
period_month: run.period_month as number,
payment_date: run.payment_date as string,
voucher_series: run.voucher_series as string,
total_gross: run.total_gross as number,
total_tax: run.total_tax as number,
total_net: run.total_net as number,
total_avgifter: run.total_avgifter as number,
total_vacation_accrual: run.total_vacation_accrual as number,
// Use the exact payroll-rate snapshot approved with this run. Reading
// current config here could change SLP between calculation and booking.
calculation_params: run.calculation_params as Record<string, unknown> | null,
employees: roster.map((sre) => ({
employee_id: sre.employee_id,
employment_type: sre.employee?.employment_type || 'employee',
gross_salary: sre.gross_salary as number,
// Apply per-employee overrides (advanced mode) so manual
// adjustments for FoU-avdrag / jämkning flow into the ledger.
tax_withheld: (sre.tax_withheld_override as number | null) ?? (sre.tax_withheld as number),
net_salary:
(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:
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
// review shows the same live bag, so preview matches booking.
default_dimensions: sre.employee?.default_dimensions ?? undefined,
line_items: (sre.line_items || []).map((li: Record<string, unknown>) => ({
item_type: li.item_type as string,
amount: li.amount as number,
account_number: li.account_number as string | null,
is_net_deduction: li.is_net_deduction as boolean,
is_gross_deduction: li.is_gross_deduction as boolean,
})),
})),
},
)
const entryIds = [salaryEntry.id, avgifterEntry.id]
const updates: Record<string, unknown> = {
status: 'booked',
salary_entry_id: salaryEntry.id,
avgifter_entry_id: avgifterEntry.id,
booked_at: new Date().toISOString(),
booked_by: userId,
}
if (vacationEntry) {
updates.vacation_entry_id = vacationEntry.id
entryIds.push(vacationEntry.id)
}
if (pensionEntry) {
updates.pension_entry_id = pensionEntry.id
entryIds.push(pensionEntry.id)
}
const { data: bookedRun, error: updateError } = await supabase
.from('salary_runs')
.update(updates)
.eq('id', salaryRunId)
.select()
.single()
if (updateError) {
return { ok: false, code: 'SALARY_RUN_BOOK_FAILED', dbError: updateError }
}
await eventBus.emit({
type: 'salary_run.booked',
payload: { salaryRunId, entryIds, userId, companyId },
})
// Vacation ledger sync (non-fatal, see the nollkörning branch).
const ledgerSync = await syncVacationLedgerForEmployees(
supabase,
companyId,
roster.map((sre) => sre.employee_id),
)
if (!ledgerSync.ok) {
log.warn('vacation ledger sync failed after booking', { message: ledgerSync.message })
}
return { ok: true, data: { run: bookedRun, entryIds, nollkorning: false } }
}
/**
* paid → booked. Exact semantics of the dashboard book route: the run must
* already be in 'paid' status.
*/
export async function bookPaidSalaryRun(
supabase: SupabaseClient,
args: BookRunArgs,
): Promise<BookRunResult<BookedRunData>> {
const { data: run, error: runError } = await supabase
.from('salary_runs')
.select('*')
.eq('id', args.salaryRunId)
.eq('company_id', args.companyId)
.eq('status', 'paid')
.single()
if (runError || !run) {
return {
ok: false,
code: 'SALARY_RUN_NOT_CALCULATED',
details: { reason: 'must_be_paid_status' },
}
}
const roster = await loadRoster(supabase, args.salaryRunId)
if (!roster.ok) return roster
return bookLoadedRun(supabase, args, run, roster.data)
}
export interface AdvanceAndBookData extends BookedRunData {
warnings: string[]
}
/**
* Walk a calculated salary run through review → approved → paid → booked.
*
* Used by the `book_salary_run` pending-operation executor: the staged
* operation's human approval covers the authorization the dashboard collects
* per-status. Validation parity with the dashboard routes:
* - every roster row must carry a calculation_breakdown (blocking)
* - missing bank details (for a positive net payout) and missing email are
* warnings, not blockers (dashboard force-approve semantics)
* - F-skatt not verified surfaces as a warning (review route parity)
*/
export async function advanceAndBookSalaryRun(
supabase: SupabaseClient,
args: BookRunArgs,
): Promise<BookRunResult<AdvanceAndBookData>> {
const { companyId, userId, salaryRunId } = args
const { data: run, error: runError } = await supabase
.from('salary_runs')
.select('*')
.eq('id', salaryRunId)
.eq('company_id', companyId)
.single()
if (runError || !run) {
return { ok: false, code: 'SALARY_RUN_NOT_FOUND' }
}
let status = run.status as string
if (status === 'booked') {
return { ok: false, code: 'SALARY_RUN_ALREADY_BOOKED' }
}
if (!['draft', 'review', 'approved', 'paid'].includes(status)) {
return { ok: false, code: 'SALARY_RUN_BOOK_FAILED', details: { reason: `unknown status: ${status}` } }
}
const rosterResult = await loadRoster(supabase, salaryRunId)
if (!rosterResult.ok) return rosterResult
const roster = rosterResult.data
const warnings: string[] = []
if (status === 'draft' || status === 'review') {
// Blocking: a roster row without a calculation would post a wrong
// verifikation. Same gate as the dashboard approve route.
const uncalculated = roster
.filter((sre) => !sre.calculation_breakdown)
.map((sre) => `${sre.employee?.first_name ?? ''} ${sre.employee?.last_name ?? ''}`.trim() || sre.employee_id)
if (uncalculated.length > 0) {
return {
ok: false,
code: 'SALARY_RUN_NOT_CALCULATED',
details: { employees: uncalculated },
}
}
for (const sre of roster) {
const emp = sre.employee
if (!emp) continue
const name = `${emp.first_name} ${emp.last_name}`
if (emp.f_skatt_status === 'not_verified') {
warnings.push(
`${name}: F-skatt ej verifierad: 30% skatteavdrag och fulla avgifter tillämpas (f-skatt.md)`,
)
}
if (effectiveNetPayout(sre) > 0 && (!emp.clearing_number || !emp.bank_account_number)) {
warnings.push(`${name}: Bankuppgifter saknas (clearingnummer och/eller kontonummer)`)
}
if (!emp.email) {
warnings.push(`${name}: E-post saknas, lönebesked kan inte skickas`)
}
}
}
if (status === 'draft') {
const { data: reviewed, error } = await supabase
.from('salary_runs')
.update({ status: 'review' })
.eq('id', salaryRunId)
.eq('company_id', companyId)
.eq('status', 'draft')
.select('id')
.single()
if (error || !reviewed) {
return { ok: false, code: 'SALARY_RUN_BOOK_FAILED', dbError: error ?? undefined }
}
status = 'review'
}
if (status === 'review') {
const { data: approved, error } = await supabase
.from('salary_runs')
.update({
status: 'approved',
approved_by: userId,
approved_at: new Date().toISOString(),
})
.eq('id', salaryRunId)
.eq('company_id', companyId)
.eq('status', 'review')
.select('id')
.single()
if (error || !approved) {
return { ok: false, code: 'SALARY_RUN_BOOK_FAILED', dbError: error ?? undefined }
}
await eventBus.emit({
type: 'salary_run.approved',
payload: { salaryRunId, approvedBy: userId, userId, companyId },
})
status = 'approved'
}
if (status === 'approved') {
const { data: paid, error } = await supabase
.from('salary_runs')
.update({ status: 'paid', paid_at: new Date().toISOString() })
.eq('id', salaryRunId)
.eq('company_id', companyId)
.eq('status', 'approved')
.select('id')
.single()
if (error || !paid) {
return { ok: false, code: 'SALARY_RUN_BOOK_FAILED', dbError: error ?? undefined }
}
status = 'paid'
}
const booked = await bookLoadedRun(supabase, args, run, roster)
if (!booked.ok) return booked
return { ok: true, data: { ...booked.data, warnings } }
}