feat(salary): allow recalling approval on a salary run (approved → review) (#894)

* feat(salary): allow recalling approval on a salary run (approved → review)

An approved run was a dead end: the only forward path was paid → booked,
so a wrong salary snapshot (e.g. stale employee monthly pay) could not be
fixed without paying and then storno-correcting. Approval is an internal
control point — nothing legally binding happens until payment, booking,
or AGI filing — so recalling it is allowed until the AGI reaches
Skatteverket.

- POST /api/salary/runs/[id]/unapprove: approved → review; clears
  approved_by/at and payment-file tracking; deletes generated-but-unfiled
  AGI declarations (stale XML must not stay exportable); 409 once the
  AGI is pending_signature/submitted/accepted — correction AGI (same
  specifikationsnummer) is the lawful path then.
- New salary_run.approval_reverted event for the audit trail.
- "Ångra godkännande" secondary action on the run page with a
  consequence-aware confirm (payment file possibly at the bank, sent
  payslips, generated AGI), sv + en.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): delete stale AGI after the unapprove transition, not before

Bot-review triage on #894: the declaration delete ran before the
optimistic status update, so a failed transition (concurrent flip,
transient error) would have destroyed the generated AGI while the run
stayed approved. Flip the run first; a delete failure afterwards is
harmless (agi_generated_at is already null, regeneration upserts over
the orphan). Also record the deleted declaration id in the
approval_reverted event payload, and warn in the confirm dialog that a
manually filed AGI requires a correction declaration instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): close the unapprove TOCTOU on concurrent AGI filing

Superagent P2 + compliance-bot round 2 on #894: AGI submission is
allowed from approved (also out-of-band via MCP/public API), so a
filing could land between the route's read and its update, and the
route would flip the run and delete a submitted declaration.

- Re-assert agi_submitted_at IS NULL inside the optimistic update
  filter, not just on the stale read.
- Guard the declaration delete with the same status filter so it
  no-ops if the declaration advanced since the read; log a miss.
- Zero-row update (PGRST116) now returns 409 "status har ändrats"
  instead of a generic 500.
- The approval_reverted event only reports deletedAgiDeclarationId
  when a row was actually deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-05 19:44:22 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 2c2743eb79
commit c43c4a076c
8 changed files with 381 additions and 0 deletions
@@ -0,0 +1,199 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
parseJsonResponse,
createMockRouteParams,
} from '@/tests/helpers'
// The route is wrapped in withRouteContext (auth via requireAuth, company via
// getActiveCompanyId, write gate via requireWritePermission) — mock those.
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 }),
}))
vi.mock('@/lib/events', () => ({ eventBus: { emit: vi.fn().mockResolvedValue(undefined) } }))
import { POST } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { eventBus } from '@/lib/events'
const mockUser = { id: 'user-1', email: 'test@test.se' }
function authed(supabase: unknown) {
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: supabase as never,
error: null,
})
}
const approvedRun = {
id: 'run-1',
company_id: 'company-1',
status: 'approved',
agi_submitted_at: null,
}
describe('POST /api/salary/runs/[id]/unapprove', () => {
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/unapprove', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(401)
})
it('returns 404 when the salary run is not found', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
authed(supabase)
enqueueMany([
{ data: null, error: { message: 'Not found' } }, // salary_runs lookup
])
const request = createMockRequest('/api/salary/runs/run-1/unapprove', { method: 'POST' })
const response = await POST(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 approved (e.g. already paid)', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
authed(supabase)
enqueueMany([
{ data: { ...approvedRun, status: 'paid' } }, // salary_runs lookup
])
const request = createMockRequest('/api/salary/runs/run-1/unapprove', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('godkänd')
})
it('returns 409 when the AGI declaration has been submitted to Skatteverket', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
authed(supabase)
enqueueMany([
{ data: approvedRun }, // salary_runs lookup
{ data: { id: 'agi-1', status: 'submitted' } }, // agi_declarations lookup
])
const request = createMockRequest('/api/salary/runs/run-1/unapprove', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(409)
expect(body.error).toContain('Skatteverket')
expect(eventBus.emit).not.toHaveBeenCalled()
})
it('returns 409 when the run itself is stamped agi_submitted_at', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
authed(supabase)
enqueueMany([
{ data: { ...approvedRun, agi_submitted_at: '2026-07-01T10:00:00Z' } }, // run lookup
{ data: null, error: { message: 'No rows' } }, // agi lookup
])
const request = createMockRequest('/api/salary/runs/run-1/unapprove', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(409)
})
it('returns 409 when the run transitions concurrently between read and update', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
authed(supabase)
enqueueMany([
{ data: approvedRun }, // salary_runs lookup
{ data: null, error: { message: 'No rows' } }, // agi_declarations lookup
{ data: null, error: { code: 'PGRST116', message: 'no rows returned' } }, // update matched 0 rows
])
const request = createMockRequest('/api/salary/runs/run-1/unapprove', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(409)
expect(body.error).toContain('ändrats')
expect(eventBus.emit).not.toHaveBeenCalled()
})
it('reverts an approved run to review and emits the event', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
authed(supabase)
enqueueMany([
{ data: approvedRun }, // salary_runs lookup
{ data: null, error: { message: 'No rows' } }, // agi_declarations lookup (none)
{ data: { id: 'run-1', status: 'review' } }, // salary_runs update
])
const request = createMockRequest('/api/salary/runs/run-1/unapprove', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response)
expect(status).toBe(200)
expect(body.data.status).toBe('review')
expect(eventBus.emit).toHaveBeenCalledWith({
type: 'salary_run.approval_reverted',
payload: {
salaryRunId: 'run-1',
revertedBy: 'user-1',
deletedAgiDeclarationId: null,
userId: 'user-1',
companyId: 'company-1',
},
})
})
it('deletes a generated (unfiled) AGI declaration after reverting', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
authed(supabase)
enqueueMany([
{ data: approvedRun }, // salary_runs lookup
{ data: { id: 'agi-1', status: 'generated' } }, // agi_declarations lookup
{ data: { id: 'run-1', status: 'review' } }, // salary_runs update
{ data: [{ id: 'agi-1' }] }, // agi_declarations delete
])
const request = createMockRequest('/api/salary/runs/run-1/unapprove', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response)
expect(status).toBe(200)
expect(body.data.status).toBe('review')
expect(eventBus.emit).toHaveBeenCalledWith(
expect.objectContaining({
type: 'salary_run.approval_reverted',
payload: expect.objectContaining({ deletedAgiDeclarationId: 'agi-1' }),
}),
)
})
})
+139
View File
@@ -0,0 +1,139 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { eventBus } from '@/lib/events'
ensureInitialized()
/**
* approved → review (recall approval, unlock the run for recalculation).
*
* Approval is an internal control point — nothing legally binding has happened
* until payment, booking, or AGI filing — so recalling it is allowed as long
* as the AGI has not reached Skatteverket. Once the AGI is in flight
* (pending_signature) or filed (submitted/accepted), the period must instead
* be redone via a correction AGI with the same specifikationsnummer, so this
* route refuses.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.run.unapprove',
async (_request, { supabase, companyId, user, log }, { params }) => {
const { id } = await params
const { data: run, error: runError } = await supabase
.from('salary_runs')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (runError || !run) {
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
}
if (run.status !== 'approved') {
return NextResponse.json(
{ error: 'Bara en godkänd lönekörning kan låsas upp. En betald eller bokförd körning korrigeras via korrigeringsflödet.' },
{ status: 400 },
)
}
const { data: agiDeclaration } = await supabase
.from('agi_declarations')
.select('id, status')
.eq('company_id', companyId)
.eq('salary_run_id', id)
.single()
if (
run.agi_submitted_at ||
['pending_signature', 'submitted', 'accepted'].includes(agiDeclaration?.status ?? '')
) {
return NextResponse.json(
{ error: 'AGI har redan skickats till Skatteverket för denna period. Ändra genom att lämna in en korrigerad AGI (samma specifikationsnummer) i stället.' },
{ status: 409 },
)
}
// Clear payment-file tracking too: a previously generated file would show
// as current after re-approval even though the amounts may change. Whether
// the file already reached the bank is outside the app's knowledge — the
// UI makes the user confirm that before calling this route.
const { data: updatedRun, error } = await supabase
.from('salary_runs')
.update({
status: 'review',
approved_by: null,
approved_at: null,
agi_generated_at: null,
payment_file_format: null,
payment_file_generated_at: null,
})
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'approved')
// TOCTOU guard: AGI submission is allowed from `approved` (also out of
// band via MCP / the public API), so a filing may have landed since the
// read above. Re-assert it hasn't inside the update filter.
.is('agi_submitted_at', null)
.select()
.single()
if (error || !updatedRun) {
// Zero rows matched (PGRST116): the run moved on concurrently — marked
// paid, or the AGI was filed — between the read and this update. Not a
// server fault; tell the user to reload instead of returning 500.
if ((error as { code?: string } | null)?.code === 'PGRST116') {
return NextResponse.json(
{ error: 'Lönekörningens status har ändrats — ladda om sidan och försök igen.' },
{ status: 409 },
)
}
return NextResponse.json({ error: 'Kunde inte återkalla godkännandet' }, { status: 500 })
}
// A generated-but-unfiled AGI now carries stale amounts — delete it so the
// stale XML can't be exported. Deliberately after the status flip: the
// reverse order could destroy the declaration and then fail the
// transition, leaving an approved run with its AGI gone. If this delete
// misses instead, agi_generated_at is already null and regeneration on the
// forward path upserts over the orphaned row. The status filter makes the
// delete a no-op if the declaration advanced (e.g. to pending_signature)
// since the read. A rejected declaration is kept: it documents the
// rejection.
const staleAgi =
agiDeclaration && ['generated', 'exported'].includes(agiDeclaration.status)
? agiDeclaration
: null
let deletedAgiDeclarationId: string | null = null
if (staleAgi) {
const { data: deletedRows, error: deleteError } = await supabase
.from('agi_declarations')
.delete()
.eq('id', staleAgi.id)
.in('status', ['generated', 'exported'])
.select('id')
deletedAgiDeclarationId = deletedRows?.length ? staleAgi.id : null
if (deleteError) {
log.warn('stale AGI declaration delete failed', {
agiDeclarationId: staleAgi.id,
error: deleteError.message,
})
}
}
await eventBus.emit({
type: 'salary_run.approval_reverted',
payload: {
salaryRunId: id,
revertedBy: user.id,
deletedAgiDeclarationId,
userId: user.id,
companyId,
},
})
return NextResponse.json({ data: updatedRun })
},
{ requireWrite: true },
)