Files
accounted/lib/salary/book-run.ts
T
0743717033 fix(salary): keep the payslip's Ackumulerat total from going stale (#1911)
* fix(salary): keep the payslip's Ackumulerat total from going stale

`salary_run_employees.ytd_*` (the "Ackumulerat {år}" block on the
lönespecifikation) was written once at calculation time and never
recomputed, from a query that only counted prior runs already in
`booked`. Preparing next month's run before the current one is booked
(entirely normal) therefore froze a YTD that is permanently missing the
month in between, and the employee's payslip understates the year.

Seen in production: an August run calculated on 2026-07-23, three days
before the July run was booked, shipped a payslip whose Ackumulerat brutto
was 60 000 kr instead of 95 000 kr.

Two fixes, both in the new lib/salary/ytd.ts:

- `computePriorYtd` counts `approved`, `paid` and `booked` prior runs, not
  only `booked`. `corrected` stays excluded: its correction run replaces
  the whole month, so counting both would double it.
- `refreshRunYtd` recomputes and rewrites the snapshot, and is now called
  at approval (the first status lönebesked can be sent from) and at
  booking, on both the dashboard and v1 surfaces. Rows already correct are
  left untouched; a failure is logged and never blocks an approval or a
  booking.

The snapshot stays a snapshot rather than becoming a render-time sum: an
employee re-opening a lönebesked must see the figures it had when it was
issued. YTD is display and reporting only, so nothing here can move a
verifikation: the per-month tax lookup and the avgifter caps never read it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(salary): fail loudly on a YTD read error and paginate the reads

Review follow-up on both counts:

- The opening-balance and prior-run reads discarded their `error`. A failed
  read looked exactly like a month with no prior pay, so `refreshRunYtd`
  would rewrite the snapshot to the current month alone and still report
  success. Both now throw; `refreshRunYtd` turns that into `ok: false` for
  its callers to log, and `runSalaryCalculation` returns DATABASE_ERROR the
  way it already does for every other query error in that function.
- The prior-run and roster reads now page through `fetchAllRows()` ordered
  on the primary key. A full roster times eleven prior months passes
  PostgREST's 1000-row cap well before an employer is large by Swedish
  standards, and a silent truncation there understates somebody's
  Ackumulerat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(salary): one paginated loader for cutover opening balances

Review follow-up. `run-calculation` and `ytd` each read
employee_opening_balances with their own unpaginated, error-discarding
query. Both now go through `loadOpeningBalances()`: paged via
fetchAllRows() ordered on the primary key, and throwing on a read error.

The error path matters more than the paging one here. That row carries
`karens_periods_adjustment` as well as the YTD carry-in, and a discarded
error looked exactly like "nobody has a cutover balance" - which would
drop a karensavdrag from sjuklön silently, not just understate a display
figure. runSalaryCalculation now maps it to DATABASE_ERROR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 21:40:14 +02:00

430 lines
16 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 { refreshRunYtd } from '@/lib/salary/ytd'
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>> {
// Refresh the payslip's "Ackumulerat" snapshot before the status flip. The
// snapshot was written at calculation time from the months authorized back
// then; a month authorized since (the normal case when next month's run is
// prepared early) is missing from it. Non-fatal: YTD is display only and
// never reaches a verifikation, so a refresh failure must not block a
// booking.
const ytdRefresh = await refreshRunYtd(supabase, { companyId, salaryRunId })
if (!ytdRefresh.ok) {
log.warn('YTD refresh failed before booking', { salaryRunId, message: ytdRefresh.message })
}
// 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 } }
}