fix(salary): keep the payslip's Ackumulerat total from going stale (#1911)
* fix(salary): keep the payslip's Ackumulerat total from going stale
`salary_run_employees.ytd_*` (the "Ackumulerat {år}" block on the
lönespecifikation) was written once at calculation time and never
recomputed, from a query that only counted prior runs already in
`booked`. Preparing next month's run before the current one is booked
(entirely normal) therefore froze a YTD that is permanently missing the
month in between, and the employee's payslip understates the year.
Seen in production: an August run calculated on 2026-07-23, three days
before the July run was booked, shipped a payslip whose Ackumulerat brutto
was 60 000 kr instead of 95 000 kr.
Two fixes, both in the new lib/salary/ytd.ts:
- `computePriorYtd` counts `approved`, `paid` and `booked` prior runs, not
only `booked`. `corrected` stays excluded: its correction run replaces
the whole month, so counting both would double it.
- `refreshRunYtd` recomputes and rewrites the snapshot, and is now called
at approval (the first status lönebesked can be sent from) and at
booking, on both the dashboard and v1 surfaces. Rows already correct are
left untouched; a failure is logged and never blocks an approval or a
booking.
The snapshot stays a snapshot rather than becoming a render-time sum: an
employee re-opening a lönebesked must see the figures it had when it was
issued. YTD is display and reporting only, so nothing here can move a
verifikation: the per-month tax lookup and the avgifter caps never read it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(salary): fail loudly on a YTD read error and paginate the reads
Review follow-up on both counts:
- The opening-balance and prior-run reads discarded their `error`. A failed
read looked exactly like a month with no prior pay, so `refreshRunYtd`
would rewrite the snapshot to the current month alone and still report
success. Both now throw; `refreshRunYtd` turns that into `ok: false` for
its callers to log, and `runSalaryCalculation` returns DATABASE_ERROR the
way it already does for every other query error in that function.
- The prior-run and roster reads now page through `fetchAllRows()` ordered
on the primary key. A full roster times eleven prior months passes
PostgREST's 1000-row cap well before an employer is large by Swedish
standards, and a silent truncation there understates somebody's
Ackumulerat.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(salary): one paginated loader for cutover opening balances
Review follow-up. `run-calculation` and `ytd` each read
employee_opening_balances with their own unpaginated, error-discarding
query. Both now go through `loadOpeningBalances()`: paged via
fetchAllRows() ordered on the primary key, and throwing on a read error.
The error path matters more than the paging one here. That row carries
`karens_periods_adjustment` as well as the YTD carry-in, and a discarded
error looked exactly like "nobody has a cutover balance" - which would
drop a karensavdrag from sjuklön silently, not just understate a display
figure. runSalaryCalculation now maps it to DATABASE_ERROR.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
Jakob Wennberg
parent
638a25a11a
commit
0743717033
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user