Files
accounted/app/api/salary/runs/[id]/__tests__/route.test.ts
T
MattssonandClaude Fable 5 f43a6653f1 feat(salary): update_salary_run MCP tool and editable draft payment date (#2041)
* feat(salary): update_salary_run MCP tool and editable draft payment date

payment_date drives the booking entry date but was only editable via the
v1 PATCH. Close the gap on both remaining surfaces:

- New staged MCP write tool gnubok_update_salary_run (search-only
  catalog; tools/list budget is at zero headroom) accepting the exact
  v1 PATCH field set: payment_date, voucher_series, notes. Draft-only
  with the same optimistic lock semantics, via a new shared service
  lib/salary/update-run.ts used by both the staging preflight and the
  commit executor.
- Run header UI: payment date on a draft run is now an inline date
  input (prefilled, committed on blur/Enter, snaps back on failure),
  saved through the existing internal PATCH. Read-only once not draft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(salary): op-type migration, calc invalidation on date change, scanner compliance

Consolidated CI + review fix pass for #2041:

- pg-real: add 'update_salary_run' to pending_operations_operation_type_check
  (wholesale re-create, NOT VALID + VALIDATE pair, mirroring 20260828160000/1).
- Swedish accounting review: a payment_date change on a draft run now clears
  every roster row's calculation_breakdown (shared service and internal PATCH
  alike), so both book preflights refuse the run until a recalculation has run
  against the new date; skatteavdrag and the AGI redovisningsperiod follow the
  payment month. Staging preview exposes invalidates_calculation and the next
  hint states the clearing.
- no-phantom-columns: literal select strings in update-run.ts; ceiling +1 with
  a documented reason for the inherent patch-shaped UPDATE payload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(salary): close skeptic findings on payment_date editing

Skeptic round 1 refuted two paths; both closed:

- Retry idempotency (correctness): the calculation_breakdown clear was
  gated on new-date-differs-from-stored, so a retry after a partial
  failure (header committed, clear failed) compared against the already
  updated date and skipped the clear forever, leaving a stale
  calculation bookable. The clear is now gated on payment_date being
  SUPPLIED, on all three surfaces (shared service, internal PATCH, v1
  PATCH: the v1 route previously had no clear at all and bypassed the
  invariant).
- Kontantprincipen (compliance): AGI derives its redovisningsperiod
  from period_year/period_month while the verifikat books on
  payment_date, so a cross-month payment_date change could book salary
  in one month and declare it in another. All three edit surfaces now
  refuse a payment_date outside the run's period month with the new
  structured error SALARY_RUN_PAYMENT_DATE_OUTSIDE_PERIOD; the UI date
  input is min/max-bounded to the period month.
- The internal PATCH update is now optimistic-locked on status='draft'
  (races return 400 instead of silently writing), matching the v1 PATCH
  and the shared service, and the clear cannot fire for a run that left
  draft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(salary): carry book_skattekonto op types through the constraint re-create

The sibling migration 20260830130000 (merged from main) re-created
pending_operations_operation_type_check with book_skattekonto_row and
book_skattekonto_rows. This branch's 20260830150000 sorts after it and
re-creates the constraint wholesale, so its list must be that migration's
superset or the two values would be silently revoked at apply time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(salary): value-validate internal PATCH and grandfather out-of-period dates

Two skeptic follow-ups:

- The internal PATCH now validates values, not just keys: JSON body must
  be an object, payment_date must be ISO (shared ISO_DATE_RE),
  voucher_series a single A-Z letter, notes a string of max 2000 chars
  or null: the same rules as the v1 UpdateSalaryRunSchema, so nothing
  unvalidated can reach the DB through the whitelist.
- Creation does not (yet) couple payment_date to the period month, so a
  legally created out-of-period date must stay correctable. All three
  edit surfaces now allow day adjustments within the run's CURRENT
  payment month as well as the period month (grandfather clause); no
  move can introduce a new wrong month.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(salary): resolve migration version collision with delete_draft_invoice

Main's delete_draft_invoice PR landed on the same 20260830150000/150001
versions and also re-creates pending_operations_operation_type_check.
Rename this branch's pair to 20260830160000/160001 (applies last) and
carry delete_draft_invoice through the wholesale re-create so nothing is
silently revoked. Final list = sibling's list + update_salary_run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* docs(salary): regenerate accounted-api skill for the new PATCH pitfalls

apiskill:check byte-compares the generated skill against the registry;
the two pitfalls added to the v1 salary-runs PATCH endpoint made
references/salary-runs.md stale and failed Core Build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 21:43:23 +02:00

490 lines
17 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
parseJsonResponse,
createMockRouteParams,
} from '@/tests/helpers'
// ── Mocks ────────────────────────────────────────────────────
// The route is wrapped in withRouteContext, which resolves auth via
// requireAuth() (the only path that enforces MFA/AAL2 on hosted) and the active
// company via getActiveCompanyId(). Mock those, not createClient/getUser.
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { DELETE, GET, PATCH } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { encryptPersonnummer } from '@/lib/salary/personnummer'
// ── Test data ────────────────────────────────────────────────
const mockUser = { id: 'user-1', email: 'test@test.se' }
// ── Tests ────────────────────────────────────────────────────
describe('DELETE /api/salary/runs/[id]', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 401 when not authenticated', async () => {
vi.mocked(requireAuth).mockResolvedValue({
user: null as never,
supabase: {} as never,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const request = createMockRequest('/api/salary/runs/run-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 404 when salary run not found', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: supabase as never,
error: null,
})
enqueueMany([
{ data: null, error: { message: 'Not found' } }, // salary_runs lookup
])
const request = createMockRequest('/api/salary/runs/run-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(404)
expect(body.error).toContain('hittades inte')
})
it('returns 400 when the run is not a draft (booked must be storno-reversed)', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: supabase as never,
error: null,
})
enqueueMany([
{ data: { id: 'run-1', status: 'booked' } }, // salary_runs lookup
])
const request = createMockRequest('/api/salary/runs/run-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('utkast')
})
it('deletes a draft run and returns success', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: supabase as never,
error: null,
})
enqueueMany([
{ data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup
{ data: null }, // salary_runs delete (cascade handles children)
])
const request = createMockRequest('/api/salary/runs/run-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ data: { id: string; deleted: boolean } }>(response)
expect(status).toBe(200)
expect(body.data).toEqual({ id: 'run-1', deleted: true })
})
})
// ── PATCH (draft-only header edit) ───────────────────────────────────────────
describe('PATCH /api/salary/runs/[id]', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function authorize(supabase: unknown) {
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: supabase as never,
error: null,
})
}
const DRAFT_RUN = {
id: 'run-1',
status: 'draft',
payment_date: '2026-07-25',
period_year: 2026,
period_month: 7,
}
it('returns 400 when the run is not a draft', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
authorize(supabase)
enqueueMany([
{ data: { ...DRAFT_RUN, status: 'review' } }, // lookup
])
const request = createMockRequest('/api/salary/runs/run-1', {
method: 'PATCH',
body: { payment_date: '2026-07-23' },
})
const response = await PATCH(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('utkast')
})
it('updates payment_date on a draft and clears roster calculation_breakdown', async () => {
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
authorize(supabase)
enqueueMany([
{ data: DRAFT_RUN }, // lookup
{ data: { ...DRAFT_RUN, payment_date: '2026-07-23' } }, // update
{ data: null }, // roster calculation_breakdown clear
])
const request = createMockRequest('/api/salary/runs/run-1', {
method: 'PATCH',
body: { payment_date: '2026-07-23' },
})
const response = await PATCH(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ data: { payment_date: string } }>(response)
expect(status).toBe(200)
expect(body.data.payment_date).toBe('2026-07-23')
// The new date invalidates any existing calculation: skatteavdrag and the
// AGI redovisningsperiod follow the payment month, and the book preflights
// refuse roster rows without a breakdown until a recalculation runs.
expect(findCall('salary_run_employees', 'update')).toEqual([
{ calculation_breakdown: null },
])
})
it('does not clear calculations when only notes change', async () => {
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
authorize(supabase)
enqueueMany([
{ data: DRAFT_RUN }, // lookup
{ data: { ...DRAFT_RUN, notes: 'Julbonus' } }, // update
])
const request = createMockRequest('/api/salary/runs/run-1', {
method: 'PATCH',
body: { notes: 'Julbonus' },
})
const response = await PATCH(request, createMockRouteParams({ id: 'run-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(findCall('salary_run_employees', 'update')).toBeUndefined()
})
it('rejects a payment_date outside the run period month (kontantprincipen)', async () => {
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
authorize(supabase)
enqueueMany([
{ data: DRAFT_RUN }, // lookup: period 2026-07
])
const request = createMockRequest('/api/salary/runs/run-1', {
method: 'PATCH',
body: { payment_date: '2026-08-01' },
})
const response = await PATCH(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('period')
// Refused before any write.
expect(findCall('salary_runs', 'update')).toBeUndefined()
})
it('grandfathers day adjustments when the current date is already outside the period', async () => {
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
authorize(supabase)
enqueueMany([
{ data: { ...DRAFT_RUN, payment_date: '2026-08-05' } }, // period 2026-07, paid in August
{ data: { ...DRAFT_RUN, payment_date: '2026-08-07' } }, // update
{ data: null }, // roster calculation_breakdown clear
])
const request = createMockRequest('/api/salary/runs/run-1', {
method: 'PATCH',
body: { payment_date: '2026-08-07' },
})
const response = await PATCH(request, createMockRouteParams({ id: 'run-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(findCall('salary_run_employees', 'update')).toEqual([{ calculation_breakdown: null }])
})
it('rejects an invalid voucher_series value', async () => {
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
authorize(supabase)
enqueueMany([
{ data: DRAFT_RUN }, // lookup
])
const request = createMockRequest('/api/salary/runs/run-1', {
method: 'PATCH',
body: { voucher_series: 'AB' },
})
const response = await PATCH(request, createMockRouteParams({ id: 'run-1' }))
const { status } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(findCall('salary_runs', 'update')).toBeUndefined()
})
it('returns 400 when the run advances past draft between fetch and update (race)', async () => {
const { supabase, enqueueMany, findCall } = createQueuedMockSupabase()
authorize(supabase)
enqueueMany([
{ data: DRAFT_RUN }, // lookup still sees draft
{ data: null }, // status-locked update matches no row
])
const request = createMockRequest('/api/salary/runs/run-1', {
method: 'PATCH',
body: { payment_date: '2026-07-23' },
})
const response = await PATCH(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('utkast')
// The clear must not run when the locked update matched nothing.
expect(findCall('salary_run_employees', 'update')).toBeUndefined()
})
})
// ── GET detail additions (previous_run, corrected_by_run_id, deliveries) ────
//
// GET consumes queue entries in from() order. The run loads first; the rest
// fire together in a Promise.all, so their from() calls resolve in this order:
// 1 run, 2 employees, 3 settings (arbetsgivare), 4 previous-run lookup,
// [corrected_by lookup when status is 'corrected'], deliveries,
// [previous-run employees when found — nested after the lookup's await, so
// it lands last].
const GET_RUN = {
id: 'run-2',
company_id: 'company-1',
status: 'draft',
period_year: 2026,
period_month: 7,
payment_date: '2026-07-25',
}
describe('GET /api/salary/runs/[id] — additive detail fields', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function authed(supabase: unknown) {
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: supabase as never,
error: null,
})
}
it('returns previous_run with effective (override-coalesced) values', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
authed(supabase)
enqueueMany([
{ data: GET_RUN },
{ data: [] }, // employees in this run
{ data: { org_number: null, entity_type: null } },
{ data: { id: 'run-1', period_year: 2026, period_month: 6 } }, // previous-run lookup
{ data: [] }, // deliveries
{
// previous-run employees — nested after the lookup's await, resolves last
data: [
{
employee_id: 'emp-1',
gross_salary: 35000,
tax_withheld: 8000,
tax_withheld_override: 7000,
net_salary: 27000,
},
],
},
])
const response = await GET(
createMockRequest('/api/salary/runs/run-2'),
createMockRouteParams({ id: 'run-2' }),
)
const { status, body } = await parseJsonResponse<{
data: {
previous_run: {
id: string
by_employee: Record<string, { gross: number; tax: number; net: number }>
} | null
corrected_by_run_id: string | null
payslip_deliveries_summary: { sent: number; failed: number; skipped: number }
}
}>(response)
expect(status).toBe(200)
expect(body.data.previous_run?.id).toBe('run-1')
// Override coalesced: tax 7000, net compensated +1000
expect(body.data.previous_run?.by_employee['emp-1']).toEqual({
gross: 35000,
tax: 7000,
net: 28000,
})
expect(body.data.corrected_by_run_id).toBeNull()
expect(body.data.payslip_deliveries_summary).toMatchObject({ sent: 0, failed: 0, skipped: 0 })
})
it('returns previous_run null on the first-ever run', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
authed(supabase)
enqueueMany([
{ data: GET_RUN },
{ data: [] },
{ data: null }, // settings
{ data: null }, // no previous booked run
{ data: [] }, // deliveries
])
const response = await GET(
createMockRequest('/api/salary/runs/run-2'),
createMockRouteParams({ id: 'run-2' }),
)
const { status, body } = await parseJsonResponse<{ data: { previous_run: unknown } }>(response)
expect(status).toBe(200)
expect(body.data.previous_run).toBeNull()
})
it('masks embedded employees: no ciphertext, no personnummer_last4', async () => {
// The mask is YYYYMMDD-XXXX; a payload carrying the mask AND the last four
// digits would reassemble the full personnummer by concatenation.
const STORED_PNR = '190203040000' // synthetic
const { supabase, enqueueMany } = createQueuedMockSupabase()
authed(supabase)
enqueueMany([
{ data: GET_RUN },
{
data: [
{
id: 'sre-1',
gross_salary: 30000,
employee: {
id: 'emp-1',
first_name: 'Test',
last_name: 'Testsson',
personnummer: encryptPersonnummer(STORED_PNR),
personnummer_last4: '0000',
employment_type: 'employee',
default_dimensions: {},
},
line_items: [],
},
],
},
{ data: null }, // settings
{ data: null }, // no previous booked run
{ data: [] }, // deliveries
])
const response = await GET(
createMockRequest('/api/salary/runs/run-2'),
createMockRouteParams({ id: 'run-2' }),
)
const { status, body } = await parseJsonResponse<{
data: { employees: Array<{ employee: Record<string, unknown> }> }
}>(response)
expect(status).toBe(200)
const employee = body.data.employees[0].employee
expect(employee.personnummer_masked).toBe('19020304-XXXX')
expect(employee).not.toHaveProperty('personnummer')
expect(employee).not.toHaveProperty('personnummer_last4')
// Neither the plaintext nor the suffix may appear anywhere in the payload.
expect(JSON.stringify(body)).not.toContain(STORED_PNR)
})
it('exposes corrected_by_run_id on corrected originals and counts latest deliveries', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
authed(supabase)
enqueueMany([
{ data: { ...GET_RUN, status: 'corrected' } },
{ data: [] },
{ data: null }, // settings
{ data: null }, // previous booked run
{ data: { id: 'run-correction' } }, // corrected_by lookup
{
data: [
// newest first (route orders sent_at desc): emp-1 latest = sent
{ employee_id: 'emp-1', status: 'sent', sent_at: '2026-07-01T10:00:00Z' },
{ employee_id: 'emp-1', status: 'failed', sent_at: '2026-06-30T10:00:00Z' },
{ employee_id: 'emp-2', status: 'skipped', sent_at: '2026-07-01T10:00:00Z' },
],
},
])
const response = await GET(
createMockRequest('/api/salary/runs/run-2'),
createMockRouteParams({ id: 'run-2' }),
)
const { status, body } = await parseJsonResponse<{
data: {
corrected_by_run_id: string | null
payslip_deliveries_summary: { sent: number; failed: number; skipped: number }
}
}>(response)
expect(status).toBe(200)
expect(body.data.corrected_by_run_id).toBe('run-correction')
// Latest attempt per employee: emp-1 sent (failure superseded), emp-2 skipped
expect(body.data.payslip_deliveries_summary).toMatchObject({
sent: 1,
failed: 0,
skipped: 1,
})
})
})