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>
This commit is contained in:
Jakob Wennberg
2026-06-12 11:42:21 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f548b04aed
commit 8e8b63a200
8 changed files with 412 additions and 22 deletions
@@ -24,8 +24,10 @@ vi.mock('@/lib/auth/require-write', () => ({
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'
@@ -43,6 +45,13 @@ beforeEach(() => {
credit_account: '1930',
vat_lines: [],
})
accountMappingMock.mockReturnValue({
debitAccount: '5410',
creditAccount: '1930',
vatTreatment: 'standard_25',
vatDebitAccount: '2641',
vatCreditAccount: null,
})
})
describe('PATCH /api/pending-operations/[id]', () => {
@@ -202,6 +211,146 @@ describe('PATCH /api/pending-operations/[id]', () => {
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: {
+49 -8
View File
@@ -4,7 +4,8 @@ import { z } from 'zod'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { buildMappingResultFromCategory, getCategoryAccountMapping } from '@/lib/bookkeeping/category-mapping'
import { getVatRate } from '@/lib/bookkeeping/vat-entries'
import type { EntityType, Transaction, TransactionCategory, VatTreatment } from '@/types'
// PATCH /api/pending-operations/[id]
@@ -38,9 +39,11 @@ const PatchSchema = z
.object({
category: z.enum(CATEGORIES).optional(),
vat_treatment: z.enum(VAT_TREATMENTS).nullable().optional(),
// Underlag's actual VAT override (null clears it; omit to preserve)
vat_amount: z.number().min(0).nullable().optional(),
})
.refine(
(v) => v.category !== undefined || v.vat_treatment !== undefined,
(v) => v.category !== undefined || v.vat_treatment !== undefined || v.vat_amount !== undefined,
{ message: 'Nothing to update' },
)
@@ -129,13 +132,50 @@ export async function PATCH(
const entityType = ((settings?.entity_type as EntityType) || 'enskild_firma')
const isBusiness = newCategory !== 'private'
const mapping = buildMappingResultFromCategory(
newCategory,
tx as Transaction,
isBusiness,
entityType,
newVatTreatment,
// Resolve whether the (possibly defaulted) treatment carries a rate-based
// VAT line — only then can a vat_amount override survive. An explicit
// override on a VAT-less treatment is a caller error; a preserved one from
// before the edit is simply stale and gets dropped.
const probe = getCategoryAccountMapping(
newCategory, (tx as Transaction).amount, isBusiness, entityType, newVatTreatment,
)
const carriesRateVat =
isBusiness &&
probe.vatTreatment !== null &&
probe.vatTreatment !== 'reverse_charge' &&
getVatRate(probe.vatTreatment as VatTreatment) > 0
let newVatAmount: number | null
if (body.vat_amount !== undefined) {
if (body.vat_amount !== null && !carriesRateVat) {
return NextResponse.json(
{ error: 'vat_amount kräver en momspliktig vat_treatment (standard_25, reduced_12 eller reduced_6).' },
{ status: 400 },
)
}
newVatAmount = body.vat_amount
} else {
const previous = typeof oldParams.vat_amount === 'number' ? oldParams.vat_amount : null
newVatAmount = carriesRateVat ? previous : null
}
let mapping
try {
mapping = buildMappingResultFromCategory(
newCategory,
tx as Transaction,
isBusiness,
entityType,
newVatTreatment,
newVatAmount,
)
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Ogiltig momsjustering' },
{ status: 400 },
)
}
if (!mapping.debit_account || !mapping.credit_account) {
return NextResponse.json(
@@ -162,6 +202,7 @@ export async function PATCH(
...oldParams,
category: newCategory,
vat_treatment: newVatTreatment ?? null,
vat_amount: newVatAmount,
}
const { data: updated, error } = await supabase