Files
accounted/app/api/pending-operations/[id]/__tests__/route.test.ts
T
MattssonandClaude Fable 5 8299ee9fb4 fix(bookkeeping): resolve settlement account in all categorization flows and ship mis-booking audit (#1383)
Completes the #985/#986/#987 caller sweep: categorize-core, v1
batch-categorize, pending-operation edits and the MCP categorize path now
resolve the settlement leg from the transaction's cash account instead of
inheriting a hardcoded or stale account. Extends the correct_entry preview
with currency, tax and dimension line metadata so staged corrections
preserve full line fidelity. Adds a read-only audit query and a runbook for
reviewing and correcting historical mis-bookings via staged storno with
explicit approval; no automated bulk mutation.

Fixes #1001

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 18:45:38 +02:00

492 lines
16 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
createMockRouteParams,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
// The route runs through the real withRouteContext wrapper: mock its auth,
// company-resolution and write-permission dependencies (getActiveCompanyId,
// not requireCompanyId, is what the wrapper calls) and inject the queued
// Supabase mock via requireAuth so the route's own queries stay in sequence.
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWritePermissionMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWritePermissionMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const mappingMock = vi.fn()
const accountMappingMock = vi.fn()
vi.mock('@/lib/bookkeeping/category-mapping', () => ({
buildMappingResultFromCategory: (...args: unknown[]) => mappingMock(...args),
getCategoryAccountMapping: (...args: unknown[]) => accountMappingMock(...args),
}))
const buildLinesMock = vi.fn()
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
buildTransactionEntryLines: (...args: unknown[]) => buildLinesMock(...args),
}))
import { PATCH } from '../route'
const mockUser = { id: 'user-1' }
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
requireWritePermissionMock.mockResolvedValue({ ok: true })
mappingMock.mockReturnValue({
debit_account: '5410',
credit_account: '1930',
vat_lines: [],
})
accountMappingMock.mockReturnValue({
debitAccount: '5410',
creditAccount: '1930',
vatTreatment: 'standard_25',
vatDebitAccount: '2641',
vatCreditAccount: null,
})
buildLinesMock.mockReturnValue([])
})
describe('PATCH /api/pending-operations/[id]', () => {
it('returns 401 when not authenticated', async () => {
const { NextResponse } = await import('next/server')
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', {
method: 'PATCH',
body: { category: 'expense_software' },
}),
createMockRouteParams({ id: 'op-1' }),
)
expect(res.status).toBe(401)
})
it('blocks viewers', async () => {
const { NextResponse } = await import('next/server')
requireWritePermissionMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'forbidden' }, { status: 403 }),
})
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', {
method: 'PATCH',
body: { category: 'expense_software' },
}),
createMockRouteParams({ id: 'op-1' }),
)
expect(res.status).toBe(403)
})
it('rejects empty body', async () => {
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', { method: 'PATCH', body: {} }),
createMockRouteParams({ id: 'op-1' }),
)
expect(res.status).toBe(400)
})
it('returns 404 when op not visible', async () => {
enqueue({ data: null })
const res = await PATCH(
createMockRequest('/api/pending-operations/op-x', {
method: 'PATCH',
body: { category: 'expense_software' },
}),
createMockRouteParams({ id: 'op-x' }),
)
expect(res.status).toBe(404)
})
it('returns 409 when op is no longer pending', async () => {
enqueue({
data: {
id: 'op-1',
company_id: 'company-1',
operation_type: 'categorize_transaction',
status: 'committed',
params: { transaction_id: 'tx-1', category: 'expense_other' },
preview_data: {},
title: '',
},
})
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', {
method: 'PATCH',
body: { category: 'expense_software' },
}),
createMockRouteParams({ id: 'op-1' }),
)
expect(res.status).toBe(409)
})
it('returns 400 when operation_type is not editable', async () => {
enqueue({
data: {
id: 'op-1',
company_id: 'company-1',
operation_type: 'create_invoice',
status: 'pending',
params: {},
preview_data: {},
title: '',
},
})
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', {
method: 'PATCH',
body: { category: 'expense_software' },
}),
createMockRouteParams({ id: 'op-1' }),
)
expect(res.status).toBe(400)
})
it('re-derives accounts and returns updated preview on category change', async () => {
// Sequence:
// 1. pending_operations lookup
// 2. transactions lookup
// 3. company_settings lookup
// 4. pending_operations update → returns updated row
enqueue({
data: {
id: 'op-1',
company_id: 'company-1',
operation_type: 'categorize_transaction',
status: 'pending',
params: {
transaction_id: 'tx-1',
category: 'expense_other',
vat_treatment: null,
},
preview_data: { debit_account: '6990', credit_account: '1930', amount: 500 },
title: 'Kategorisera: X',
},
})
enqueue({
data: {
id: 'tx-1',
company_id: 'company-1',
amount: -500,
currency: 'SEK',
date: '2026-05-10',
},
})
enqueue({ data: { entity_type: 'aktiebolag' } })
enqueue({
data: {
id: 'op-1',
params: { transaction_id: 'tx-1', category: 'expense_software', vat_treatment: null },
preview_data: {
debit_account: '5410',
credit_account: '1930',
amount: 500,
currency: 'SEK',
vat_lines: [],
category: 'expense_software',
},
title: 'Kategorisera: X',
status: 'pending',
},
})
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', {
method: 'PATCH',
body: { category: 'expense_software' },
}),
createMockRouteParams({ id: 'op-1' }),
)
const { status, body } = await parseJsonResponse<{
data: { preview_data: { category: string; debit_account: string } }
}>(res)
expect(status).toBe(200)
expect(body.data.preview_data.category).toBe('expense_software')
expect(body.data.preview_data.debit_account).toBe('5410')
expect(mappingMock).toHaveBeenCalledTimes(1)
})
it('re-derives the full journal lines from the new mapping (stale-preview guard)', async () => {
enqueue({
data: {
id: 'op-1',
company_id: 'company-1',
operation_type: 'categorize_transaction',
status: 'pending',
params: { transaction_id: 'tx-1', category: 'expense_other', vat_treatment: null },
preview_data: {
debit_account: '6990',
credit_account: '1930',
amount: 500,
// Stale lines from staging — must be replaced, not spread through.
lines: [{ account_number: '6990', debit_amount: 400, credit_amount: 0 }],
},
title: 'Kategorisera: X',
},
})
enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -500, currency: 'SEK' } })
enqueue({ data: { entity_type: 'aktiebolag' } })
enqueue({ data: { id: 'op-1', params: {}, preview_data: {}, title: '', status: 'pending' } })
const mapping = {
debit_account: '5420',
credit_account: '1930',
vat_lines: [
{ account_number: '2641', debit_amount: 100, credit_amount: 0, description: 'Ingående moms 25%' },
],
}
mappingMock.mockReturnValue(mapping)
buildLinesMock.mockReturnValue([
{ account_number: '2641', debit_amount: 100, credit_amount: 0, line_description: 'Ingående moms 25%' },
{ account_number: '5420', debit_amount: 400, credit_amount: 0, line_description: 'Kostnad' },
{ account_number: '1930', debit_amount: 0, credit_amount: 500, line_description: 'X' },
])
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', {
method: 'PATCH',
body: { category: 'expense_software' },
}),
createMockRouteParams({ id: 'op-1' }),
)
expect(res.status).toBe(200)
expect(buildLinesMock).toHaveBeenCalledTimes(1)
expect(buildLinesMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'tx-1' }),
mapping,
)
})
it('re-derives edited previews against the linked cash account', async () => {
enqueue({
data: {
id: 'op-1',
company_id: 'company-1',
operation_type: 'categorize_transaction',
status: 'pending',
params: { transaction_id: 'tx-1', category: 'expense_other', vat_treatment: null },
preview_data: { debit_account: '6990', credit_account: '1930', amount: 500 },
title: 'Kategorisera: X',
},
})
enqueue({
data: {
id: 'tx-1',
company_id: 'company-1',
amount: -500,
currency: 'SEK',
cash_account_id: 'cash-1',
},
})
enqueue({ data: { entity_type: 'aktiebolag' } })
enqueue({ data: { ledger_account: '1931' } })
enqueue({ data: { id: 'op-1', params: {}, preview_data: {}, title: '', status: 'pending' } })
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', {
method: 'PATCH',
body: { category: 'expense_software' },
}),
createMockRouteParams({ id: 'op-1' }),
)
expect(res.status).toBe(200)
expect(buildLinesMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'tx-1', cash_account_id: 'cash-1' }),
expect.objectContaining({ debit_account: '5410', credit_account: '1931' }),
)
})
it('preserves a staged vat_amount override when the new treatment still carries VAT', async () => {
enqueue({
data: {
id: 'op-1',
company_id: 'company-1',
operation_type: 'categorize_transaction',
status: 'pending',
params: {
transaction_id: 'tx-1',
category: 'expense_representation',
vat_treatment: 'reduced_12',
vat_amount: 42.43,
},
preview_data: {},
title: '',
},
})
enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -415.8, currency: 'SEK' } })
enqueue({ data: { entity_type: 'enskild_firma' } })
enqueue({ data: { id: 'op-1', params: {}, preview_data: {}, title: '', status: 'pending' } })
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', {
method: 'PATCH',
body: { category: 'expense_office' },
}),
createMockRouteParams({ id: 'op-1' }),
)
expect(res.status).toBe(200)
// 6th arg = vat_amount override, carried over from the staged params
// (vat_treatment persists too, only the category changed)
expect(mappingMock).toHaveBeenCalledWith(
'expense_office', expect.anything(), true, 'enskild_firma', 'reduced_12', 42.43,
)
})
it('drops a stale vat_amount override when the new treatment is VAT-less', async () => {
accountMappingMock.mockReturnValueOnce({
debitAccount: '6570',
creditAccount: '1930',
vatTreatment: null,
vatDebitAccount: null,
vatCreditAccount: null,
})
enqueue({
data: {
id: 'op-1',
company_id: 'company-1',
operation_type: 'categorize_transaction',
status: 'pending',
params: {
transaction_id: 'tx-1',
category: 'expense_representation',
vat_treatment: 'reduced_12',
vat_amount: 42.43,
},
preview_data: {},
title: '',
},
})
enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -415.8, currency: 'SEK' } })
enqueue({ data: { entity_type: 'enskild_firma' } })
enqueue({ data: { id: 'op-1', params: {}, preview_data: {}, title: '', status: 'pending' } })
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', {
method: 'PATCH',
body: { category: 'expense_bank_fees', vat_treatment: null },
}),
createMockRouteParams({ id: 'op-1' }),
)
expect(res.status).toBe(200)
expect(mappingMock).toHaveBeenCalledWith(
'expense_bank_fees', expect.anything(), true, 'enskild_firma', undefined, null,
)
})
it('returns 400 when vat_amount is explicitly set on a VAT-less treatment', async () => {
accountMappingMock.mockReturnValueOnce({
debitAccount: '5410',
creditAccount: '1930',
vatTreatment: null,
vatDebitAccount: null,
vatCreditAccount: null,
})
enqueue({
data: {
id: 'op-1',
company_id: 'company-1',
operation_type: 'categorize_transaction',
status: 'pending',
params: { transaction_id: 'tx-1', category: 'expense_other' },
preview_data: {},
title: '',
},
})
enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -500, currency: 'SEK' } })
enqueue({ data: { entity_type: 'enskild_firma' } })
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', {
method: 'PATCH',
body: { vat_treatment: 'exempt', vat_amount: 50 },
}),
createMockRouteParams({ id: 'op-1' }),
)
expect(res.status).toBe(400)
expect(mappingMock).not.toHaveBeenCalled()
})
it('returns 400 when the mapping builder rejects the vat_amount', async () => {
mappingMock.mockImplementationOnce(() => {
throw new Error('vat_amount 100 exceeds the maximum possible Swedish VAT on 415.8')
})
enqueue({
data: {
id: 'op-1',
company_id: 'company-1',
operation_type: 'categorize_transaction',
status: 'pending',
params: { transaction_id: 'tx-1', category: 'expense_representation', vat_treatment: 'reduced_12' },
preview_data: {},
title: '',
},
})
enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -415.8, currency: 'SEK' } })
enqueue({ data: { entity_type: 'enskild_firma' } })
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', {
method: 'PATCH',
body: { vat_amount: 100 },
}),
createMockRouteParams({ id: 'op-1' }),
)
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(400)
expect(body.error).toBe('Något gick fel. Försök igen.')
})
it('returns 400 when mapping yields no accounts', async () => {
enqueue({
data: {
id: 'op-1',
company_id: 'company-1',
operation_type: 'categorize_transaction',
status: 'pending',
params: { transaction_id: 'tx-1', category: 'expense_other' },
preview_data: {},
title: '',
},
})
enqueue({ data: { id: 'tx-1', amount: -100, currency: 'SEK' } })
enqueue({ data: { entity_type: 'enskild_firma' } })
mappingMock.mockReturnValueOnce({
debit_account: null,
credit_account: null,
vat_lines: [],
})
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', {
method: 'PATCH',
body: { category: 'private' },
}),
createMockRouteParams({ id: 'op-1' }),
)
expect(res.status).toBe(400)
})
})