Files
accounted/app/api/pending-operations/[id]/__tests__/route.test.ts
T
Jakob WennbergandClaude Fable 5 8e8b63a200 fix(bookkeeping): honor underlag VAT via vat_amount override in categorize flow (#717)
* fix(bookkeeping): honor underlag VAT via vat_amount override in categorize flow

The categorize flow always derived VAT as rate × gross/(1+rate) from the
transaction amount, with no way to use the underlag's actual moms. On e.g.
a restaurant receipt with dricks (no VAT on the tip), the agent could see
the document's correct VAT but the staged booking recomputed the wrong
rate-based amount on every attempt.

- buildMappingResultFromCategory: optional vatAmountOverride replaces the
  rate-derived VAT line ("Ingående/Utgående moms (enligt underlag)"; 0 =
  no VAT line). Rejects negatives, amounts above the 25%-extraction bound,
  and combination with reverse_charge / VAT-less treatments / private.
- gnubok_categorize_transaction: new vat_amount input, threaded into the
  staged preview and persisted in the operation params.
- commitCategorizeTransaction: reads params.vat_amount so the approved
  posting matches the staged preview exactly.
- PATCH /api/pending-operations/[id]: accepts vat_amount (null clears);
  preserves a staged override across category edits while the treatment
  still carries rate-based VAT, drops it when it no longer does.

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

* review: guard order + agent guidance on vat_amount (PR #717 bots)

- Check treatment compatibility before the 25%-extraction bound so an
  oversized override on reverse_charge reports the actual mistake (the
  treatment), not the amount. Document why the typeof re-check stays:
  commit-time params come from jsonb, so TS types don't hold at runtime.
- vat_amount property description now warns that foreign VAT is never
  deductible as ingående moms and that a 0-moms document should use
  vat_treatment="exempt" rather than vat_amount=0.

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

* fix(mcp): tools/list payload budget + reject vat_amount 0

core-only failed: the verbose vat_amount descriptions pushed the projected
tools/list payload to 36,051 tokens (ceiling 36,000; main is at 35,862).
Per the guard's own guidance, trim descriptions instead of bumping:
now 35,943.

Folds in the Swedish review's round-2 point while trimming: vat_amount 0
is now rejected with a pointer to vat_treatment "exempt". A 0-moms
document is an exempt supply — "exempt" produces the identical expense
booking and the correct income account (3004), so 0 had no use case and
only created a silent momsdeklaration misclassification path. Schema
declares exclusiveMinimum: 0.

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

* fix(bookkeeping): use roundOre for vat_amount math (antipattern ratchet)

Second core-only failure: the naive-ore-round ratchet caught the new
Math.round(x*100)/100 lines (662 > baseline 661). Switch the override
path to roundOre from lib/money — including the pre-existing computed-VAT
line this PR touched — and ratchet the baseline down (659, raw-route-auth
168 locked in from main-side fixes).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:42:21 +02:00

384 lines
12 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
createMockRouteParams,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
const requireCompanyIdMock = vi.fn()
vi.mock('@/lib/company/context', () => ({
requireCompanyId: (...args: unknown[]) => requireCompanyIdMock(...args),
}))
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),
}))
import { PATCH } from '../route'
const mockUser = { id: 'user-1' }
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
requireWritePermissionMock.mockResolvedValue({ ok: true })
requireCompanyIdMock.mockResolvedValue('company-1')
mappingMock.mockReturnValue({
debit_account: '5410',
credit_account: '1930',
vat_lines: [],
})
accountMappingMock.mockReturnValue({
debitAccount: '5410',
creditAccount: '1930',
vatTreatment: 'standard_25',
vatDebitAccount: '2641',
vatCreditAccount: null,
})
})
describe('PATCH /api/pending-operations/[id]', () => {
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
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('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).toMatch(/exceeds the maximum/)
})
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)
})
})