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

288 lines
10 KiB
TypeScript

/**
* Year-to-date (ackumulerat) totals for an employee's payslips.
*
* `salary_run_employees.ytd_gross/ytd_tax/ytd_net` is the "Ackumulerat
* {år}" block on the lönespecifikation. It is a stored snapshot, not a
* derived value: once written it stays put, so an employee who re-opens a
* payslip months later sees the same figures the PDF had when it was
* issued.
*
* The snapshot is written first at calculation time (run-calculation.ts) and
* then REFRESHED at every step that freezes the run's own figures: approval
* (the first status from which payslips can be sent) and booking. Without
* that refresh the snapshot silently rots: preparing next month's run before
* the current one is booked (entirely normal) captures a YTD that is missing
* the month in between, and nothing ever recomputes it.
*
* YTD is payslip display + reporting only. Per-month tax-table lookup and
* the per-month arbetsgivaravgifter caps never read it, so a refresh can
* never move a booked verifikation: it only corrects what the employee is
* shown.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre } from '@/lib/money'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
/**
* Run statuses whose amounts count toward an employee's YTD.
*
* - `approved` / `paid` / `booked`: the run's figures are authorized. The
* employee has (or is about to have) a payslip for that month, so it
* belongs in the accumulated total. Counting only `booked` was the
* original rule and understated YTD for every month paid but not yet
* posted to the ledger.
* - `draft` / `review`: still editable, no payslip issued.
* - `corrected`: superseded. The correction run replaces the whole month
* (the original's verifikationer are storno'd), so counting both would
* double the month.
*/
export const YTD_COUNTED_STATUSES = ['approved', 'paid', 'booked'] as const
export interface YtdTotals {
gross: number
tax: number
net: number
}
/** The subset of `employee_opening_balances` that YTD needs. */
export interface OpeningBalanceYtdRow {
employee_id: string
cutover_date: string
ytd_gross: number
ytd_tax: number
ytd_net: number
}
interface ComputePriorYtdArgs {
companyId: string
periodYear: number
periodMonth: number
/** Roster employee ids. An empty list short-circuits to an empty map. */
employeeIds: string[]
/**
* Cutover opening balances, when the caller has already loaded them
* (run-calculation reads the same rows for karensavdrag). Omitted, they
* are fetched here.
*/
openingRows?: OpeningBalanceYtdRow[]
}
/**
* An opening balance as stored, including the karensavdrag carry-over that
* the sjuklön calculation reads (not YTD's business, but the same row).
*/
export interface OpeningBalanceRow extends OpeningBalanceYtdRow {
karens_periods_adjustment: number
}
/**
* Every cutover opening balance on a roster.
*
* Throws on a read error rather than returning nothing: an empty result is
* indistinguishable from "nobody has a cutover balance", which would drop
* both the carry-in YTD and the karensavdrag adjustment without a trace.
* `refreshRunYtd` and `runSalaryCalculation` each turn the throw into their
* own error result.
*/
export async function loadOpeningBalances(
supabase: SupabaseClient,
companyId: string,
employeeIds: string[],
): Promise<OpeningBalanceRow[]> {
if (employeeIds.length === 0) return []
return (await fetchAllRows(({ from, to }) =>
supabase
.from('employee_opening_balances')
.select('employee_id, cutover_date, ytd_gross, ytd_tax, ytd_net, karens_periods_adjustment')
.eq('company_id', companyId)
.in('employee_id', employeeIds)
.order('id')
.range(from, to),
)) as unknown as OpeningBalanceRow[]
}
/**
* A prior month's contribution to an employee's YTD.
*
* `salary_run` is typed as an object: supabase-js infers the to-one embed as
* an array, but PostgREST returns an object for a many-to-one relationship.
*/
interface PriorRunRow {
employee_id: string
gross_salary: number
tax_withheld: number
net_salary: number
salary_run: { period_year: number; period_month: number }
}
/**
* YTD carried INTO a period: every counted run in earlier months of the same
* year, plus any pre-cutover balance from a previous payroll system.
*
* The current run's own amounts are deliberately excluded. Callers add them
* (they hold the authoritative per-employee figures: the engine result at
* calculation time, the stored row at refresh time).
*/
export async function computePriorYtd(
supabase: SupabaseClient,
{ companyId, periodYear, periodMonth, employeeIds, openingRows }: ComputePriorYtdArgs,
): Promise<Map<string, YtdTotals>> {
const ytdByEmployee = new Map<string, YtdTotals>()
if (employeeIds.length === 0) return ytdByEmployee
const opening = openingRows ?? (await loadOpeningBalances(supabase, companyId, employeeIds))
const openingByEmployee = new Map(opening.map((row) => [row.employee_id, row]))
// Paginated: 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 here understates somebody's Ackumulerat. Ordered by the
// PK so page boundaries neither skip nor duplicate a month.
const priorRuns = (await fetchAllRows(({ from, to }) =>
supabase
.from('salary_run_employees')
.select(
'employee_id, gross_salary, tax_withheld, net_salary, salary_run:salary_runs!inner(period_year, period_month, status)',
)
.eq('company_id', companyId)
.in('employee_id', employeeIds)
.eq('salary_run.period_year', periodYear)
.in('salary_run.status', YTD_COUNTED_STATUSES)
.lt('salary_run.period_month', periodMonth)
.order('id')
.range(from, to),
)) as unknown as PriorRunRow[]
for (const prior of priorRuns) {
// The opening balance is authoritative for pre-cutover YTD: a run
// backdated before the cutover month covers a month the opening already
// carries, so counting both would double the YTD.
const employeeOpening = openingByEmployee.get(prior.employee_id)
if (employeeOpening) {
const cutoverYear = Number(employeeOpening.cutover_date.slice(0, 4))
const cutoverMonth = Number(employeeOpening.cutover_date.slice(5, 7))
if (
prior.salary_run.period_year === cutoverYear &&
prior.salary_run.period_month < cutoverMonth
) {
continue
}
}
const current = ytdByEmployee.get(prior.employee_id) || { gross: 0, tax: 0, net: 0 }
current.gross += prior.gross_salary
current.tax += prior.tax_withheld
current.net += prior.net_salary
ytdByEmployee.set(prior.employee_id, current)
}
// Merge the opening YTD when the period is in the cutover year, on or
// after the cutover month (the month gate prevents a double-count if
// someone backdates an in-system run before cutover).
for (const row of opening) {
const cutoverYear = Number(row.cutover_date.slice(0, 4))
const cutoverMonth = Number(row.cutover_date.slice(5, 7))
if (!(periodYear === cutoverYear && periodMonth >= cutoverMonth)) continue
const current = ytdByEmployee.get(row.employee_id) || { gross: 0, tax: 0, net: 0 }
current.gross = roundOre(current.gross + (row.ytd_gross || 0))
current.tax = roundOre(current.tax + (row.ytd_tax || 0))
current.net = roundOre(current.net + (row.ytd_net || 0))
ytdByEmployee.set(row.employee_id, current)
}
return ytdByEmployee
}
/** The roster columns the refresh reads and rewrites. */
interface RosterYtdRow {
id: string
employee_id: string
gross_salary: number
tax_withheld: number
net_salary: number
ytd_gross: number
ytd_tax: number
ytd_net: number
}
export type RefreshRunYtdResult =
| { ok: true; updated: number }
| { ok: false; message: string }
/**
* Recompute and store the YTD snapshot for every employee on a run.
*
* Callers treat a failure as non-fatal (log and continue): YTD is a display
* figure, and refusing to approve or book a run because an accumulated total
* could not be recomputed would be the worse outcome. Rows whose stored
* values are already correct are left untouched, so a re-run is a no-op
* rather than an `updated_at` churn.
*/
export async function refreshRunYtd(
supabase: SupabaseClient,
{ companyId, salaryRunId }: { companyId: string; salaryRunId: string },
): Promise<RefreshRunYtdResult> {
const { data: run, error: runError } = await supabase
.from('salary_runs')
.select('id, period_year, period_month')
.eq('id', salaryRunId)
.eq('company_id', companyId)
.maybeSingle()
if (runError) return { ok: false, message: runError.message }
if (!run) return { ok: false, message: 'salary run not found' }
let rows: RosterYtdRow[]
let prior: Map<string, YtdTotals>
try {
rows = (await fetchAllRows(({ from, to }) =>
supabase
.from('salary_run_employees')
.select(
'id, employee_id, gross_salary, tax_withheld, net_salary, ytd_gross, ytd_tax, ytd_net',
)
.eq('salary_run_id', salaryRunId)
.eq('company_id', companyId)
.order('id')
.range(from, to),
)) as unknown as RosterYtdRow[]
if (rows.length === 0) return { ok: true, updated: 0 }
prior = await computePriorYtd(supabase, {
companyId,
periodYear: run.period_year as number,
periodMonth: run.period_month as number,
employeeIds: rows.map((row) => row.employee_id),
})
} catch (err) {
return { ok: false, message: err instanceof Error ? err.message : 'unknown error' }
}
let updated = 0
for (const row of rows) {
const carried = prior.get(row.employee_id) || { gross: 0, tax: 0, net: 0 }
const next = {
ytd_gross: roundOre(carried.gross + row.gross_salary),
ytd_tax: roundOre(carried.tax + row.tax_withheld),
ytd_net: roundOre(carried.net + row.net_salary),
}
if (
next.ytd_gross === roundOre(row.ytd_gross) &&
next.ytd_tax === roundOre(row.ytd_tax) &&
next.ytd_net === roundOre(row.ytd_net)
) {
continue
}
// Object literal rather than the computed `next`: the phantom-column
// guard (tests/schema/no-phantom-columns.test.ts) can only check columns
// it can read statically.
const { error: updateError } = await supabase
.from('salary_run_employees')
.update({ ytd_gross: next.ytd_gross, ytd_tax: next.ytd_tax, ytd_net: next.ytd_net })
.eq('id', row.id)
.eq('company_id', companyId)
if (updateError) return { ok: false, message: updateError.message }
updated += 1
}
return { ok: true, updated }
}