Files
accounted/lib/bookkeeping/account-override.ts
T
MattssonandClaude Fable 5 fbe4e18730 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>
2026-08-14 01:24:48 +02:00

87 lines
3.5 KiB
TypeScript

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
}