From 0743717033f9ea1f8df3f3b8ebf1da4a9fc5b9ab Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 25 Aug 2026 21:40:14 +0200 Subject: [PATCH] fix(salary): keep the payslip's Ackumulerat total from going stale (#1911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) --- DECISIONS.md | 1 + .../runs/[id]/approve/__tests__/route.test.ts | 13 + app/api/salary/runs/[id]/approve/route.ts | 12 +- .../runs/[id]/book/__tests__/route.test.ts | 6 + .../[id]/__tests__/lifecycle.test.ts | 11 + .../salary-runs/[id]/approve/route.ts | 16 + .../salary-runs/[id]/book/route.ts | 15 + lib/salary/__tests__/book-run.test.ts | 10 + lib/salary/__tests__/ytd.test.ts | 363 ++++++++++++++++++ lib/salary/book-run.ts | 12 + lib/salary/run-calculation.ts | 133 ++----- lib/salary/ytd.ts | 287 ++++++++++++++ 12 files changed, 783 insertions(+), 96 deletions(-) create mode 100644 lib/salary/__tests__/ytd.test.ts create mode 100644 lib/salary/ytd.ts diff --git a/DECISIONS.md b/DECISIONS.md index 4628aea8..4a326179 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1242,5 +1242,6 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/api/salary/runs/[id]/approve/__tests__/route.test.ts b/app/api/salary/runs/[id]/approve/__tests__/route.test.ts index 899f9239..dce75ec0 100644 --- a/app/api/salary/runs/[id]/approve/__tests__/route.test.ts +++ b/app/api/salary/runs/[id]/approve/__tests__/route.test.ts @@ -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 () => { diff --git a/app/api/salary/runs/[id]/approve/route.ts b/app/api/salary/runs/[id]/approve/route.ts index 8978269a..7cf50d8b 100644 --- a/app/api/salary/runs/[id]/approve/route.ts +++ b/app/api/salary/runs/[id]/approve/route.ts @@ -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 }, diff --git a/app/api/salary/runs/[id]/book/__tests__/route.test.ts b/app/api/salary/runs/[id]/book/__tests__/route.test.ts index 1c42799f..dd051d3c 100644 --- a/app/api/salary/runs/[id]/book/__tests__/route.test.ts +++ b/app/api/salary/runs/[id]/book/__tests__/route.test.ts @@ -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' diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/__tests__/lifecycle.test.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/__tests__/lifecycle.test.ts index 9ebd7583..2ba3ac5d 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/__tests__/lifecycle.test.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/__tests__/lifecycle.test.ts @@ -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('@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 () => { diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/approve/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/approve/route.ts index 5ff9dd0d..8141dbcf 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/approve/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/approve/route.ts @@ -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', diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts index 181f07de..90232b2b 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts @@ -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', diff --git a/lib/salary/__tests__/book-run.test.ts b/lib/salary/__tests__/book-run.test.ts index 973998b5..8fe7cf8e 100644 --- a/lib/salary/__tests__/book-run.test.ts +++ b/lib/salary/__tests__/book-run.test.ts @@ -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(), diff --git a/lib/salary/__tests__/ytd.test.ts b/lib/salary/__tests__/ytd.test.ts new file mode 100644 index 00000000..338b12e2 --- /dev/null +++ b/lib/salary/__tests__/ytd.test.ts @@ -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 = {}) => ({ + 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 + + 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 + + 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 + + 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 }) + }) +}) diff --git a/lib/salary/book-run.ts b/lib/salary/book-run.ts index 06246afe..be020821 100644 --- a/lib/salary/book-run.ts +++ b/lib/salary/book-run.ts @@ -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, roster: RosterRow[], ): Promise> { + // 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), diff --git a/lib/salary/run-calculation.ts b/lib/salary/run-calculation.ts index 7082a5e8..afad6c11 100644 --- a/lib/salary/run-calculation.ts +++ b/lib/salary/run-calculation.ts @@ -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 + 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() - // 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) diff --git a/lib/salary/ytd.ts b/lib/salary/ytd.ts new file mode 100644 index 00000000..8e9dbfb3 --- /dev/null +++ b/lib/salary/ytd.ts @@ -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 { + 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> { + const ytdByEmployee = new Map() + 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 { + 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 + 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 } +}