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:
co-authored by
Claude Fable 5
parent
f548b04aed
commit
8e8b63a200
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -497,6 +497,9 @@ async function categorizeTransactionCore(
|
||||
txId: string,
|
||||
category: TransactionCategory,
|
||||
vatTreatment: VatTreatment | undefined,
|
||||
// Underlag's actual VAT when it differs from rate × belopp (e.g. dricks on
|
||||
// a restaurant receipt carries no moms). Replaces the computed VAT line.
|
||||
vatAmount: number | undefined,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
supabase: SupabaseClient,
|
||||
@@ -619,7 +622,8 @@ async function categorizeTransactionCore(
|
||||
transaction as Transaction,
|
||||
isBusiness,
|
||||
entityType,
|
||||
vatTreatment
|
||||
vatTreatment,
|
||||
vatAmount
|
||||
)
|
||||
|
||||
if (!mappingResult.debit_account || !mappingResult.credit_account) {
|
||||
@@ -2549,7 +2553,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_categorize_transaction',
|
||||
title: 'Categorize Bank Transaction',
|
||||
description: 'Categorize a bank transaction. Stages the journal entry; commit via gnubok_approve_pending_operation. If an underlag is attached it rejects vat_treatment="reverse_charge" when the seller already charged VAT.',
|
||||
description: 'Categorize a bank transaction. Stages the journal entry; commit via gnubok_approve_pending_operation. vat_amount overrides the computed moms; reverse_charge is rejected when the underlag shows the seller already charged VAT.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -2557,6 +2561,7 @@ export const tools: McpTool[] = [
|
||||
transaction_id: { type: 'string', description: 'UUID of the transaction to categorize' },
|
||||
category: { type: 'string', description: 'Transaction category', enum: [...VALID_CATEGORIES] },
|
||||
vat_treatment: { type: 'string', description: 'VAT treatment override. Defaults to standard_25 for business expenses. Set reverse_charge ONLY when the underlag confirms the seller did NOT charge VAT (omvänd skattskyldighet). An invoice with foreign VAT already debited is NOT reverse charge.', enum: [...VALID_VAT_TREATMENTS] },
|
||||
vat_amount: { type: 'number', exclusiveMinimum: 0, description: 'The underlag\'s exact moms (> 0) when it differs from rate × belopp — e.g. dricks carries no VAT. Requires a rate-based vat_treatment. Swedish moms only — foreign VAT is never deductible. For a 0-moms document use vat_treatment="exempt".' },
|
||||
notes: { type: 'string', description: 'Audit-trail context appended to the verifikation description. For category=representation use this to record deltagare + syfte ("Anna Andersson (Acme AB), kundmöte om Y"). For project work, include the project ref. Keep under 200 chars; pure metadata, not a re-description of the transaction.' },
|
||||
},
|
||||
required: ['transaction_id', 'category'],
|
||||
@@ -2569,11 +2574,16 @@ export const tools: McpTool[] = [
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const vatAmount = typeof args.vat_amount === 'number' && Number.isFinite(args.vat_amount)
|
||||
? args.vat_amount
|
||||
: undefined
|
||||
|
||||
// Compute the preview (accounts, amounts, VAT lines)
|
||||
const result = await categorizeTransactionCore(
|
||||
args.transaction_id as string,
|
||||
args.category as TransactionCategory,
|
||||
args.vat_treatment as VatTreatment | undefined,
|
||||
vatAmount,
|
||||
userId,
|
||||
companyId,
|
||||
supabase,
|
||||
@@ -2605,6 +2615,7 @@ export const tools: McpTool[] = [
|
||||
transaction_id: args.transaction_id,
|
||||
category: args.category,
|
||||
vat_treatment: args.vat_treatment || null,
|
||||
vat_amount: vatAmount ?? null,
|
||||
notes: typeof args.notes === 'string' && args.notes.trim().length > 0
|
||||
? (args.notes as string).trim()
|
||||
: null,
|
||||
|
||||
@@ -137,6 +137,115 @@ describe('buildMappingResultFromCategory', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildMappingResultFromCategory vat_amount override (underlagets faktiska moms)', () => {
|
||||
// Real-world case: restaurant receipt 415.80 kr incl. dricks. The receipt's
|
||||
// actual 12% VAT is 42.43 kr — lower than rate-extraction 44.55 kr, because
|
||||
// dricks carries no moms. The override must win over the computed amount.
|
||||
it('uses the underlag VAT instead of rate-extraction for an expense', () => {
|
||||
const tx = makeTransaction({ amount: -415.8 })
|
||||
const result = buildMappingResultFromCategory(
|
||||
'expense_representation', tx, true, 'enskild_firma', 'reduced_12', 42.43,
|
||||
)
|
||||
|
||||
expect(result.vat_lines).toHaveLength(1)
|
||||
expect(result.vat_lines[0].account_number).toBe('2641')
|
||||
expect(result.vat_lines[0].debit_amount).toBe(42.43)
|
||||
expect(result.vat_lines[0].description).toBe('Ingående moms (enligt underlag)')
|
||||
})
|
||||
|
||||
it('without override the computed amount is unchanged (regression)', () => {
|
||||
const tx = makeTransaction({ amount: -415.8 })
|
||||
const result = buildMappingResultFromCategory(
|
||||
'expense_representation', tx, true, 'enskild_firma', 'reduced_12',
|
||||
)
|
||||
|
||||
expect(result.vat_lines).toHaveLength(1)
|
||||
expect(result.vat_lines[0].debit_amount).toBe(44.55)
|
||||
expect(result.vat_lines[0].description).toBe('Ingående moms 12%')
|
||||
})
|
||||
|
||||
it('null override behaves like no override', () => {
|
||||
const tx = makeTransaction({ amount: -415.8 })
|
||||
const result = buildMappingResultFromCategory(
|
||||
'expense_representation', tx, true, 'enskild_firma', 'reduced_12', null,
|
||||
)
|
||||
expect(result.vat_lines[0].debit_amount).toBe(44.55)
|
||||
})
|
||||
|
||||
it('rejects override 0, pointing to vat_treatment exempt', () => {
|
||||
// A 0-moms document is an exempt supply — booking it as a rate-bearing
|
||||
// treatment minus its VAT line would misclassify it in the momsdeklaration.
|
||||
const tx = makeTransaction({ amount: -500 })
|
||||
expect(() =>
|
||||
buildMappingResultFromCategory('expense_office', tx, true, 'enskild_firma', 'standard_25', 0),
|
||||
).toThrow(/exempt/)
|
||||
})
|
||||
|
||||
it('overrides output VAT on income', () => {
|
||||
const tx = makeTransaction({ amount: 1000 })
|
||||
const result = buildMappingResultFromCategory(
|
||||
'income_services', tx, true, 'enskild_firma', 'standard_25', 180,
|
||||
)
|
||||
|
||||
expect(result.vat_lines).toHaveLength(1)
|
||||
expect(result.vat_lines[0].account_number).toBe('2611')
|
||||
expect(result.vat_lines[0].credit_amount).toBe(180)
|
||||
expect(result.vat_lines[0].description).toBe('Utgående moms (enligt underlag)')
|
||||
})
|
||||
|
||||
it('rejects an override above the 25% extraction bound', () => {
|
||||
const tx = makeTransaction({ amount: -415.8 })
|
||||
// max possible Swedish VAT on 415.80 gross is 83.16 (25% extraction)
|
||||
expect(() =>
|
||||
buildMappingResultFromCategory('expense_representation', tx, true, 'enskild_firma', 'reduced_12', 100),
|
||||
).toThrow(/exceeds the maximum possible Swedish VAT/)
|
||||
})
|
||||
|
||||
it('rejects a negative override', () => {
|
||||
const tx = makeTransaction({ amount: -500 })
|
||||
expect(() =>
|
||||
buildMappingResultFromCategory('expense_office', tx, true, 'enskild_firma', 'standard_25', -1),
|
||||
).toThrow(/positive/)
|
||||
})
|
||||
|
||||
it('rejects an override combined with reverse_charge', () => {
|
||||
const tx = makeTransaction({ amount: -1000 })
|
||||
expect(() =>
|
||||
buildMappingResultFromCategory('expense_software', tx, true, 'enskild_firma', 'reverse_charge', 50),
|
||||
).toThrow(/cannot be combined/)
|
||||
})
|
||||
|
||||
it('treatment incompatibility wins over the bound check (oversized + reverse_charge)', () => {
|
||||
const tx = makeTransaction({ amount: -1000 })
|
||||
// 500 also exceeds maxVat (200), but the agent's actual mistake is the
|
||||
// treatment — the error must say so, not complain about the amount.
|
||||
expect(() =>
|
||||
buildMappingResultFromCategory('expense_software', tx, true, 'enskild_firma', 'reverse_charge', 500),
|
||||
).toThrow(/cannot be combined/)
|
||||
})
|
||||
|
||||
it('rejects an override on a VAT-less treatment', () => {
|
||||
const tx = makeTransaction({ amount: -1000 })
|
||||
expect(() =>
|
||||
buildMappingResultFromCategory('expense_software', tx, true, 'enskild_firma', 'exempt', 50),
|
||||
).toThrow(/cannot be combined/)
|
||||
})
|
||||
|
||||
it('rejects an override on a VAT-exempt default category (bank fees)', () => {
|
||||
const tx = makeTransaction({ amount: -100 })
|
||||
expect(() =>
|
||||
buildMappingResultFromCategory('expense_bank_fees', tx, true, 'enskild_firma', undefined, 10),
|
||||
).toThrow(/cannot be combined/)
|
||||
})
|
||||
|
||||
it('rejects an override on private transactions', () => {
|
||||
const tx = makeTransaction({ amount: -500 })
|
||||
expect(() =>
|
||||
buildMappingResultFromCategory('private', tx, false, 'enskild_firma', undefined, 50),
|
||||
).toThrow(/cannot be combined/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildMappingResultFromCategory returns non-empty accounts', () => {
|
||||
const allCategories: TransactionCategory[] = [
|
||||
'income_services',
|
||||
|
||||
@@ -250,6 +250,37 @@ describe('createTransactionJournalEntry', () => {
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('nets the expense line against an underlag VAT override below the rate amount', async () => {
|
||||
// Restaurant receipt 415.80 kr incl. dricks: the document's 12% VAT is
|
||||
// 42.43 kr (not rate-extraction 44.55) because dricks carries no moms.
|
||||
// The expense line must absorb the difference so the entry balances.
|
||||
const tx = makeTransaction({ amount: -415.80, description: 'LEONH Repr' })
|
||||
const vatLines: VatJournalLine[] = [
|
||||
{ account_number: '2641', debit_amount: 42.43, credit_amount: 0, description: 'Ingående moms (enligt underlag)' },
|
||||
]
|
||||
const mapping = makeMappingResult({
|
||||
debit_account: '6071',
|
||||
credit_account: '1930',
|
||||
vat_lines: vatLines,
|
||||
})
|
||||
|
||||
await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping)
|
||||
|
||||
const input = mockedCreateEntry.mock.calls[0][3]
|
||||
expect(input.lines).toHaveLength(3)
|
||||
|
||||
const debit2641 = input.lines.find(l => l.account_number === '2641')
|
||||
expect(debit2641?.debit_amount).toBe(42.43)
|
||||
|
||||
const debit6071 = input.lines.find(l => l.account_number === '6071')
|
||||
expect(debit6071?.debit_amount).toBe(373.37) // 415.80 - 42.43
|
||||
|
||||
const credit1930 = input.lines.find(l => l.account_number === '1930')
|
||||
expect(credit1930?.credit_amount).toBe(415.80)
|
||||
|
||||
assertBalanced(input)
|
||||
})
|
||||
|
||||
it('handles VAT rounding precision on expense', async () => {
|
||||
const tx = makeTransaction({ amount: -997.50, description: 'Expense with rounding' })
|
||||
const vatLines: VatJournalLine[] = [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TransactionCategory, MappingResult, VatJournalLine, Transaction, EntityType, VatTreatment } from '@/types'
|
||||
import { getVatRate, generateReverseChargeLines } from './vat-entries'
|
||||
import { roundOre } from '@/lib/money'
|
||||
|
||||
/**
|
||||
* Maps TransactionCategory to BAS accounts for journal entry creation
|
||||
@@ -205,13 +206,23 @@ export function getCategoryAccountMapping(
|
||||
/**
|
||||
* Build a MappingResult from a category selection
|
||||
* Used by the categorization API to create journal entries
|
||||
*
|
||||
* `vatAmountOverride` is the underlag's actual VAT when it differs from the
|
||||
* rate-derived amount — e.g. a restaurant receipt where dricks carries no
|
||||
* moms, so the document's VAT is lower than rate × gross. It can only replace
|
||||
* a rate-based VAT line (standard_25/reduced_12/reduced_6); it never applies
|
||||
* to fictive reverse-charge VAT and never conjures a line for treatments
|
||||
* without VAT. Zero is rejected: a document with no moms is an exempt supply
|
||||
* and must be booked with vat_treatment "exempt" so the momsdeklaration sees
|
||||
* the correct classification, not a rate-bearing treatment minus its VAT line.
|
||||
*/
|
||||
export function buildMappingResultFromCategory(
|
||||
category: TransactionCategory,
|
||||
transaction: Transaction,
|
||||
isBusiness: boolean,
|
||||
entityType: EntityType = 'enskild_firma',
|
||||
vatTreatment?: VatTreatment
|
||||
vatTreatment?: VatTreatment,
|
||||
vatAmountOverride?: number | null
|
||||
): MappingResult {
|
||||
const mapping = getCategoryAccountMapping(category, transaction.amount, isBusiness, entityType, vatTreatment)
|
||||
|
||||
@@ -219,6 +230,38 @@ export function buildMappingResultFromCategory(
|
||||
|
||||
// Calculate VAT if applicable using the resolved treatment from mapping
|
||||
const treatment = mapping.vatTreatment as VatTreatment | null
|
||||
const hasVatOverride = vatAmountOverride !== undefined && vatAmountOverride !== null
|
||||
|
||||
if (hasVatOverride) {
|
||||
// Treatment compatibility first: an invalid override on reverse_charge is
|
||||
// a treatment problem, not an amount problem — the agent should get the
|
||||
// correction hint that matches the actual mistake.
|
||||
if (!isBusiness || !treatment || treatment === 'reverse_charge' || getVatRate(treatment) <= 0) {
|
||||
throw new Error(
|
||||
`vat_amount cannot be combined with vat_treatment "${treatment ?? 'none'}" — ` +
|
||||
'it only overrides a rate-based VAT line (standard_25, reduced_12, reduced_6).'
|
||||
)
|
||||
}
|
||||
// typeof re-check is deliberate: at commit time the override comes from
|
||||
// jsonb params, so the TS signature doesn't guarantee a number at runtime.
|
||||
if (typeof vatAmountOverride !== 'number' || !Number.isFinite(vatAmountOverride) || vatAmountOverride <= 0) {
|
||||
throw new Error(
|
||||
`vat_amount must be a positive number, got ${vatAmountOverride}. ` +
|
||||
'For a document with no moms, use vat_treatment "exempt" instead of vat_amount 0.'
|
||||
)
|
||||
}
|
||||
const grossAmount = Math.abs(transaction.amount)
|
||||
// 25% is the highest Swedish VAT rate, so rate-extraction at 25% bounds
|
||||
// any legitimate document VAT — even on mixed-rate receipts.
|
||||
const maxVat = roundOre(grossAmount * 0.25 / 1.25)
|
||||
if (vatAmountOverride > maxVat) {
|
||||
throw new Error(
|
||||
`vat_amount ${vatAmountOverride} exceeds the maximum possible Swedish VAT on ${grossAmount} ` +
|
||||
`(${maxVat} at 25%). Check the underlag — the override must be the document's actual moms.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isBusiness && treatment) {
|
||||
const vatRate = getVatRate(treatment)
|
||||
if (treatment === 'reverse_charge' && transaction.amount < 0) {
|
||||
@@ -235,23 +278,25 @@ export function buildMappingResultFromCategory(
|
||||
}
|
||||
} else if (vatRate > 0) {
|
||||
const grossAmount = Math.abs(transaction.amount)
|
||||
const vatAmount = Math.round((grossAmount * vatRate / (1 + vatRate)) * 100) / 100
|
||||
const vatAmount = hasVatOverride
|
||||
? roundOre(vatAmountOverride as number)
|
||||
: roundOre(grossAmount * vatRate / (1 + vatRate))
|
||||
|
||||
if (transaction.amount < 0 && mapping.vatDebitAccount) {
|
||||
if (vatAmount > 0 && transaction.amount < 0 && mapping.vatDebitAccount) {
|
||||
// Expense: Ingående moms (deductible VAT)
|
||||
vatLines.push({
|
||||
account_number: mapping.vatDebitAccount,
|
||||
debit_amount: vatAmount,
|
||||
credit_amount: 0,
|
||||
description: `Ingående moms ${vatRate * 100}%`,
|
||||
description: hasVatOverride ? 'Ingående moms (enligt underlag)' : `Ingående moms ${vatRate * 100}%`,
|
||||
})
|
||||
} else if (transaction.amount > 0 && mapping.vatCreditAccount) {
|
||||
} else if (vatAmount > 0 && transaction.amount > 0 && mapping.vatCreditAccount) {
|
||||
// Income: Utgående moms (output VAT)
|
||||
vatLines.push({
|
||||
account_number: mapping.vatCreditAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: vatAmount,
|
||||
description: `Utgående moms ${vatRate * 100}%`,
|
||||
description: hasVatOverride ? 'Utgående moms (enligt underlag)' : `Utgående moms ${vatRate * 100}%`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +256,13 @@ async function commitCategorizeTransaction(
|
||||
typeof params.notes === 'string' && params.notes.trim().length > 0
|
||||
? (params.notes as string)
|
||||
: undefined
|
||||
// The underlag's actual VAT, staged when the document's moms differs from
|
||||
// rate × belopp (e.g. dricks). Threaded into the mapping builder so the
|
||||
// approved posting matches the staged preview exactly.
|
||||
const vatAmount =
|
||||
typeof params.vat_amount === 'number' && Number.isFinite(params.vat_amount)
|
||||
? params.vat_amount
|
||||
: undefined
|
||||
|
||||
const { data: transaction, error: fetchError } = await supabase
|
||||
.from('transactions').select('*').eq('id', txId).eq('company_id', companyId).single()
|
||||
@@ -276,7 +283,7 @@ async function commitCategorizeTransaction(
|
||||
const fiscalYearStartMonth = settings?.fiscal_year_start_month ?? 1
|
||||
|
||||
const mappingResult = buildMappingResultFromCategory(
|
||||
category, transaction as Transaction, isBusiness, entityType, vatTreatment
|
||||
category, transaction as Transaction, isBusiness, entityType, vatTreatment, vatAmount
|
||||
)
|
||||
|
||||
if (!mappingResult.debit_account || !mappingResult.credit_account) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_comment": "Ratchet baseline for scripts/checks/no-new-antipatterns.mjs. These counts may only decrease. Re-run with --update after a migration lowers them. Goal: both reach 0 (A1 route-auth campaign, D1 rounding codemod).",
|
||||
"rawRouteAuth": {
|
||||
"count": 171,
|
||||
"count": 168,
|
||||
"files": [
|
||||
"app/api/account/delete/route.ts",
|
||||
"app/api/account/password/route.ts",
|
||||
@@ -39,7 +39,6 @@
|
||||
"app/api/bookkeeping/voucher-gaps/route.ts",
|
||||
"app/api/calendar/feed/route.ts",
|
||||
"app/api/cash-accounts/route.ts",
|
||||
"app/api/company/check-org-number/route.ts",
|
||||
"app/api/company/current/route.ts",
|
||||
"app/api/company/members/[id]/route.ts",
|
||||
"app/api/company/members/invite/[id]/route.ts",
|
||||
@@ -142,7 +141,6 @@
|
||||
"app/api/salary/runs/[id]/payslips/send/route.ts",
|
||||
"app/api/salary/runs/[id]/preview/route.ts",
|
||||
"app/api/salary/runs/[id]/review/route.ts",
|
||||
"app/api/salary/runs/[id]/route.ts",
|
||||
"app/api/salary/tax-tables/lookup/route.ts",
|
||||
"app/api/salary/tax-tables/status/route.ts",
|
||||
"app/api/settings/api-keys/[id]/route.ts",
|
||||
@@ -171,12 +169,11 @@
|
||||
"app/api/transactions/[id]/uncategorize/route.ts",
|
||||
"app/api/transactions/batch-match-invoices/route.ts",
|
||||
"app/api/transactions/create-from-document/route.ts",
|
||||
"app/api/transactions/route.ts",
|
||||
"app/api/transactions/suggest-categories/route.ts",
|
||||
"app/api/vat/validate/route.ts"
|
||||
]
|
||||
},
|
||||
"naiveOreRound": {
|
||||
"count": 661
|
||||
"count": 659
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user