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 } +}