feat(mcp): book on custom accounts via account_override; fix kontoplan settings link (#1608)

* feat(mcp): book on custom accounts via account_override; fix kontoplan settings link

gnubok_categorize_transaction only spoke a 19-category enum mapping to 21
hardcoded BAS accounts, so company-custom accounts (e.g. VMB) were
unreachable from the agent surface even when active in the chart.

- add account_override to gnubok_categorize_transaction with v1 REST
  semantics via a shared helper (lib/bookkeeping/account-override.ts):
  business-side replacement, class-2 auto-VAT drop with the 2610-2649
  moms-line exception, plus a same-account degenerate guard; validated at
  staging and re-validated at commit
- align the gnubok_create_voucher staging gate with the engine's seeding
  semantics: BAS 2026 accounts merely absent from the chart pass (the
  engine backfills them at commit) and the preview lists
  will_activate_accounts with BAS-name fallback; non-BAS unknown and
  inactive accounts still rejected
- stop suggest_categories silently dropping mapping rules whose account
  is outside the fixed category maps; they surface with the rule's own
  account and an explanatory match_reason
- correct the create_account next-step hint (categorize could never use
  the new account before; now true via account_override)
- point the settings "Kontoplan (BAS)" link at /chart-of-accounts and
  redirect the orphaned /bookkeeping?tab=accounts URL (tab removed in
  #850; the deep link never worked after the #854 merge collision)

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

* fix(mcp): address review findings on account_override

- commit executor rejects a present-but-malformed stored account_override
  loudly instead of degrading to the category default (CodeRabbit major;
  the approver approved a preview showing the override account); with
  commitPendingOperation regression tests
- accountToCategory returns null for unknown income accounts so custom
  income accounts get the same diagnostic as expenses (CodeRabbit minor),
  with income + reason-accumulation tests (CodeRabbit nit)
- pin the class-2 VAT-drop balance invariant with a test through
  buildTransactionEntryLines (Swedish compliance review: gross booking,
  never an unbalanced net + missing VAT leg)
- account_override description asks the agent to state the actual
  affärshändelse in notes when overriding (BFL 5 kap description concern)
- eventBus.clear() in the two new test suites (CodeRabbit minor)

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

* fix(mcp): never guess a moms leg onto an account_override without explicit VAT intent

Round-2 Swedish compliance finding: the class-2 VAT drop did not cover
margin-scheme (VMB) accounts in class 3/4, which are the override's
flagship use case, so a forgotten vat_treatment attached the category
default standard_25 and booked an ingående-moms deduction on a
transaction where input VAT is not deductible (ML 2023:200).

applyAccountOverride now takes explicit VAT intent (vat_treatment or
vat_amount present) and books GROSS with no auto-VAT line without it:
forgetting the flag under-deducts (lawful), never over-deducts. Both
call sites (MCP staging preview, commit core) derive the flag the same
way; the tool description states the enforced behavior. Deliberate
divergence from v1 REST recorded in DECISIONS.md.

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

* chore: move stray decision-log entry to the root DECISIONS.md

The round-2 entry was appended from the wrong working directory and
landed as lib/bookkeeping/__tests__/DECISIONS.md.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-14 01:24:48 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 4bb0655e4a
commit fbe4e18730
14 changed files with 915 additions and 24 deletions
+4
View File
@@ -956,9 +956,13 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-13] PR #1598, compliance findings closed with the rollout after the Swedish accounting review escalated them from follow-up to fix-with-rollout: (a) runReconciliation's >= 0.9 auto-apply now writes 'matched' to payment_match_log (behandlingshistorik, BFNAR 2013:2 kap 8); (b) the three match-route storno-conflict branches no longer storno-reverse a reconciliation-linked verifikat: a reconciliation link points at an independent verifikat that may evidence other affarshandelser, and a wholesale reversal is an over-broad rattelse (BFL 5 kap 5 §). The detach is DEFERRED (round 2, CodeRabbit): nothing is persisted up front; the final transaction update overwrites the pointer and clears reconciliation_method in the same write, so a failure anywhere in the match flow leaves the existing link intact, and the release is logged as 'unmatched' after the commit.
[2026-08-13] PR #1598, CodeRabbit findings: confirm-suggestions maxDuration 300; lookbackTouched on the migrator nudge buttons; persistSuggestions on main's post-backfill sweep; sie_sweep stamp errors logged; sandbox keeps the CSV CTA (file import works there); payment_match_log CHECK swap now NOT VALID + VALIDATE (no table-scan under ACCESS EXCLUSIVE); every logMatchEvent call awaited (serverless can freeze unawaited work).
[2026-08-13] Historical audit gap quantified on prod (read-only): 762 manual-method links across 52 companies since 2026-03-23 have no payment_match_log row (upper bound: includes linked_to_existing_voucher drops AND older unlogged manual paths). Not backfillable (the inserts never landed); the links themselves are intact on transactions. Recorded here as the explicit ops note the compliance review asked for.
[2026-08-13] MCP account_override (custom accounts via categorize) implemented as a new shared helper lib/bookkeeping/account-override.ts used by the MCP staging preview and categorize-core commit path, mirroring v1 REST semantics (business-side replacement, class-2 VAT drop with 2610-2649 exception) plus a same-account degenerate guard v1 lacks; the v1/internal REST routes keep their inline copies untouched: refactoring them into the helper would widen a scoped fix into a three-surface regression risk. Divergence from REST: MCP rejects account_override + category 'private' explicitly instead of silently ignoring it (agent surfaces get deterministic errors, not silent drops).
[2026-08-13] gnubok_create_voucher staging gate softened to engine semantics inline (already-fetched chart rows + getBASReference) instead of calling findUnresolvableAccounts: identical verdicts, zero extra queries, and the preview gains will_activate_accounts + BAS-name fallback so the approver sees the auto-activation side-effect.
[2026-08-13] suggest_categories rules on accounts outside the fixed maps now surface as expense_other/income_other with the rule's own account and a neutral Swedish match_reason (no MCP jargon: the same suggestion renders in the web UI); previously such rules were silently dropped (agent-reported as "4020 not available").
[2026-08-13] Öresavrundning av nettolön: round-UP-only to whole kronor, booked on 3740 as a derived 'oresavrundning' line item (semesterersattning pattern), no per-employee carry-forward: rounding down would underpay wages, 3740 is the codebase's established rounding account, and a carry-forward ledger is not worth the complexity for max 99 öre/employee/month.
[2026-08-13] totalEmployerCost excludes the öresavrundning amount (skeptic finding): payslip summary, KPI cards and lönejournal recompute employer cost from stored columns, so an engine-only inclusion printed two different totals on the same payslip; the öre cost lives in the ledger as the 3740 debit instead. Manual 'oresavrundning' line items are schema-blocked: they are the only type the booking excludes from the gross reconciliation, so a hand-created one structurally unbalances the verifikat.
[2026-08-13] Startkort empty states use inline gradient scrims over their strata images: this is imagery treatment inside a hero surface, not card chrome, so the "no bg-gradient on cards" rule deliberately does not apply there (and nowhere else).
[2026-08-13] Startkort webp assets (public/startkort/) are rendered outputs from the strata-engine in the CRM workspace, with per-file sources and flags recorded in components/dashboard/startkort-assets.ts; regenerate there, never edit the webp files by hand.
[2026-08-13] Renaming a migration after the Supabase preview applied it orphans the PREVIEW tracker, not prod: the preview branch for PR 1605 had recorded 20260813180000, the rename to 20260813213000 removed that file, and the next Migrations task aborted with "Remote migration versions not found in local migrations directory". Repair = delete the orphaned version row from supabase_migrations.schema_migrations on the preview branch project only (prod never applied it, so merge-time apply is unaffected), then re-run tasks with a fresh commit. The migration itself is drop-if-exists idempotent so the replay under the new version is safe.
[2026-08-14] PR #1608 round-2 compliance finding (VMB class-3/4 VAT hole): account_override now books GROSS with no auto-VAT line unless the caller stated explicit VAT intent (vat_treatment or vat_amount). Deliberate divergence from v1 REST, which keeps the category-default standard_25 on overrides: MCP callers are agents, and a forgotten flag must under-deduct (lawful), never fabricate an ingående-moms deduction on a margin-scheme account. The review's class-appropriateness suggestion (block e.g. expense onto 27xx) was noted but not implemented: the approval gate is the guard, and a class matrix would block legitimate balance-sheet bookings v1 REST supports today.
@@ -181,7 +181,7 @@ export function BookkeepingSettingsContent() {
<SettingsGroup>
<SettingsRow label={t('related_heading')} borderless>
<Link
href="/bookkeeping?tab=accounts"
href="/chart-of-accounts"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
@@ -0,0 +1,175 @@
/**
* gnubok_categorize_transaction: the account_override parameter.
*
* Custom accounts (e.g. VMB accounts like 4020) are outside the fixed
* category → account maps, so account_override is the ONLY path by which the
* MCP categorize surface can book on them. These tests pin:
* - staging-time validation (unknown / inactive / malformed / private),
* - the override landing in both the staged params (for the commit
* executor) and the preview (for the approver),
* Companion suites:
* - lib/bookkeeping/__tests__/account-override.test.ts (the shared helper)
* - lib/transactions/__tests__/categorize-core.override.test.ts (commit path)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const mockDetectDup = vi.fn()
vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({
detectBookingDuplicate: (...args: unknown[]) => mockDetectDup(...args),
}))
import { tools } from '../server'
const categorize = tools.find((t) => t.name === 'gnubok_categorize_transaction')!
const TX_ID = '00000000-0000-4000-8000-0000000000bb'
/** `transactions` row for categorizeTransactionCore's select('*'). Synthetic. */
const coreTxRow = (over: Record<string, unknown> = {}) => ({
id: TX_ID,
date: '2026-07-10',
amount: -479,
currency: 'SEK',
amount_sek: -479,
exchange_rate: 1,
description: 'SECOND HAND BUTIK',
merchant_name: null,
cash_account_id: null,
document_id: null,
journal_entry_id: null,
is_business: true,
...over,
})
/** The narrower projection the tool re-fetches for the guard + title. */
const guardTxRow = (over: Record<string, unknown> = {}) => ({
description: 'SECOND HAND BUTIK',
merchant_name: null,
amount: -479,
currency: 'SEK',
amount_sek: -479,
exchange_rate: 1,
date: '2026-07-10',
cash_account_id: null,
...over,
})
const settingsRow = { entity_type: 'aktiebolag', fiscal_year_start_month: 1 }
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
mockDetectDup.mockResolvedValue(null)
})
describe('gnubok_categorize_transaction: account_override', () => {
it('declares account_override in the input schema with a 4-digit pattern', () => {
const schema = categorize.inputSchema as {
properties: { account_override?: { type: string; pattern?: string } }
required?: string[]
}
expect(schema.properties.account_override).toBeDefined()
expect(schema.properties.account_override?.type).toBe('string')
expect(schema.properties.account_override?.pattern).toBe('^\\d{4}$')
expect(schema.required ?? []).not.toContain('account_override')
})
it('rejects a malformed override before any DB work', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
categorize.execute(
{ transaction_id: TX_ID, category: 'expense_other', account_override: '40a0' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/4 digits/)
})
it('rejects at staging when the override account is not in the chart', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: coreTxRow() }) // core: transactions
enqueue({ data: settingsRow }) // core: company_settings
enqueue({ data: null }) // applyAccountOverride: chart_of_accounts miss
await expect(
categorize.execute(
{ transaction_id: TX_ID, category: 'expense_other', account_override: '4020' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/finns inte i kontoplanen/)
})
it('rejects at staging when the override account is inactive', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: coreTxRow() })
enqueue({ data: settingsRow })
enqueue({ data: { account_number: '4020', account_class: 4, is_active: false } })
await expect(
categorize.execute(
{ transaction_id: TX_ID, category: 'expense_other', account_override: '4020' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/inaktivt/)
})
it('rejects account_override combined with category "private"', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: coreTxRow() })
enqueue({ data: settingsRow })
await expect(
categorize.execute(
{ transaction_id: TX_ID, category: 'private', account_override: '4020' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/private/)
})
it('stages with the override in params AND preview when the account is active', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: coreTxRow() }) // core: transactions
enqueue({ data: settingsRow }) // core: company_settings
enqueue({ data: { account_number: '4020', account_class: 4, is_active: true } }) // override chart hit
enqueue({ data: guardTxRow() }) // tool: transactions re-fetch
enqueue({ data: null }) // resolvePeriodStatusForDate: company_settings
enqueue({ data: null }) // resolvePeriodStatusForDate: fiscal_periods
enqueue({ data: { id: 'op-override-1' } }) // pending_operations insert
const result = (await categorize.execute(
{
transaction_id: TX_ID,
category: 'expense_other',
vat_treatment: 'exempt',
account_override: '4020',
},
'company-1',
'user-1',
supabase as never,
{ type: 'api_key' },
)) as { staged: boolean; operation_id?: string; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.operation_id).toBe('op-override-1')
// The preview must show the account that will actually be posted, not the
// category default (6991 for expense_other).
expect(result.preview.debit_account).toBe('4020')
expect(result.preview.account_override).toBe('4020')
// The staged params must carry the override so the commit executor books
// on it after approval.
const insertArgs = findCall('pending_operations', 'insert')
expect(insertArgs).toBeDefined()
const payload = (insertArgs as unknown[])[0] as { params?: { account_override?: string | null } }
expect(payload.params?.account_override).toBe('4020')
})
})
@@ -142,7 +142,7 @@ describe('gnubok_create_voucher: staging gates', () => {
).rejects.toThrow(/utanför/i)
})
it('rejects when a referenced account is missing from the chart', async () => {
it('rejects an account that is neither in the chart nor in BAS 2026', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
@@ -154,22 +154,81 @@ describe('gnubok_create_voucher: staging gates', () => {
},
error: null,
})
// chart_of_accounts returns nothing: both accounts unknown
enqueue({ data: [], error: null })
// chart_of_accounts knows only 1930: 4020 is a custom number the company
// never created, and it is absent from the BAS 2026 catalog, so the
// engine could not seed it either.
enqueue({
data: [{ account_number: '1930', account_name: 'Företagskonto', is_active: true }],
error: null,
})
await expect(
createVoucher.execute(
{
entry_date: '2026-05-12',
description: 'unknown accounts',
description: 'custom account never created',
fiscal_period_id: 'fp-1',
lines: balancedLines,
lines: [
{ account_number: '4020', debit_amount: 250, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
],
},
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/saknas i kontoplanen/i)
).rejects.toThrow(/saknas i kontoplanen och finns inte i BAS 2026: 4020/i)
})
it('stages when a BAS 2026 account is merely absent from the chart (engine seeds it)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
id: 'fp-1',
is_closed: false,
period_start: '2026-01-01',
period_end: '2026-12-31',
name: '2026',
},
error: null,
})
// 7690 (Övriga personalkostnader) is in BAS 2026 but not in this chart:
// the old gate rejected it even though createDraftEntry backfills it.
enqueue({
data: [{ account_number: '1930', account_name: 'Företagskonto', is_active: true }],
error: null,
})
enqueue({ data: null, error: null }) // resolvePeriodStatusForDate layer 1
enqueue({ data: null, error: null }) // resolvePeriodStatusForDate layer 2
enqueue({ data: { id: 'op-seedable' }, error: null }) // pending_operations insert
const result = (await createVoucher.execute(
{
entry_date: '2026-05-12',
description: 'Friskvård badhus',
fiscal_period_id: 'fp-1',
lines: [
{ account_number: '7690', debit_amount: 250, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
],
},
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
preview: {
will_activate_accounts?: string[]
lines: Array<{ account_number: string; account_name: string | null }>
}
}
expect(result.staged).toBe(true)
// The approver must see the activation side-effect and a named line
// (name from the BAS catalog, not a bare number).
expect(result.preview.will_activate_accounts).toEqual(['7690'])
const line7690 = result.preview.lines.find((l) => l.account_number === '7690')
expect(line7690?.account_name).toMatch(/personalkostnader/i)
})
it('rejects when a referenced account exists but is inactive', async () => {
+56 -10
View File
@@ -18,6 +18,8 @@ import { createLogger } from '@/lib/logger'
import { roundOre, sumOre } from '@/lib/money'
import type { SupabaseClient } from '@supabase/supabase-js'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { applyAccountOverride } from '@/lib/bookkeeping/account-override'
import { ACCOUNT_NUMBER_RE } from '@/lib/invariants/account-number'
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import { applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
@@ -824,6 +826,9 @@ async function categorizeTransactionCore(
// 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,
// Explicit business-side account replacing the category default (v1 REST
// account_override semantics): must exist active in chart_of_accounts.
accountOverride: string | undefined,
userId: string,
companyId: string,
supabase: SupabaseClient,
@@ -960,6 +965,22 @@ async function categorizeTransactionCore(
)
mappingResult = applySettlementAccount(mappingResult, settlementAccount)
// Applied at staging so the agent gets the tight rejection (unknown or
// inactive account) BEFORE anything is queued for approval, and the staged
// preview shows the account that will actually be posted. The commit path
// (categorizeMatchedTransaction) re-validates independently.
if (accountOverride) {
if (!isBusiness) {
throw new Error('account_override kan inte kombineras med category "private".')
}
mappingResult = await applyAccountOverride(
supabase, companyId, accountOverride, transaction.amount, mappingResult,
// Explicit VAT intent: a stated treatment or an underlag vat_amount.
// Without it the override books gross (see applyAccountOverride).
vatTreatment != null || vatAmount != null,
)
}
if (!mappingResult.debit_account || !mappingResult.credit_account) {
throw new Error(
`No account mapping for category "${category}" with entity type "${entityType}". ` +
@@ -4172,7 +4193,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_categorize_transaction',
title: 'Categorize Bank Transaction',
description: 'Categorize a bank transaction. Stages the verifikat: cost line NET of moms, bank line gross; dimensions bag tags the cost line. vat_amount overrides computed moms; reverse_charge rejected when the underlag shows seller VAT. Commit via gnubok_approve_pending_operation.',
description: 'Categorize a bank transaction. Stages the verifikat: cost line NET of moms, bank line gross; dimensions bag tags the cost line. account_override books the business side on any active kontoplan account (custom incl.). Commit via gnubok_approve_pending_operation.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -4181,6 +4202,7 @@ export const tools: McpTool[] = [
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".' },
account_override: { type: 'string', pattern: '^\\d{4}$', description: 'Books the business side (debit when money goes out, credit when money comes in) on this kontoplan account instead of the category default: the ONLY way to reach company-custom accounts (e.g. VMB). Must exist and be active (gnubok_list_accounts; create via gnubok_create_account). VMB purchases/sales carry no deductible moms: use vat_treatment "exempt". Without an explicit vat_treatment (or vat_amount) the override books GROSS with no auto-VAT line: a moms leg is never guessed onto a custom account. Class-2 overrides outside 2610-2649 always drop auto-VAT. Not valid with category "private". State the actual affärshändelse in notes (BFL 5 kap).' },
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.' },
dimensions: {
type: 'object',
@@ -4208,12 +4230,20 @@ export const tools: McpTool[] = [
// categorization preview runs. Resolution happens right before staging.
const inputDimensions = parseDimensionsArg(args.dimensions, 'dimensions')
// Runtime guard (hosts don't always enforce inputSchema patterns).
const accountOverride =
args.account_override === undefined ? undefined : String(args.account_override).trim()
if (accountOverride !== undefined && !ACCOUNT_NUMBER_RE.test(accountOverride)) {
throw new Error('account_override must be exactly 4 digits, e.g. "4020".')
}
// 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,
accountOverride,
userId,
companyId,
supabase,
@@ -4296,6 +4326,7 @@ export const tools: McpTool[] = [
category: args.category,
vat_treatment: args.vat_treatment || null,
vat_amount: vatAmount ?? null,
account_override: accountOverride ?? null,
notes: typeof args.notes === 'string' && args.notes.trim().length > 0
? (args.notes as string).trim()
: null,
@@ -4305,6 +4336,7 @@ export const tools: McpTool[] = [
{
debit_account: result.debit_account,
credit_account: result.credit_account,
...(accountOverride ? { account_override: accountOverride } : {}),
amount: result.amount,
currency: result.currency,
// Exact journal lines the approval will post (net cost line, VAT
@@ -6457,7 +6489,7 @@ export const tools: McpTool[] = [
{ ...params, source: ref ? 'bas_2026' : 'custom' },
actor,
{
description: 'Once approved, the account is active and can carry voucher lines via gnubok_create_voucher or gnubok_categorize_transaction.',
description: 'Once approved, the account is active and bookable via gnubok_create_voucher, gnubok_bulk_book_transactions, or gnubok_categorize_transaction with account_override.',
tool: 'gnubok_list_accounts',
},
{
@@ -14542,9 +14574,13 @@ export const tools: McpTool[] = [
// Resolve account names for the preview so the approver reads
// "1010 Balanserade utgifter / 2440 Leverantörsskulder" rather than
// bare numbers. Also gate: refuse to stage when any line references an
// unknown or inactive account so the approver isn't shown a voucher
// that would fail at commit time anyway.
// bare numbers. Also gate: refuse to stage when a line references an
// account the ENGINE could not resolve either (same semantics as
// findUnresolvableAccounts): a BAS 2026 number merely absent from the
// chart passes, because createDraftEntry seeds it at commit; only
// numbers outside both the chart and the BAS catalog, plus rows a user
// deliberately deactivated (the backfill never resurrects those), are
// rejected here.
const accountNumbers = [...new Set(lines.map((l) => l.account_number))]
const { data: accounts } = await supabase
.from('chart_of_accounts')
@@ -14559,26 +14595,33 @@ export const tools: McpTool[] = [
})
}
const unknownAccounts = accountNumbers.filter((n) => !accountInfo.has(n))
const unseedableAccounts = unknownAccounts.filter((n) => !getBASReference(n))
const seedableAccounts = unknownAccounts.filter((n) => Boolean(getBASReference(n)))
const inactiveAccounts = accountNumbers.filter(
(n) => accountInfo.has(n) && !accountInfo.get(n)!.active,
)
if (unknownAccounts.length > 0 || inactiveAccounts.length > 0) {
if (unseedableAccounts.length > 0 || inactiveAccounts.length > 0) {
const parts: string[] = []
if (unknownAccounts.length > 0) {
parts.push(`saknas i kontoplanen: ${unknownAccounts.join(', ')}`)
if (unseedableAccounts.length > 0) {
parts.push(`saknas i kontoplanen och finns inte i BAS 2026: ${unseedableAccounts.join(', ')}`)
}
if (inactiveAccounts.length > 0) {
parts.push(`inaktiva: ${inactiveAccounts.join(', ')}`)
}
throw new Error(
`Kan inte skapa verifikation. Konton ${parts.join('; ')}. ` +
'Aktivera dem i kontoplanen eller välj andra konton.'
'Skapa kontot med gnubok_create_account, aktivera det med gnubok_update_account, eller välj andra konton.'
)
}
const previewLines = lines.map((l) => ({
account_number: l.account_number,
account_name: accountInfo.get(l.account_number)?.name ?? null,
// Fallback to the BAS catalog name for a seedable account that is not
// in the chart yet, so the approver still reads a named line.
account_name:
accountInfo.get(l.account_number)?.name ??
getBASReference(l.account_number)?.account_name ??
null,
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
line_description: l.line_description ?? null,
@@ -14648,6 +14691,9 @@ export const tools: McpTool[] = [
total_credit: balance.totalCredit,
line_count: lines.length,
lines: previewLines,
// BAS accounts not yet in the chart: the engine activates them at
// commit. Surfaced so the approver sees the side-effect up front.
...(seedableAccounts.length > 0 ? { will_activate_accounts: seedableAccounts } : {}),
// Echoed for every non-exact dimension resolution (resolve-don't-
// select) so the agent can verify what a name attached to.
...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}),
@@ -0,0 +1,162 @@
/**
* applyAccountOverride: the shared override semantics behind the v1 REST
* account_override and the MCP gnubok_categorize_transaction parameter.
* The override replaces the business side of a category-derived mapping;
* these tests pin side selection, chart validation, the class-2 VAT drop
* (with the 2610-2649 moms-line exception), and the degenerate same-account
* guard.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
import { applyAccountOverride } from '../account-override'
import { buildMappingResultFromCategory } from '../category-mapping'
import { buildTransactionEntryLines } from '../transaction-entries'
import type { MappingResult, Transaction } from '@/types'
const mapping = (over: Partial<MappingResult> = {}): MappingResult =>
({
rule: null,
debit_account: '6991',
credit_account: '1930',
risk_level: 'LOW',
confidence: 1.0,
requires_review: false,
default_private: false,
vat_lines: [
{ account_number: '2641', debit_amount: 100, credit_amount: 0, description: 'Ingående moms 25%' },
],
description: 'Övrig kostnad: test',
...over,
}) as MappingResult
const chartRow = (over: Record<string, unknown> = {}) => ({
account_number: '4020',
account_class: 4,
is_active: true,
...over,
})
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('applyAccountOverride', () => {
it('replaces the DEBIT side when money goes out (amount < 0)', async () => {
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: chartRow() })
const result = await applyAccountOverride(supabase as never, 'company-1', '4020', -479, mapping(), true)
expect(result.debit_account).toBe('4020')
expect(result.credit_account).toBe('1930')
expect(result.vat_lines).toHaveLength(1)
})
it('replaces the CREDIT side when money comes in (amount > 0)', async () => {
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: chartRow({ account_number: '3021', account_class: 3 }) })
const result = await applyAccountOverride(
supabase as never, 'company-1', '3021', 479,
mapping({ debit_account: '1930', credit_account: '3001' }), true,
)
expect(result.debit_account).toBe('1930')
expect(result.credit_account).toBe('3021')
})
it('throws with an actionable message when the account is not in the chart', async () => {
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: null })
await expect(
applyAccountOverride(supabase as never, 'company-1', '4020', -479, mapping(), true),
).rejects.toThrow(/finns inte i kontoplanen/)
})
it('throws with an activation hint when the account exists but is inactive', async () => {
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: chartRow({ is_active: false }) })
await expect(
applyAccountOverride(supabase as never, 'company-1', '4020', -479, mapping(), true),
).rejects.toThrow(/inaktivt/)
})
it('drops auto-VAT lines for a class-2 override outside the moms-line range', async () => {
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: chartRow({ account_number: '2894', account_class: 2 }) })
const result = await applyAccountOverride(supabase as never, 'company-1', '2894', -479, mapping(), true)
expect(result.debit_account).toBe('2894')
expect(result.vat_lines).toEqual([])
})
it('keeps auto-VAT lines for a class-2 override inside 2610-2649 (moms-line accounts)', async () => {
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: chartRow({ account_number: '2641', account_class: 2 }) })
const result = await applyAccountOverride(supabase as never, 'company-1', '2641', -479, mapping(), true)
expect(result.debit_account).toBe('2641')
expect(result.vat_lines).toHaveLength(1)
})
it('drops auto-VAT for ANY override without explicit VAT intent (VMB class-3/4 hole)', async () => {
// Swedish compliance review finding: VMB accounts live in class 3/4, so
// the class-2 drop alone let a forgotten vat_treatment attach the
// category-default standard_25 moms leg to a margin-scheme account: an
// ingående-moms deduction the caller never asked for (ML 2023:200).
// Without explicit VAT intent the override must book gross: forgetting
// the flag under-deducts (lawful), never over-deducts.
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: chartRow() }) // 4020, class 4
const result = await applyAccountOverride(supabase as never, 'company-1', '4020', -479, mapping(), false)
expect(result.debit_account).toBe('4020')
expect(result.vat_lines).toEqual([])
})
it('keeps the entry balanced at GROSS when a class-2 override clears the VAT lines', async () => {
// The Swedish compliance review asked for this invariant explicitly: the
// business-line amount is derived from vat_lines inside
// buildTransactionEntryLines, so clearing them books gross, never an
// unbalanced net + missing VAT leg (BFL 5 kap balanced-entry requirement).
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: chartRow({ account_number: '2894', account_class: 2 }) })
const tx = {
id: 'tx-1',
company_id: 'company-1',
date: '2026-07-10',
amount: -479,
currency: 'SEK',
description: 'Second hand',
} as Transaction
let mr = buildMappingResultFromCategory('expense_other', tx, true, 'aktiebolag', 'standard_25')
expect(mr.vat_lines).toHaveLength(1)
mr = await applyAccountOverride(supabase as never, 'company-1', '2894', tx.amount, mr, true)
const lines = buildTransactionEntryLines(tx, mr)
const totalDebit = lines.reduce((s, l) => s + (l.debit_amount ?? 0), 0)
const totalCredit = lines.reduce((s, l) => s + (l.credit_amount ?? 0), 0)
expect(totalDebit).toBe(479)
expect(totalCredit).toBe(479)
expect(lines.find((l) => l.account_number === '2894')?.debit_amount).toBe(479)
expect(lines.some((l) => l.account_number === '2641')).toBe(false)
})
it('refuses a degenerate entry where both sides land on the same account', async () => {
const { supabase, mockResult } = createMockSupabase()
mockResult({ data: chartRow({ account_number: '1930', account_class: 1 }) })
await expect(
applyAccountOverride(supabase as never, 'company-1', '1930', -479, mapping(), true),
).rejects.toThrow(/samma konto/)
})
})
+86
View File
@@ -0,0 +1,86 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { MappingResult } from '@/types'
/**
* Apply an explicit account override to a category-derived MappingResult.
*
* Same semantics as the v1 REST categorize route (app/api/v1/.../categorize):
* the override replaces the business side of the mapping (debit when money
* goes out, credit when money comes in) AFTER the settlement account has been
* applied, so callers can book on company-custom accounts (e.g. VMB accounts)
* that the fixed category → account maps cannot reach.
*
* The account must exist AND be active in the company's chart_of_accounts.
* Unlike voucher lines, an override is never BAS-backfilled: the category
* mapping's own account is the safe default when the override is wrong, so an
* unknown number is a caller error, not a seeding opportunity.
*
* VAT lines survive an override only when the caller stated its VAT intent
* explicitly (`vatExplicit`: a vat_treatment or vat_amount was passed).
* Without it the override books GROSS with no auto-VAT line: the category
* default (standard_25) was derived for the category's default account, and
* carrying it onto an arbitrary override account fabricates a moms deduction
* the caller never asked for. The flagship case is margin-scheme (VMB)
* accounts in class 3/4, where input VAT is not deductible at all
* (ML 2023:200): forgetting the treatment must under-deduct, never
* over-deduct. Overrides onto a balance-sheet class 2 account drop the
* auto-VAT lines even when explicit, EXCEPT the moms-line range 2610-2649
* where posting VAT is the point (2650 momsredovisningskonto and 2690
* diverse are class 2 but not moms-line accounts; auto-VAT there would
* double-post).
*
* Throws on unknown/inactive account or a degenerate same-account entry; the
* message is Swedish and actionable for both the agent and the approval UI.
*/
export async function applyAccountOverride(
supabase: SupabaseClient,
companyId: string,
accountOverride: string,
transactionAmount: number,
mappingResult: MappingResult,
vatExplicit: boolean,
): Promise<MappingResult> {
const { data: account, error } = await supabase
.from('chart_of_accounts')
.select('account_number, account_class, is_active')
.eq('company_id', companyId)
.eq('account_number', accountOverride)
.maybeSingle()
if (error) {
throw new Error(`Database error: ${error.message}`)
}
if (!account) {
throw new Error(
`Konto ${accountOverride} finns inte i kontoplanen: account_override kräver ett befintligt aktivt konto. ` +
'Skapa det först (gnubok_create_account) eller välj ett annat konto.',
)
}
if (!account.is_active) {
throw new Error(
`Konto ${accountOverride} är inaktivt i kontoplanen. ` +
'Aktivera det först (gnubok_update_account med is_active=true) eller välj ett annat konto.',
)
}
if (transactionAmount < 0) {
mappingResult.debit_account = accountOverride
} else {
mappingResult.credit_account = accountOverride
}
if (mappingResult.debit_account === mappingResult.credit_account) {
throw new Error(
`account_override ${accountOverride} är samma konto som motkontot: ` +
'verifikatet skulle debitera och kreditera samma konto. Välj ett annat konto.',
)
}
const overrideNum = parseInt(accountOverride, 10)
const isMomsLineAccount = overrideNum >= 2610 && overrideNum <= 2649
if (!vatExplicit || (account.account_class === 2 && !isMomsLineAccount)) {
mappingResult.vat_lines = []
}
return mappingResult
}
@@ -1364,3 +1364,70 @@ describe('commitPendingOperation: categorize_transaction: dimensions propagation
expect(opts.dimensions).toBeUndefined()
})
})
// ─── categorize_transaction: account_override tamper gate ───────────────────
describe('commitPendingOperation: categorize_transaction account_override', () => {
it('rejects loudly when a stored override is present but malformed (tamper/drift)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's rejected update
const op = makePendingOp({
operation_type: 'categorize_transaction',
params: { transaction_id: 'tx-1', category: 'expense_other', account_override: '40a0' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
// Never degrade to the category default: the approver approved a preview
// showing the override account. 400 lands as 'failed' (the dispatcher
// reserves auto-reject for 404/409); the point is that the core is never
// called and the error names the override.
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toContain('account_override')
expect(categorizeMatchedTransaction).not.toHaveBeenCalled()
})
it('threads a valid stored override into the core opts', async () => {
vi.mocked(categorizeMatchedTransaction).mockResolvedValueOnce({
data: { journal_entry_id: 'je-1' },
})
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's commit update
const op = makePendingOp({
operation_type: 'categorize_transaction',
params: { transaction_id: 'tx-1', category: 'expense_other', account_override: '4020' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
const opts = vi.mocked(categorizeMatchedTransaction).mock.calls[0][4]
expect(opts.accountOverride).toBe('4020')
})
it('passes undefined when the staged params carry account_override: null (no override)', async () => {
vi.mocked(categorizeMatchedTransaction).mockResolvedValueOnce({
data: { journal_entry_id: 'je-1' },
})
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's commit update
const op = makePendingOp({
operation_type: 'categorize_transaction',
params: { transaction_id: 'tx-1', category: 'expense_other', account_override: null },
})
await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
const opts = vi.mocked(categorizeMatchedTransaction).mock.calls[0][4]
expect(opts.accountOverride).toBeUndefined()
})
})
+20
View File
@@ -43,6 +43,7 @@ import {
resolveUnsettledStatus,
} from '@/lib/supplier-invoices/lifecycle'
import { coerceDimensionsBag } from '@/lib/bookkeeping/dimension-resolver'
import { ACCOUNT_NUMBER_RE } from '@/lib/invariants/account-number'
import { isSlpPensionAccount } from '@/lib/bookkeeping/slp-lines'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { runWithActor } from '@/lib/bookkeeping/actor-context-node'
@@ -306,6 +307,23 @@ async function commitCategorizeTransaction(
// propagation all live in the shared core (lib/transactions/categorize-core.ts)
// so the bulk-book-inbox executor and the Underlag "Bokför valda" route reuse
// exactly this logic.
// Tamper/drift gate for the explicit business-side account: a PRESENT but
// malformed account_override must fail loudly, never degrade to the
// category default. The approver approved a preview showing the override
// account, so posting anything else would diverge from what was approved.
const rawAccountOverride = params.account_override
if (
rawAccountOverride != null &&
!(typeof rawAccountOverride === 'string' && ACCOUNT_NUMBER_RE.test(rawAccountOverride))
) {
return {
error:
'Ogiltigt account_override i den stagade operationen (förväntade 4 siffror). ' +
'Avvisa operationen och stagea om kategoriseringen.',
status: 400,
}
}
return categorizeMatchedTransaction(supabase, userId, companyId, txId, {
category,
vatTreatment,
@@ -314,6 +332,8 @@ async function commitCategorizeTransaction(
allowDuplicate: params.allow_duplicate === true,
// Dimensions PR7: resolved at staging; coerce is the drift/tamper gate.
dimensions: coerceDimensionsBag(params.dimensions),
// Validated against the chart both at staging and inside the core at commit.
accountOverride: (rawAccountOverride as string | null | undefined) ?? undefined,
})
}
@@ -0,0 +1,141 @@
/**
* categorizeMatchedTransaction: the accountOverride commit path.
*
* The MCP staging tool validates the override once, but the account can be
* deactivated between staging and the user's approval, so the core re-applies
* and re-validates independently. These tests pin that the posted mapping
* carries the override account, and that a stale override degrades to a
* structured 400 (never a posted entry on a dead account).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const mockCreateJE = vi.fn()
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
createTransactionJournalEntry: (...args: unknown[]) => mockCreateJE(...args),
}))
vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({
detectBookingDuplicate: vi.fn().mockResolvedValue(null),
}))
vi.mock('@/lib/transactions/inbox-underlag', () => ({
propagateUnderlagForBookedTransaction: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/transactions/link-journal-entry', () => ({
hasLiveJournalEntryLink: vi.fn().mockResolvedValue(false),
}))
vi.mock('@/lib/processing-history/append', () => ({
appendProcessingHistory: vi.fn().mockResolvedValue(undefined),
}))
import { categorizeMatchedTransaction } from '../categorize-core'
const TX_ID = '00000000-0000-4000-8000-0000000000cc'
const txRow = (over: Record<string, unknown> = {}) => ({
id: TX_ID,
company_id: 'company-1',
date: '2026-07-10',
amount: -479,
currency: 'SEK',
amount_sek: -479,
exchange_rate: 1,
description: 'SECOND HAND BUTIK',
merchant_name: null,
cash_account_id: null,
document_id: null,
journal_entry_id: null,
...over,
})
const settingsRow = { entity_type: 'aktiebolag', fiscal_year_start_month: 1 }
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
mockCreateJE.mockResolvedValue({ id: 'je-override-1' })
})
describe('categorizeMatchedTransaction: accountOverride', () => {
it('posts the entry with the override on the business side', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() }) // transactions select
enqueue({ data: settingsRow }) // company_settings
enqueue({ data: { account_number: '4020', account_class: 4, is_active: true } }) // override chart hit
enqueue({ data: [{ id: 'fp-1' }] }) // ensureFiscalPeriod: open period exists
enqueue({ data: null }) // transactions update
const result = await categorizeMatchedTransaction(
supabase as never, 'user-1', 'company-1', TX_ID,
{ category: 'expense_other', vatTreatment: 'exempt', accountOverride: '4020' },
)
expect(result.error).toBeUndefined()
expect(result.data?.journal_entry_id).toBe('je-override-1')
// 5th arg of createTransactionJournalEntry is the mapping result.
const mappingArg = mockCreateJE.mock.calls[0][4] as {
debit_account: string
credit_account: string
}
expect(mappingArg.debit_account).toBe('4020')
expect(mappingArg.credit_account).toBe('1930')
})
it('books GROSS with no auto-VAT line when the override has no explicit VAT intent', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() })
enqueue({ data: settingsRow })
enqueue({ data: { account_number: '4020', account_class: 4, is_active: true } })
enqueue({ data: [{ id: 'fp-1' }] }) // ensureFiscalPeriod
enqueue({ data: null }) // transactions update
const result = await categorizeMatchedTransaction(
supabase as never, 'user-1', 'company-1', TX_ID,
// No vatTreatment and no vatAmount: the category default standard_25
// must NOT ride along onto the custom account.
{ category: 'expense_other', accountOverride: '4020' },
)
expect(result.error).toBeUndefined()
const mappingArg = mockCreateJE.mock.calls[0][4] as {
debit_account: string
vat_lines: unknown[]
}
expect(mappingArg.debit_account).toBe('4020')
expect(mappingArg.vat_lines).toEqual([])
})
it('returns 400 (never posts) when the override was deactivated after staging', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() })
enqueue({ data: settingsRow })
enqueue({ data: { account_number: '4020', account_class: 4, is_active: false } })
const result = await categorizeMatchedTransaction(
supabase as never, 'user-1', 'company-1', TX_ID,
{ category: 'expense_other', vatTreatment: 'exempt', accountOverride: '4020' },
)
expect(result.status).toBe(400)
expect(result.error).toMatch(/inaktivt/)
expect(mockCreateJE).not.toHaveBeenCalled()
})
it('returns 400 when accountOverride is combined with category "private"', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: txRow() })
enqueue({ data: settingsRow })
const result = await categorizeMatchedTransaction(
supabase as never, 'user-1', 'company-1', TX_ID,
{ category: 'private', accountOverride: '4020' },
)
expect(result.status).toBe(400)
expect(result.error).toMatch(/private/)
expect(mockCreateJE).not.toHaveBeenCalled()
})
})
@@ -82,6 +82,87 @@ describe('buildMerchantHistory / merchantHistoryFor', () => {
})
})
describe('getSuggestedCategories: mapping rules on custom accounts', () => {
const rule = (over: Record<string, unknown> = {}) =>
({
id: 'rule-1',
company_id: 'company-1',
is_active: true,
merchant_pattern: 'Myrorna',
description_pattern: null,
mcc_codes: null,
debit_account: '4020',
credit_account: '1930',
default_private: false,
confidence_score: 0.9,
priority: 10,
source: 'user',
user_description: null,
...over,
// MappingRule carries many more columns; only the fields the suggestion
// engine reads are modelled here.
}) as never
it('surfaces a rule booking on a custom account instead of silently dropping it', async () => {
const result = getSuggestedCategories(
tx({ merchant_name: 'Myrorna', description: 'MYRORNA BUTIK 1' }),
[rule()],
{},
)
// 4020 is outside the fixed category maps: the old reverse-lookup
// returned null and the rule vanished with no diagnostic.
expect(result.length).toBe(1)
expect(result[0]).toMatchObject({
category: 'expense_other',
account: '4020',
source: 'mapping_rule',
confidence: 0.9,
})
expect(result[0].match_reason).toMatch(/konto 4020/)
})
it('surfaces an unmapped INCOME account with the custom-account diagnostic', async () => {
const result = getSuggestedCategories(
tx({ amount: 100, merchant_name: 'Myrorna', description: 'SWISH MYRORNA' }),
[rule({ debit_account: '3020' })],
{},
)
expect(result.length).toBe(1)
expect(result[0]).toMatchObject({
category: 'income_other',
account: '3020',
source: 'mapping_rule',
})
expect(result[0].match_reason).toMatch(/konto 3020/)
})
it('accumulates the user_description reason with the custom-account reason', async () => {
const result = getSuggestedCategories(
tx({ merchant_name: 'Myrorna', description: 'MYRORNA BUTIK 1' }),
[rule({ source: 'user_description', user_description: 'Second hand-inköp till butiken' })],
{},
)
expect(result.length).toBe(1)
expect(result[0].match_reason).toMatch(/Matchad på din beskrivning: Second hand-inköp till butiken/)
expect(result[0].match_reason).toMatch(/konto 4020/)
})
it('keeps the exact category for rules on accounts inside the fixed maps', async () => {
const result = getSuggestedCategories(
tx({ merchant_name: 'Anthropic', description: 'ANTHROPIC* CLAUDE' }),
[rule({ merchant_pattern: 'Anthropic', debit_account: '5420' })],
{},
)
expect(result.length).toBe(1)
expect(result[0]).toMatchObject({
category: 'expense_software',
account: '5420',
source: 'mapping_rule',
})
expect(result[0].match_reason).toBeUndefined()
})
})
describe('getSuggestedCategories: counterparty history', () => {
it('returns an empty list (not a fabricated spread) when nothing matches', () => {
const result = getSuggestedCategories(
+28 -1
View File
@@ -24,6 +24,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { applyAccountOverride } from '@/lib/bookkeeping/account-override'
import { applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
@@ -72,6 +73,14 @@ export interface CategorizeMatchedTransactionOpts {
* registry at staging time (MCP) or picked in the UI.
*/
dimensions?: Record<string, string>
/**
* Explicit business-side account (e.g. a company-custom VMB account) that
* replaces the category's debit (money out) or credit (money in) account,
* with the same semantics as the v1 REST route's account_override: must be
* present and active in chart_of_accounts, never combined with category
* 'private'. See lib/bookkeeping/account-override.ts.
*/
accountOverride?: string
}
// ── Helper: duplicate-guard claim text ───────────────────────────────
@@ -211,7 +220,7 @@ export async function categorizeMatchedTransaction(
*/
exclude?: BookingDuplicateExclusions,
): Promise<CategorizeCoreResult> {
const { category, vatTreatment, vatAmount, notes, allowDuplicate, dimensions } = opts
const { category, vatTreatment, vatAmount, notes, allowDuplicate, dimensions, accountOverride } = opts
const { data: transaction, error: fetchError } = await supabase
.from('transactions').select('*').eq('id', txId).eq('company_id', companyId).single()
@@ -342,6 +351,24 @@ export async function categorizeMatchedTransaction(
log,
)
mappingResult = applySettlementAccount(mappingResult, settlementAccount)
// Re-validated here (not only at staging): the account can be deactivated
// between MCP staging and the user's approval, and the posted entry must
// never land on an account the chart no longer offers.
if (accountOverride) {
if (!isBusiness) {
return { error: 'account_override kan inte kombineras med category "private".', status: 400 }
}
try {
mappingResult = await applyAccountOverride(
supabase, companyId, accountOverride, transaction.amount, mappingResult,
// Explicit VAT intent: a stated treatment or an underlag vat_amount.
// Without it the override books gross (see applyAccountOverride).
vatTreatment != null || vatAmount != null,
)
} catch (err) {
return { error: err instanceof Error ? err.message : 'account_override failed', status: 400 }
}
}
// Dimensions PR7: tag the business lines of the generated verifikat.
if (dimensions && Object.keys(dimensions).length > 0) {
mappingResult.dimensions = dimensions
+21 -6
View File
@@ -147,9 +147,14 @@ export function getSuggestedCategories(
}
if (matches && rule.debit_account && !rule.default_private) {
// Reverse-lookup: find category from debit account
const category = accountToCategory(rule.debit_account, transaction.amount)
if (category && !seen.has(category)) {
// Reverse-lookup: find category from debit account. A rule booking on
// an account outside the fixed maps (company-custom accounts like VMB)
// must still surface: the account itself is the signal, and callers
// reach it via account_override. Fall back to the direction's generic
// category instead of silently dropping the rule.
const mapped = accountToCategory(rule.debit_account, transaction.amount)
const category = mapped ?? (transaction.amount < 0 ? 'expense_other' : 'income_other')
if (!seen.has(category)) {
seen.add(category)
const suggestion: SuggestedCategory = {
category: category as TransactionCategory,
@@ -158,8 +163,15 @@ export function getSuggestedCategories(
confidence: rule.confidence_score || 0.8,
source: 'mapping_rule',
}
const reasons: string[] = []
if (rule.source === 'user_description' && rule.user_description) {
suggestion.match_reason = `Matchad på din beskrivning: ${rule.user_description}`
reasons.push(`Matchad på din beskrivning: ${rule.user_description}`)
}
if (!mapped) {
reasons.push(`Regeln bokför på konto ${rule.debit_account} (utanför standardkategorierna)`)
}
if (reasons.length > 0) {
suggestion.match_reason = reasons.join('. ')
}
suggestions.push(suggestion)
}
@@ -215,12 +227,15 @@ export function getSuggestedCategories(
*/
function accountToCategory(account: string, amount: number): string | null {
if (amount > 0) {
// Income
// Income. Unknown accounts return null (not a blanket 'income_other') so
// the caller can tell a mapped account from a custom one and attach the
// custom-account diagnostic; the caller's fallback still lands on
// income_other, so the surfaced category is unchanged.
const incomeMap: Record<string, string> = {
'3001': 'income_services',
'3900': 'income_other',
}
return incomeMap[account] || 'income_other'
return incomeMap[account] || null
}
// Expense
+8
View File
@@ -109,6 +109,14 @@ const nextConfig: NextConfig = {
destination: '/kpi',
permanent: true,
},
// The kontoplan lived as a tab on /bookkeeping until 2026-07-01 (#850);
// old bookmarks and stale links still carry ?tab=accounts.
{
source: '/bookkeeping',
has: [{ type: 'query', key: 'tab', value: 'accounts' }],
destination: '/chart-of-accounts',
permanent: false,
},
// Docs canonicalised to docs.gnubok.se. Every `docs_url` field on the
// v1 error envelope still points at this host; the 308 forwards both
// humans and agents to the docs subdomain without us needing to