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:
Jakob Wennberg
2026-08-25 21:40:14 +02:00
committed by GitHub
co-authored by Claude Opus 5 Jakob Wennberg
parent 638a25a11a
commit 0743717033
12 changed files with 783 additions and 96 deletions
+1
View File
@@ -1242,5 +1242,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-25] Ranged income statement sums period movements, not closing balances (skeptic refutation on PR #1909): with from_date > period_start the trial balance rolls pre-range P&L activity into opening columns, so closing-column sums are year-to-date mislabeled as the range (July revenue reported as Jan-Jul). generateIncomeStatement now passes periodMovements to buildIncomeStatementFromRows whenever fromDate is set, matching the resultatrapport convention; full-period behavior is byte-identical. from_date was also dropped from the v1 balance-sheet routes (a balansräkning is a cumulative position; ÅRL 3 kap): as_of/to_date only, matching the MCP tool.
[2026-08-25] Issue #1870: skattekonto AGI seed reverted 2730 -> 2731 (salary side kept on 2731), not the alternative of moving SALARY_ACCOUNTS.AVGIFTER_LIABILITY to 2730: BAS 2026 defines 2731 as exactly the reported-but-unpaid arbetsgivaravgift liability (the accrual account is 2940), and the salary module's whole-krona/ore-residual logic (PR #1609, 2026-08-14 decisions) is built around 2731. The 20260519160000 migration's rationale mislabeled 2731 as the accrual account; a one-sided flip either way reintroduces the split. Historical 2730 debits since 2026-05-19 are left for per-company reclass verifikat, not repaired in-migration.
[2026-08-25] The marketplace entry for the Accounted plugin is a git-subdir source (public repo URL + path claude-plugin), not the relative path ./claude-plugin: relative sources only resolve when the whole marketplace repo is cloned (Claude Code), while Claude.ai's Add-marketplace backend fetches the manifest and resolves each plugin source as a repository, which surfaced as 'Repository not accessible' on a public repo. git-subdir is also the form the plugin-directory catalog uses for monorepos.
[2026-08-25] Payslip YTD ("Ackumulerat") stays a stored snapshot on salary_run_employees, refreshed at approve + book, rather than being recomputed at PDF-render time: an employee who re-downloads a lonebesked must see the figures it had when it was issued, and a render-time sum would silently restate delivered payslips after any backdated correction. The same change widens the counted prior-run statuses from booked-only to approved/paid/booked (corrected stays excluded: its correction run replaces the whole month), because the original snapshot-at-calculate-time rule froze a YTD that was missing every month not yet booked when next month's run was prepared.
[2026-08-25] ROT/RUT BegartBelopp truncates to whole kronor (truncateToWholeKronor), not half-up: the deduction is capped at 50%/30% of arbetskostnaden. At the RUT cap, half-up manufactured begart > betalt and blocked correct invoices; for ROT below the cap it over-requested past the cap and the 1513 fordran, so those files now ask 1 kr less (skeptic-verified on PR #1910).
[2026-08-25] Woo bulk revenue template = per-rate account choice, no hardcoded varor/tjanster preset: BAS 2026 has no standard 30xx goods/services subdivision (3040-series is company-specific), so presets would invent accounts; chosen accounts are validated against the company chart instead, and only diffs from the 3001-series default are sent.
@@ -17,9 +17,16 @@ vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/events', () => ({ eventBus: { emit: vi.fn().mockResolvedValue(undefined) } }))
// Approval refreshes the payslip YTD snapshot (display-only side effect,
// covered by lib/salary/__tests__/ytd.test.ts): stub it so its reads do not
// have to be queued into every approval fixture.
vi.mock('@/lib/salary/ytd', () => ({
refreshRunYtd: vi.fn().mockResolvedValue({ ok: true, updated: 0 }),
}))
import { POST } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { refreshRunYtd } from '@/lib/salary/ytd'
const mockUser = { id: 'user-1', email: 'test@test.se' }
@@ -82,6 +89,12 @@ describe('POST /api/salary/runs/[id]/approve: bank-detail guard', () => {
expect(status).toBe(200)
expect(body.data.status).toBe('approved')
// Approval is the first status lönebesked can be sent from, so the
// Ackumulerat snapshot is brought up to date here.
expect(refreshRunYtd).toHaveBeenCalledWith(expect.anything(), {
companyId: 'company-1',
salaryRunId: 'run-1',
})
})
it('still blocks when an employee who is actually paid has no bank details', async () => {
+11 -1
View File
@@ -3,6 +3,7 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { eventBus } from '@/lib/events'
import { effectiveNetPayout } from '@/lib/salary/payment/effective-net'
import { refreshRunYtd } from '@/lib/salary/ytd'
ensureInitialized()
@@ -12,7 +13,7 @@ ensureInitialized()
* details before generating the payment file (which hard-blocks on its own). */
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.run.approve',
async (request, { supabase, companyId, user }, { params }) => {
async (request, { supabase, companyId, user, log }, { params }) => {
const { id } = await params
const force = new URL(request.url).searchParams.get('force') === 'true'
@@ -117,6 +118,15 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
return NextResponse.json({ error: 'Kunde inte godkänna lönekörningen' }, { status: 500 })
}
// Approval is the first status from which lönebesked can be sent, so it
// is where the payslip's "Ackumulerat" snapshot must be brought up to
// date: a run calculated before an earlier month was authorized still
// carries a YTD missing that month. Non-fatal, YTD is display only.
const ytdRefresh = await refreshRunYtd(supabase, { companyId: companyId!, salaryRunId: id })
if (!ytdRefresh.ok) {
log.warn('YTD refresh failed after approval', { salaryRunId: id, message: ytdRefresh.message })
}
await eventBus.emit({
type: 'salary_run.approved',
payload: { salaryRunId: id, approvedBy: user.id, userId: user.id, companyId },
@@ -24,6 +24,12 @@ vi.mock('@/lib/events', () => ({
eventBus: { emit: vi.fn().mockResolvedValue(undefined) },
}))
vi.mock('@/lib/salary/salary-entries', () => ({ createSalaryRunEntries: vi.fn() }))
// The booking core refreshes the payslip YTD snapshot first; it is a
// display-only side effect with its own tests (lib/salary/__tests__/ytd.test.ts),
// so stub it out rather than queue its reads into every booking fixture.
vi.mock('@/lib/salary/ytd', () => ({
refreshRunYtd: vi.fn().mockResolvedValue({ ok: true, updated: 0 }),
}))
import { POST } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
@@ -30,6 +30,10 @@ vi.mock('@/lib/auth/api-keys', async () => {
}
})
vi.mock('@/lib/salary/ytd', () => ({
refreshRunYtd: vi.fn().mockResolvedValue({ ok: true, updated: 0 }),
}))
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
@@ -63,6 +67,7 @@ vi.mock('@/lib/salary/agi/generate-declaration', () => ({
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { POST as calculate } from '../calculate/route'
import { POST as approve } from '../approve/route'
import { refreshRunYtd } from '@/lib/salary/ytd'
import { POST as markPaid } from '../mark-paid/route'
import { POST as book } from '../book/route'
import { POST as generateAgi } from '../generate-agi/route'
@@ -296,6 +301,12 @@ describe('POST /salary-runs/:id/approve', () => {
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.status).toBe('approved')
// Parity with the dashboard approve route: the payslip's Ackumulerat
// snapshot is refreshed at the first status lönebesked can be sent from.
expect(refreshRunYtd).toHaveBeenCalledWith(expect.anything(), {
companyId: COMPANY_ID,
salaryRunId: RUN_ID,
})
})
it('returns SALARY_RUN_APPROVE_VALIDATION_FAILED for missing bank details', async () => {
@@ -22,6 +22,7 @@ import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { eventBus } from '@/lib/events'
import { refreshRunYtd } from '@/lib/salary/ytd'
const SalaryRunApproved = z.object({
id: z.string().uuid(),
@@ -198,6 +199,21 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// Approval is the first status from which lönebesked can be sent, so the
// payslip's "Ackumulerat" snapshot is refreshed here: a run calculated
// before an earlier month was authorized still carries a YTD missing
// that month. Non-fatal, YTD is display only.
const ytdRefresh = await refreshRunYtd(ctx.supabase, {
companyId: ctx.companyId!,
salaryRunId,
})
if (!ytdRefresh.ok) {
ctx.log.warn('YTD refresh failed after approval', {
salaryRunId,
message: ytdRefresh.message,
})
}
try {
await eventBus.emit({
type: 'salary_run.approved',
@@ -38,6 +38,7 @@ import { isFSkattStatus } from '@/lib/salary/declared-avgifter'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { eventBus } from '@/lib/events'
import { refreshRunYtd } from '@/lib/salary/ytd'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const SalaryRunBooked = z.object({
@@ -370,6 +371,20 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// Final refresh of the payslip's "Ackumulerat" snapshot, mirroring
// lib/salary/book-run.ts. Non-fatal: YTD is display only and never
// reaches a verifikation.
const ytdRefresh = await refreshRunYtd(ctx.supabase, {
companyId: ctx.companyId!,
salaryRunId,
})
if (!ytdRefresh.ok) {
ctx.log.warn('YTD refresh failed after booking', {
salaryRunId,
message: ytdRefresh.message,
})
}
try {
await eventBus.emit({
type: 'salary_run.booked',
+10
View File
@@ -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(),
+363
View File
@@ -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 })
})
})
+12
View File
@@ -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),
+38 -95
View File
@@ -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)
+287
View File
@@ -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 }
}