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>
This commit is contained in:
co-authored by
Claude Opus 5
Jakob Wennberg
parent
638a25a11a
commit
0743717033
@@ -13,10 +13,14 @@ vi.mock('@/lib/salary/salary-entries', () => ({ createSalaryRunEntries: vi.fn()
|
||||
vi.mock('@/lib/salary/vacation-ledger', () => ({
|
||||
syncVacationLedgerForEmployees: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/salary/ytd', () => ({
|
||||
refreshRunYtd: vi.fn().mockResolvedValue({ ok: true, updated: 0 }),
|
||||
}))
|
||||
|
||||
import { advanceAndBookSalaryRun, bookPaidSalaryRun } from '../book-run'
|
||||
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
|
||||
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
|
||||
import { refreshRunYtd } from '@/lib/salary/ytd'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
const log = {
|
||||
@@ -194,6 +198,12 @@ describe('bookPaidSalaryRun', () => {
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.data.entryIds).toEqual(['je-1', 'je-2'])
|
||||
// Booking is the last chance to correct the payslip's Ackumulerat block
|
||||
// before the run becomes immutable.
|
||||
expect(refreshRunYtd).toHaveBeenCalledWith(expect.anything(), {
|
||||
companyId: 'company-1',
|
||||
salaryRunId: 'run-1',
|
||||
})
|
||||
expect(createSalaryRunEntries).toHaveBeenCalledTimes(1)
|
||||
expect(createSalaryRunEntries).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Tests for the payslip YTD ("Ackumulerat") snapshot: which prior runs count
|
||||
* toward it, how cutover opening balances interact with it, and the refresh
|
||||
* that keeps it from rotting when runs are calculated out of order.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import {
|
||||
computePriorYtd,
|
||||
loadOpeningBalances,
|
||||
refreshRunYtd,
|
||||
YTD_COUNTED_STATUSES,
|
||||
} from '../ytd'
|
||||
|
||||
const COMPANY = 'company-1'
|
||||
|
||||
const makePrior = (overrides: Record<string, unknown> = {}) => ({
|
||||
employee_id: 'e1',
|
||||
gross_salary: 25000,
|
||||
tax_withheld: 4346,
|
||||
net_salary: 20654,
|
||||
salary_run: { period_year: 2026, period_month: 6, status: 'booked' },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('YTD_COUNTED_STATUSES', () => {
|
||||
it('counts every authorized status but never draft, review or corrected', () => {
|
||||
// A month in `paid` has left the building; a month in `corrected` is
|
||||
// superseded by its correction run and would double the month.
|
||||
expect([...YTD_COUNTED_STATUSES]).toEqual(['approved', 'paid', 'booked'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadOpeningBalances', () => {
|
||||
let mock: ReturnType<typeof createQueuedMockSupabase>
|
||||
|
||||
beforeEach(() => {
|
||||
mock = createQueuedMockSupabase()
|
||||
})
|
||||
|
||||
it('short-circuits an empty roster without querying', async () => {
|
||||
expect(await loadOpeningBalances(mock.supabase as never, COMPANY, [])).toEqual([])
|
||||
expect(mock.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reads the karens carry-over alongside the YTD columns, ordered for paging', async () => {
|
||||
mock.enqueue({ data: [] })
|
||||
|
||||
await loadOpeningBalances(mock.supabase as never, COMPANY, ['e1'])
|
||||
|
||||
// karens_periods_adjustment feeds sjuklön, so it rides along with the YTD
|
||||
// columns rather than costing a second read of the same row.
|
||||
expect(mock.findCall('employee_opening_balances', 'select')).toEqual([
|
||||
'employee_id, cutover_date, ytd_gross, ytd_tax, ytd_net, karens_periods_adjustment',
|
||||
])
|
||||
expect(mock.findCall('employee_opening_balances', 'order')).toEqual(['id'])
|
||||
})
|
||||
|
||||
it('throws rather than reporting nobody has a cutover balance', async () => {
|
||||
mock.enqueue({ error: { message: 'opening down' } })
|
||||
|
||||
await expect(
|
||||
loadOpeningBalances(mock.supabase as never, COMPANY, ['e1']),
|
||||
).rejects.toThrow('opening down')
|
||||
})
|
||||
})
|
||||
|
||||
describe('computePriorYtd', () => {
|
||||
let mock: ReturnType<typeof createQueuedMockSupabase>
|
||||
|
||||
beforeEach(() => {
|
||||
mock = createQueuedMockSupabase()
|
||||
})
|
||||
|
||||
it('returns an empty map without querying when the roster is empty', async () => {
|
||||
const ytd = await computePriorYtd(mock.supabase as never, {
|
||||
companyId: COMPANY,
|
||||
periodYear: 2026,
|
||||
periodMonth: 8,
|
||||
employeeIds: [],
|
||||
})
|
||||
|
||||
expect(ytd.size).toBe(0)
|
||||
expect(mock.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('sums prior months and filters on every authorized status', async () => {
|
||||
mock.enqueue({ data: [] }) // employee_opening_balances
|
||||
mock.enqueue({
|
||||
data: [
|
||||
makePrior({ salary_run: { period_year: 2026, period_month: 6, status: 'booked' } }),
|
||||
makePrior({
|
||||
gross_salary: 35000,
|
||||
tax_withheld: 6709,
|
||||
net_salary: 28291,
|
||||
// The regression: an earlier month approved but not yet booked was
|
||||
// silently worth 0, so the next payslip understated Ackumulerat.
|
||||
salary_run: { period_year: 2026, period_month: 7, status: 'approved' },
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const ytd = await computePriorYtd(mock.supabase as never, {
|
||||
companyId: COMPANY,
|
||||
periodYear: 2026,
|
||||
periodMonth: 8,
|
||||
employeeIds: ['e1'],
|
||||
})
|
||||
|
||||
expect(ytd.get('e1')).toEqual({ gross: 60000, tax: 11055, net: 48945 })
|
||||
expect(mock.findCalls('salary_run_employees', 'in')).toContainEqual([
|
||||
'salary_run.status',
|
||||
YTD_COUNTED_STATUSES,
|
||||
])
|
||||
expect(mock.findCall('salary_run_employees', 'lt')).toEqual(['salary_run.period_month', 8])
|
||||
})
|
||||
|
||||
it('lets the opening balance own the pre-cutover months', async () => {
|
||||
mock.enqueue({
|
||||
data: [
|
||||
// Backdated into a month the opening balance already carries: skipped
|
||||
// so the pre-cutover pay is not counted twice.
|
||||
makePrior({ salary_run: { period_year: 2026, period_month: 2, status: 'booked' } }),
|
||||
makePrior({
|
||||
gross_salary: 30000,
|
||||
tax_withheld: 6000,
|
||||
net_salary: 24000,
|
||||
salary_run: { period_year: 2026, period_month: 5, status: 'booked' },
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const ytd = await computePriorYtd(mock.supabase as never, {
|
||||
companyId: COMPANY,
|
||||
periodYear: 2026,
|
||||
periodMonth: 8,
|
||||
employeeIds: ['e1'],
|
||||
openingRows: [
|
||||
{
|
||||
employee_id: 'e1',
|
||||
cutover_date: '2026-04-01',
|
||||
ytd_gross: 90000,
|
||||
ytd_tax: 18000,
|
||||
ytd_net: 72000,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(ytd.get('e1')).toEqual({ gross: 120000, tax: 24000, net: 96000 })
|
||||
})
|
||||
|
||||
it('ignores an opening balance from a different year', async () => {
|
||||
mock.enqueue({ data: [makePrior()] })
|
||||
|
||||
const ytd = await computePriorYtd(mock.supabase as never, {
|
||||
companyId: COMPANY,
|
||||
periodYear: 2026,
|
||||
periodMonth: 8,
|
||||
employeeIds: ['e1'],
|
||||
openingRows: [
|
||||
{
|
||||
employee_id: 'e1',
|
||||
cutover_date: '2025-04-01',
|
||||
ytd_gross: 90000,
|
||||
ytd_tax: 18000,
|
||||
ytd_net: 72000,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(ytd.get('e1')).toEqual({ gross: 25000, tax: 4346, net: 20654 })
|
||||
})
|
||||
|
||||
it('orders the paged prior-run read on the primary key', async () => {
|
||||
mock.enqueue({ data: [] })
|
||||
mock.enqueue({ data: [] })
|
||||
|
||||
await computePriorYtd(mock.supabase as never, {
|
||||
companyId: COMPANY,
|
||||
periodYear: 2026,
|
||||
periodMonth: 8,
|
||||
employeeIds: ['e1'],
|
||||
})
|
||||
|
||||
// Without a stable total order, a roster wide enough to page would skip
|
||||
// or double a month across the page boundary.
|
||||
expect(mock.findCall('salary_run_employees', 'order')).toEqual(['id'])
|
||||
})
|
||||
|
||||
it('throws rather than reporting an empty carry-in when the read fails', async () => {
|
||||
mock.enqueue({ data: [] })
|
||||
mock.enqueue({ error: { message: 'boom' } })
|
||||
|
||||
// Returning an empty map here would silently rewrite the snapshot to the
|
||||
// current month alone, which is the exact failure this module exists to
|
||||
// prevent.
|
||||
await expect(
|
||||
computePriorYtd(mock.supabase as never, {
|
||||
companyId: COMPANY,
|
||||
periodYear: 2026,
|
||||
periodMonth: 8,
|
||||
employeeIds: ['e1'],
|
||||
}),
|
||||
).rejects.toThrow('boom')
|
||||
})
|
||||
|
||||
it('skips the opening-balance query when the caller already loaded them', async () => {
|
||||
mock.enqueue({ data: [] }) // prior runs
|
||||
|
||||
await computePriorYtd(mock.supabase as never, {
|
||||
companyId: COMPANY,
|
||||
periodYear: 2026,
|
||||
periodMonth: 8,
|
||||
employeeIds: ['e1'],
|
||||
openingRows: [],
|
||||
})
|
||||
|
||||
expect(mock.calls.some((c) => c.table === 'employee_opening_balances')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshRunYtd', () => {
|
||||
let mock: ReturnType<typeof createQueuedMockSupabase>
|
||||
|
||||
beforeEach(() => {
|
||||
mock = createQueuedMockSupabase()
|
||||
})
|
||||
|
||||
const enqueueRun = () =>
|
||||
mock.enqueue({ data: { id: 'run-1', period_year: 2026, period_month: 8 } })
|
||||
|
||||
it('rewrites a snapshot that was frozen before an earlier month was booked', async () => {
|
||||
enqueueRun()
|
||||
mock.enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'sre-1',
|
||||
employee_id: 'e1',
|
||||
gross_salary: 35000,
|
||||
tax_withheld: 6709,
|
||||
net_salary: 28291,
|
||||
// Stale: captured when only June (25 000) had been booked.
|
||||
ytd_gross: 60000,
|
||||
ytd_tax: 11055,
|
||||
ytd_net: 48945,
|
||||
},
|
||||
],
|
||||
})
|
||||
mock.enqueue({ data: [] }) // opening balances
|
||||
mock.enqueue({
|
||||
data: [
|
||||
makePrior({ salary_run: { period_year: 2026, period_month: 6, status: 'booked' } }),
|
||||
makePrior({
|
||||
gross_salary: 35000,
|
||||
tax_withheld: 6709,
|
||||
net_salary: 28291,
|
||||
salary_run: { period_year: 2026, period_month: 7, status: 'booked' },
|
||||
}),
|
||||
],
|
||||
})
|
||||
mock.enqueue({ data: null }) // the update
|
||||
|
||||
const result = await refreshRunYtd(mock.supabase as never, {
|
||||
companyId: COMPANY,
|
||||
salaryRunId: 'run-1',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true, updated: 1 })
|
||||
expect(mock.findCall('salary_run_employees', 'update')).toEqual([
|
||||
{ ytd_gross: 95000, ytd_tax: 17764, ytd_net: 77236 },
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves an already-correct snapshot untouched', async () => {
|
||||
enqueueRun()
|
||||
mock.enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'sre-1',
|
||||
employee_id: 'e1',
|
||||
gross_salary: 35000,
|
||||
tax_withheld: 6709,
|
||||
net_salary: 28291,
|
||||
ytd_gross: 60000,
|
||||
ytd_tax: 11055,
|
||||
ytd_net: 48945,
|
||||
},
|
||||
],
|
||||
})
|
||||
mock.enqueue({ data: [] })
|
||||
mock.enqueue({ data: [makePrior()] })
|
||||
|
||||
const result = await refreshRunYtd(mock.supabase as never, {
|
||||
companyId: COMPANY,
|
||||
salaryRunId: 'run-1',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true, updated: 0 })
|
||||
expect(mock.findCall('salary_run_employees', 'update')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports a missing run instead of throwing', async () => {
|
||||
mock.enqueue({ data: null })
|
||||
|
||||
const result = await refreshRunYtd(mock.supabase as never, {
|
||||
companyId: COMPANY,
|
||||
salaryRunId: 'run-1',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, message: 'salary run not found' })
|
||||
})
|
||||
|
||||
it('reports a database error instead of throwing', async () => {
|
||||
mock.enqueue({ error: { message: 'boom' } })
|
||||
|
||||
const result = await refreshRunYtd(mock.supabase as never, {
|
||||
companyId: COMPANY,
|
||||
salaryRunId: 'run-1',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, message: 'boom' })
|
||||
})
|
||||
|
||||
it('reports a failed prior-run read instead of writing a truncated snapshot', async () => {
|
||||
enqueueRun()
|
||||
mock.enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'sre-1',
|
||||
employee_id: 'e1',
|
||||
gross_salary: 35000,
|
||||
tax_withheld: 6709,
|
||||
net_salary: 28291,
|
||||
ytd_gross: 60000,
|
||||
ytd_tax: 11055,
|
||||
ytd_net: 48945,
|
||||
},
|
||||
],
|
||||
})
|
||||
mock.enqueue({ data: [] }) // opening balances
|
||||
mock.enqueue({ error: { message: 'boom' } }) // prior runs
|
||||
|
||||
const result = await refreshRunYtd(mock.supabase as never, {
|
||||
companyId: COMPANY,
|
||||
salaryRunId: 'run-1',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: false, message: 'boom' })
|
||||
expect(mock.findCall('salary_run_employees', 'update')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is a no-op for a run with no roster', async () => {
|
||||
enqueueRun()
|
||||
mock.enqueue({ data: [] })
|
||||
|
||||
const result = await refreshRunYtd(mock.supabase as never, {
|
||||
companyId: COMPANY,
|
||||
salaryRunId: 'run-1',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ ok: true, updated: 0 })
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,7 @@ 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'
|
||||
|
||||
@@ -89,6 +90,17 @@ async function bookLoadedRun(
|
||||
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),
|
||||
|
||||
@@ -33,6 +33,7 @@ import { loadAndDeriveAbsence } from './derive-absence-line-items'
|
||||
import { getLineItemAccount } from './account-mapping'
|
||||
import { computePremiumLines } from './shift-premium-engine'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { computePriorYtd, loadOpeningBalances } from './ytd'
|
||||
import { dailyDivisor, hourlyDivisor } from './work-schedule'
|
||||
import type { WorkedDayShift } from './shift-premium-engine'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
@@ -251,101 +252,52 @@ export async function runSalaryCalculation(
|
||||
}
|
||||
}
|
||||
|
||||
// 6. YTD aggregation across prior BOOKED runs in the same period_year.
|
||||
// Drives the engine's progressive-tax + capped-avgift calculations.
|
||||
const { data: priorRuns } = await 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)
|
||||
.eq('salary_run.period_year', run.period_year)
|
||||
.eq('salary_run.status', 'booked')
|
||||
.lt('salary_run.period_month', run.period_month)
|
||||
|
||||
// 6b. Cutover opening balances (payroll gap-closure 2.2): a company that
|
||||
// switched to Accounted mid-year has YTD state from its previous
|
||||
// payroll system that no booked run here carries. Fetched BEFORE the
|
||||
// prior-run aggregation because the cutover month also decides which
|
||||
// booked runs count (see the exclusion in the loop below). YTD is
|
||||
// payslip display + reporting only: per-month tax lookup and the
|
||||
// per-month avgifter caps never read it.
|
||||
const rosterEmployeeIds = runEmployees
|
||||
.map((sre) => sre.employee?.id)
|
||||
.filter((id): id is string => !!id)
|
||||
// 6. Cutover opening balances (payroll gap-closure 2.2): a company that
|
||||
// switched to Accounted mid-year has YTD state from its previous
|
||||
// payroll system that no run in this system carries. Loaded here
|
||||
// because the karensavdrag adjustment further down reads the same rows.
|
||||
const rosterEmployeeIds = runEmployees.map((sre) => sre.employee_id as string)
|
||||
const openingByEmployee = new Map<
|
||||
string,
|
||||
{ cutoverDate: string; karensPeriodsAdjustment: number }
|
||||
>()
|
||||
const openingRowsTyped: Array<{
|
||||
employee_id: string
|
||||
cutover_date: string
|
||||
ytd_gross: number
|
||||
ytd_tax: number
|
||||
ytd_net: number
|
||||
karens_periods_adjustment: number
|
||||
}> = []
|
||||
if (rosterEmployeeIds.length > 0) {
|
||||
const { data: openingRows } = await 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', rosterEmployeeIds)
|
||||
|
||||
for (const opening of (openingRows || []) as typeof openingRowsTyped) {
|
||||
openingRowsTyped.push(opening)
|
||||
// 6b. YTD carried into this period (prior counted runs + any pre-cutover
|
||||
// balance). Stored on the roster rows below as the payslip's
|
||||
// "Ackumulerat" block, and refreshed again when the run is approved
|
||||
// and booked: calculating a run before an earlier month is authorized
|
||||
// would otherwise freeze a YTD that is missing that month forever.
|
||||
// YTD is display + reporting only: the per-month tax lookup and the
|
||||
// per-month avgifter caps never read it.
|
||||
//
|
||||
// A failed read throws rather than yielding an empty carry-in. Silently
|
||||
// dropping every prior month (and, from the same rows, the karensavdrag
|
||||
// adjustment that reaches sjuklön) is worse than failing the
|
||||
// calculation, and matches how this function treats every other query
|
||||
// error.
|
||||
let ytdByEmployee: Map<string, { gross: number; tax: number; net: number }>
|
||||
try {
|
||||
const openingRows = await loadOpeningBalances(supabase, companyId, rosterEmployeeIds)
|
||||
for (const opening of openingRows) {
|
||||
openingByEmployee.set(opening.employee_id, {
|
||||
cutoverDate: opening.cutover_date,
|
||||
karensPeriodsAdjustment: opening.karens_periods_adjustment ?? 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const ytdByEmployee = new Map<string, { gross: number; tax: number; net: number }>()
|
||||
// Cast via unknown: supabase-js infers the to-one `salary_run` embed as an
|
||||
// array, but PostgREST returns an object for a many-to-one relationship.
|
||||
for (const prior of (priorRuns || []) as unknown as Array<{
|
||||
employee_id: string
|
||||
gross_salary: number
|
||||
tax_withheld: number
|
||||
net_salary: number
|
||||
salary_run: { period_year: number; period_month: number }
|
||||
}>) {
|
||||
// The opening balance is authoritative for pre-cutover YTD: a booked run
|
||||
// backdated before the cutover month covers a month the opening already
|
||||
// carries, so counting both would double the YTD.
|
||||
const opening = openingByEmployee.get(prior.employee_id)
|
||||
if (opening) {
|
||||
const cutoverYear = Number(opening.cutoverDate.slice(0, 4))
|
||||
const cutoverMonth = Number(opening.cutoverDate.slice(5, 7))
|
||||
if (
|
||||
prior.salary_run.period_year === cutoverYear &&
|
||||
prior.salary_run.period_month < cutoverMonth
|
||||
) {
|
||||
continue
|
||||
}
|
||||
ytdByEmployee = await computePriorYtd(supabase, {
|
||||
companyId,
|
||||
periodYear: run.period_year as number,
|
||||
periodMonth: run.period_month as number,
|
||||
employeeIds: rosterEmployeeIds,
|
||||
openingRows,
|
||||
})
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'DATABASE_ERROR',
|
||||
details: { reason: err instanceof Error ? err.message : 'YTD aggregation failed' },
|
||||
}
|
||||
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 run's period is in the cutover year, on
|
||||
// or after the cutover month (the month gate prevents double-count if
|
||||
// someone backdates an in-system run before cutover).
|
||||
for (const opening of openingRowsTyped) {
|
||||
const cutoverYear = Number(opening.cutover_date.slice(0, 4))
|
||||
const cutoverMonth = Number(opening.cutover_date.slice(5, 7))
|
||||
const runOnOrAfterCutover =
|
||||
run.period_year === cutoverYear && run.period_month >= cutoverMonth
|
||||
if (!runOnOrAfterCutover) continue
|
||||
const current = ytdByEmployee.get(opening.employee_id) || { gross: 0, tax: 0, net: 0 }
|
||||
current.gross = roundOre(current.gross + (opening.ytd_gross || 0))
|
||||
current.tax = roundOre(current.tax + (opening.ytd_tax || 0))
|
||||
current.net = roundOre(current.net + (opening.ytd_net || 0))
|
||||
ytdByEmployee.set(opening.employee_id, current)
|
||||
}
|
||||
|
||||
// 7. Pay period bounds: used to load per-day absence + worked-day records.
|
||||
@@ -778,18 +730,9 @@ export async function runSalaryCalculation(
|
||||
parental_days: parentalDays,
|
||||
vacation_days_taken: vacationDays,
|
||||
calculation_breakdown: { steps: result.steps },
|
||||
ytd_gross:
|
||||
Math.round(
|
||||
((ytdByEmployee.get(sre.employee_id)?.gross || 0) + result.grossSalary) * 100,
|
||||
) / 100,
|
||||
ytd_tax:
|
||||
Math.round(
|
||||
((ytdByEmployee.get(sre.employee_id)?.tax || 0) + result.taxWithheld) * 100,
|
||||
) / 100,
|
||||
ytd_net:
|
||||
Math.round(
|
||||
((ytdByEmployee.get(sre.employee_id)?.net || 0) + result.netSalary) * 100,
|
||||
) / 100,
|
||||
ytd_gross: roundOre((ytdByEmployee.get(sre.employee_id)?.gross || 0) + result.grossSalary),
|
||||
ytd_tax: roundOre((ytdByEmployee.get(sre.employee_id)?.tax || 0) + result.taxWithheld),
|
||||
ytd_net: roundOre((ytdByEmployee.get(sre.employee_id)?.net || 0) + result.netSalary),
|
||||
})
|
||||
.eq('id', sre.id)
|
||||
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user