Files
accounted/lib/bookkeeping/counterparty-templates.ts
T
Jakob WennbergandClaude Opus 4.8 e5f1d7a916 fix(reports): count reversed entries in bank/supplier/AR reconciliation (#734)
A reversed (storno-corrected) journal entry must be summed together with
its storno + correction, exactly as the trial balance and balance sheet
already do (.in('status', ['posted','reversed'])). The reconciliation
paths used posted-only and manufactured phantom differences.

- bank-reconciliation getReconciliationStatus: gl_1930_balance now counts
  posted+reversed (so it equals the balansräkning for the account). Dropped
  the correction_adjustment subtraction and the reversed-linked-tx drop — a
  corrected/amount-corrected bank receipt now reconciles. The prior model
  broke once correctEntry began re-pointing the bank transaction to the live
  correction (the two changes were mutually inconsistent and produced a
  difference equal to the corrected amount).
- supplier-reconciliation (2440) and ar-reconciliation (1510/1513): same
  posted -> posted+reversed fix. Removes the false "Ej avstämd" gap a fully
  paid, fully corrected company shows — the books net to 0 over posted+reversed
  while a posted-only query double-counted the payment legs.

Also in this change:
- MCP gnubok_get_reconciliation_status: add account_number param (was hardcoded
  to 1930; the lib already supported per-account reconciliation).
- counterparty-templates normalizeCounterpartyName: strip trailing month /
  personal-initials tokens so "ngrok JW" / "Ngrok Mars" learn as one merchant.
- pending-operation reject 409 (route + MCP tool): clarify a resolved op was
  approved explicitly (no auto-commit path exists) instead of a bare
  "already committed".
- create_voucher: normalize description with String().trim() for consistency
  with line_description.
- migration 20260625120000: backfill stripped diacritics on ~830 companies'
  seeded chart-of-accounts names (Foretagskonto -> Företagskonto, etc.); the
  seed function was fixed for new companies in 20260516130000 but never
  backfilled. UPDATE-only and idempotent. Already applied to production.

Tests: 899 passing across the touched suites; lint + typecheck clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:46:41 +02:00

991 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { SupabaseClient } from '@supabase/supabase-js'
import {
normalizeMerchantName,
levenshteinDistance,
} from '@/lib/documents/core-receipt-matcher'
import {
generateInputVatLine,
generateReverseChargeLines,
getVatRate,
} from './vat-entries'
import type {
CategorizationTemplate,
CategorizationTemplateSource,
EntityType,
LinePatternEntry,
MappingResult,
Transaction,
VatJournalLine,
VatTreatment,
} from '@/types'
import type { SIEVoucher } from '@/lib/import/types'
// ── Normalization ──────────────────────────────────────────────
/**
* Month tokens (Swedish + English, abbreviated and full) that show up as a
* trailing period label on a bank-feed description ("Ngrok Mars", "Spotify
* januari") rather than as part of the merchant's identity.
*/
const TRAILING_MONTH_TOKENS = new Set([
'jan', 'feb', 'mar', 'apr', 'maj', 'may', 'jun', 'jul', 'aug', 'sep', 'sept',
'okt', 'oct', 'nov', 'dec',
'januari', 'februari', 'mars', 'april', 'juni', 'juli', 'augusti',
'september', 'oktober', 'november', 'december',
])
/**
* Strip trailing tokens that label *when/who* rather than *what merchant*:
* a month name, or a 1–2 letter all-caps personal initial ("ngrok JW",
* "ngrok JW", "Ngrok Mars" all describe the same merchant). Without this, one
* merchant splinters into many un-learnable variants and counterparty matching
* never fires (the reported ngrok bug: three prior bookings, zero matches).
*
* Conservative by design: only acts on a TRAILING token, only on 1–2 char
* all-caps initials (so 3-letter brands like SEB/ICA and any lowercased word
* survive), and always keeps at least one core token (never strips to empty).
*/
function stripTrailingNoiseTokens(s: string): string {
const tokens = s.trim().split(/\s+/).filter(Boolean)
while (tokens.length > 1) {
const last = tokens[tokens.length - 1]
const isMonth = TRAILING_MONTH_TOKENS.has(last.toLowerCase())
// Personal initials: 1–2 letters, all-caps in the ORIGINAL casing (run
// before normalizeMerchantName lowercases everything).
const isInitials = /^[A-ZÅÄÖ]{1,2}$/.test(last)
if (!isMonth && !isInitials) break
tokens.pop()
}
return tokens.join(' ')
}
/**
* Normalize a transaction description to a canonical counterparty name.
*
* Strips bank transfer prefixes, trailing dates, invoice references, trailing
* digit sequences, and trailing period/initials tokens, then delegates to
* normalizeMerchantName() for Swedish company suffix removal and lowercasing.
*/
export function normalizeCounterpartyName(raw: string): string {
const cleaned = raw
// Strip common bank transfer prefixes
.replace(/^(BANKGIRO|SWISH|KORTKÖP|KORT\s*KÖP|PG|BG|AUTOGIRO|PLUSGIRO)\s*/i, '')
// Strip dates (20240615, 2024-06-15, 24-06-15)
.replace(/\b\d{2,4}[-/]?\d{2}[-/]?\d{2}\b/g, '')
// Strip invoice/reference numbers (F2024-001, #12345, INV-123)
.replace(/\b[F#]?\d{4,}\S*/gi, '')
.replace(/\bINV[-]?\d+/gi, '')
// Strip trailing sequences of 4+ digits (card numbers, transaction refs)
.replace(/\s+\d{4,}\s*$/g, '')
.trim()
// Drop trailing month/initials tokens before merchant-name normalization so
// "ngrok JW" and "Ngrok Mars" collapse to the same canonical "ngrok".
return normalizeMerchantName(stripTrailingNoiseTokens(cleaned))
}
// ── Confidence ─────────────────────────────────────────────────
// ── Display ───────────────────────────────────────────────────
/** Swedish company suffixes that should be uppercased */
const UPPER_SUFFIXES = new Set(['ab', 'hb', 'kb', 'ek', 'ef', 'uf'])
/**
* Capitalize a normalized counterparty name for display.
* "telia sverige ab" → "Telia Sverige AB"
*/
export function formatCounterpartyName(name: string): string {
return name
.split(' ')
.map(w => UPPER_SUFFIXES.has(w) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
}
/**
* Logarithmic confidence formula.
* Starts low, grows slowly, caps at 0.95. Early corrections are cheap,
* later corrections are appropriately alarming.
*/
export function calculateConfidence(occurrenceCount: number): number {
const raw = 0.3 + Math.log2(occurrenceCount + 1) * 0.15
return Math.round(Math.min(raw, 0.95) * 100) / 100
}
// ── Source Priority ───────────────────────────────────────────
const SOURCE_PRIORITY: Record<CategorizationTemplateSource, number> = {
sni_default: 0,
auto_learned: 1,
sie_import: 2,
user_approved: 3,
// AI-corrected templates carry explicit user validation (they edited the
// AI's proposal, then confirmed "remember this"), so rank equal to
// user_approved. Fresh incoming AI corrections still win over older
// templates of the same rank (>= in resolveSource).
ai_corrected: 3,
}
export function resolveSource(
existing: CategorizationTemplateSource,
incoming: CategorizationTemplateSource
): CategorizationTemplateSource {
return SOURCE_PRIORITY[incoming] >= SOURCE_PRIORITY[existing] ? incoming : existing
}
// ── Counterparty Template ID Convention ──────────────────────
export const COUNTERPARTY_PREFIX = 'counterparty:'
export function isCounterpartyTemplateId(id: string): boolean { return id.startsWith(COUNTERPARTY_PREFIX) }
export function extractCounterpartyId(id: string): string { return id.slice(COUNTERPARTY_PREFIX.length) }
export function toCounterpartyTemplateId(id: string): string { return COUNTERPARTY_PREFIX + id }
// ── VAT Account Mapping ──────────────────────────────────────
const VAT_ACCOUNT_TREATMENT: Record<string, string> = {
'2611': 'standard_25',
'2621': 'reduced_12',
'2631': 'reduced_6',
'2641': 'standard_25',
'2645': 'reverse_charge',
'2614': 'reverse_charge',
'2624': 'reverse_charge',
'2634': 'reverse_charge',
}
// ── Lookup ─────────────────────────────────────────────────────
export interface CounterpartyTemplateMatch {
template: CategorizationTemplate
matchMethod: 'exact_alias' | 'exact_normalized' | 'fuzzy'
confidence: number
}
/**
* Find a counterparty template matching a transaction.
*
* Three-tier matching (delegated to batch version with single-element array):
* 1. Exact alias match
* 2. Exact normalized name match
* 3. Fuzzy Levenshtein — distance ≤2 for short names, ≤3 for long names
*/
export async function findCounterpartyTemplate(
supabase: SupabaseClient,
companyId: string,
transaction: Transaction
): Promise<CounterpartyTemplateMatch | null> {
const results = await findCounterpartyTemplatesBatch(supabase, companyId, [transaction])
return results.get(transaction.id) ?? null
}
/**
* Batch counterparty template matching for multiple transactions.
* One DB query, all matching done in memory.
*/
export async function findCounterpartyTemplatesBatch(
supabase: SupabaseClient,
companyId: string,
transactions: Transaction[]
): Promise<Map<string, CounterpartyTemplateMatch>> {
const result = new Map<string, CounterpartyTemplateMatch>()
const { data: allTemplates } = await supabase
.from('categorization_templates')
.select('*')
.eq('company_id', companyId)
.eq('is_active', true)
if (!allTemplates || allTemplates.length === 0) return result
const templates = allTemplates as CategorizationTemplate[]
// Build alias lookup: lowercase alias → template
const aliasMap = new Map<string, CategorizationTemplate>()
for (const tmpl of templates) {
for (const alias of tmpl.counterparty_aliases || []) {
aliasMap.set(alias, tmpl)
}
}
// Build normalized name lookup
const nameMap = new Map<string, CategorizationTemplate>()
for (const tmpl of templates) {
nameMap.set(tmpl.counterparty_name, tmpl)
}
for (const tx of transactions) {
const rawName = tx.merchant_name || tx.description
if (!rawName) continue
const normalized = normalizeCounterpartyName(rawName)
if (!normalized || normalized.length < 2) continue
// 1. Exact alias match
const aliasMatch = aliasMap.get(rawName.toLowerCase())
if (aliasMatch) {
result.set(tx.id, {
template: aliasMatch,
matchMethod: 'exact_alias',
confidence: Math.min(Number(aliasMatch.confidence) * 1.0, 1),
})
continue
}
// 2. Exact normalized name match
const exactMatch = nameMap.get(normalized)
if (exactMatch) {
result.set(tx.id, {
template: exactMatch,
matchMethod: 'exact_normalized',
confidence: Math.round(Number(exactMatch.confidence) * 0.95 * 100) / 100,
})
continue
}
// 3. Fuzzy Levenshtein match
let bestMatch: CategorizationTemplate | null = null
let bestDistance = Infinity
for (const tmpl of templates) {
const dist = levenshteinDistance(normalized, tmpl.counterparty_name)
const maxAllowed = normalized.length <= 10 ? 2 : 3
if (dist <= maxAllowed && dist < bestDistance) {
bestDistance = dist
bestMatch = tmpl
}
}
if (bestMatch) {
const similarity = 1 - bestDistance / Math.max(normalized.length, bestMatch.counterparty_name.length)
result.set(tx.id, {
template: bestMatch,
matchMethod: 'fuzzy',
confidence: Math.round(Number(bestMatch.confidence) * similarity * 100) / 100,
})
}
}
return result
}
// ── Build MappingResult ────────────────────────────────────────
/**
* Convert a counterparty template match into a MappingResult
* (same shape the mapping engine expects).
*/
export function buildMappingResultFromCounterpartyTemplate(
match: CounterpartyTemplateMatch,
transaction: Transaction,
_entityType: EntityType
): MappingResult {
const tmpl = match.template
// Multi-line pattern path
if (tmpl.line_pattern && tmpl.line_pattern.length > 0) {
return buildMultiLineMappingResult(tmpl, match, transaction)
}
// Legacy single debit/credit path
const absAmount = Math.abs(transaction.amount)
const isExpense = transaction.amount < 0
const vatLines: VatJournalLine[] = []
if (isExpense && tmpl.vat_treatment) {
const vatTreatment = tmpl.vat_treatment as VatTreatment
if (vatTreatment === 'reverse_charge') {
const rcLines = generateReverseChargeLines(absAmount)
for (const rcl of rcLines) {
vatLines.push({
account_number: rcl.account_number,
debit_amount: rcl.debit_amount,
credit_amount: rcl.credit_amount,
description: rcl.line_description || '',
})
}
} else {
const vatRate = getVatRate(vatTreatment)
if (vatRate > 0) {
const vatLine = generateInputVatLine(absAmount, vatRate)
if (vatLine) {
vatLines.push({
account_number: vatLine.account_number,
debit_amount: vatLine.debit_amount,
credit_amount: vatLine.credit_amount,
description: vatLine.line_description || '',
})
}
}
}
}
const privateAccounts = ['2013', '2893']
const isPrivate = privateAccounts.includes(tmpl.debit_account)
return {
rule: null,
debit_account: tmpl.debit_account,
credit_account: tmpl.credit_account,
risk_level: 'NONE',
confidence: match.confidence,
requires_review: false,
default_private: isPrivate,
vat_lines: vatLines,
description: `Motpart: ${tmpl.counterparty_name} (${tmpl.occurrence_count} ggr)`,
}
}
/**
* Build a MappingResult from a multi-line counterparty template pattern.
*
* VAT is computed from rate (exact), business/tax from ratio against non-VAT subtotal.
* Rounding difference goes to 3740 (Öresutjämning).
* Settlement line always equals the exact transaction amount.
*/
function buildMultiLineMappingResult(
tmpl: CategorizationTemplate,
match: CounterpartyTemplateMatch,
transaction: Transaction
): MappingResult {
const pattern = tmpl.line_pattern!
const absAmount = Math.abs(transaction.amount)
const allLines: VatJournalLine[] = []
// 1. Compute VAT lines first (from rate, exact)
let totalVat = 0
for (const entry of pattern) {
if (entry.type === 'vat' && entry.vat_rate) {
const vatAmount = Math.round(absAmount * entry.vat_rate / (1 + entry.vat_rate) * 100) / 100
totalVat += vatAmount
allLines.push({
account_number: entry.account,
debit_amount: entry.side === 'debit' ? vatAmount : 0,
credit_amount: entry.side === 'credit' ? vatAmount : 0,
description: '',
})
}
}
// 2. Compute non-VAT subtotal
const nonVatAmount = Math.round((absAmount - totalVat) * 100) / 100
// 3. Compute business/tax lines from ratios against nonVatAmount
let nonVatAllocated = 0
for (const entry of pattern) {
if ((entry.type === 'business' || entry.type === 'tax') && entry.ratio !== undefined) {
const amount = Math.round(nonVatAmount * entry.ratio * 100) / 100
nonVatAllocated += amount
allLines.push({
account_number: entry.account,
debit_amount: entry.side === 'debit' ? amount : 0,
credit_amount: entry.side === 'credit' ? amount : 0,
description: '',
})
}
}
// 4. Check for rounding difference → 3740
const totalAllocated = Math.round((totalVat + nonVatAllocated) * 100) / 100
const roundingDiff = Math.round((absAmount - totalAllocated) * 100) / 100
if (roundingDiff !== 0) {
// Determine the side for the rounding line (same side as business lines)
const businessSide = pattern.find(e => e.type === 'business')?.side ?? 'credit'
allLines.push({
account_number: '3740',
debit_amount: businessSide === 'debit' ? Math.abs(roundingDiff) : 0,
credit_amount: businessSide === 'credit' ? Math.abs(roundingDiff) : 0,
description: 'Öresutjämning',
})
}
return {
rule: null,
debit_account: tmpl.debit_account,
credit_account: tmpl.credit_account,
risk_level: 'NONE',
confidence: match.confidence,
requires_review: false,
default_private: false,
vat_lines: allLines,
all_lines_complete: true,
description: `Motpart: ${tmpl.counterparty_name} (${tmpl.occurrence_count} ggr)`,
}
}
// ── Feedback / Upsert ──────────────────────────────────────────
export interface TemplateUpsertParams {
counterpartyName: string
aliases: string[]
debitAccount: string
creditAccount: string
vatTreatment: string | null
vatAccount: string | null
category: string | null
occurrenceCount: number
confidence: number
lastSeenDate: string | null
source: CategorizationTemplateSource
linePattern?: LinePatternEntry[] | null
}
/**
* Low-level insert-or-update for a counterparty template.
*
* - existingTemplate undefined → DB lookup by (userId, counterpartyName)
* - existingTemplate null → skip lookup (batch mode: caller knows none exists)
* - existingTemplate object → use directly (batch mode: pre-fetched)
*
* Re-approval: accumulates occurrence_count, recalculates confidence from total.
* Correction: uses params.occurrenceCount/confidence, updates accounts.
* Both paths use resolveSource() so lower-priority sources never overwrite higher.
*/
export async function insertOrUpdateTemplate(
supabase: SupabaseClient,
companyId: string,
params: TemplateUpsertParams,
existingTemplate?: CategorizationTemplate | null
): Promise<void> {
// Resolve existing template
let existing: CategorizationTemplate | null = null
if (existingTemplate === undefined) {
const { data } = await supabase
.from('categorization_templates')
.select('*')
.eq('company_id', companyId)
.eq('counterparty_name', params.counterpartyName)
.maybeSingle()
existing = data as CategorizationTemplate | null
} else {
existing = existingTemplate
}
if (existing) {
const isCorrection =
existing.debit_account !== params.debitAccount ||
existing.credit_account !== params.creditAccount
// Merge aliases (deduplicated)
const mergedAliases = [...(existing.counterparty_aliases || [])]
for (const alias of params.aliases) {
if (!mergedAliases.includes(alias)) {
mergedAliases.push(alias)
}
}
const newSource = resolveSource(existing.source, params.source)
if (isCorrection) {
await supabase
.from('categorization_templates')
.update({
debit_account: params.debitAccount,
credit_account: params.creditAccount,
vat_treatment: params.vatTreatment ?? existing.vat_treatment,
vat_account: params.vatAccount ?? existing.vat_account,
category: params.category || existing.category,
occurrence_count: params.occurrenceCount,
confidence: params.confidence,
last_seen_date: params.lastSeenDate,
source: newSource,
counterparty_aliases: mergedAliases,
line_pattern: params.linePattern !== undefined ? params.linePattern : existing.line_pattern,
})
.eq('id', existing.id)
} else {
// Re-approval: accumulate count, recalculate confidence from total
const newCount = existing.occurrence_count + params.occurrenceCount
const newConfidence = calculateConfidence(newCount)
await supabase
.from('categorization_templates')
.update({
occurrence_count: newCount,
confidence: newConfidence,
last_seen_date: params.lastSeenDate,
source: newSource,
counterparty_aliases: mergedAliases,
category: params.category || existing.category,
...(params.linePattern !== undefined ? { line_pattern: params.linePattern } : {}),
})
.eq('id', existing.id)
}
} else {
await supabase
.from('categorization_templates')
.insert({
company_id: companyId,
counterparty_name: params.counterpartyName,
counterparty_aliases: params.aliases,
debit_account: params.debitAccount,
credit_account: params.creditAccount,
vat_treatment: params.vatTreatment,
vat_account: params.vatAccount,
category: params.category,
line_pattern: params.linePattern ?? null,
occurrence_count: params.occurrenceCount,
confidence: params.confidence,
last_seen_date: params.lastSeenDate,
source: params.source,
})
}
}
/**
* Upsert a counterparty template from a categorization result.
* Thin wrapper around insertOrUpdateTemplate for single-transaction callers.
*/
export async function upsertCounterpartyTemplate(
supabase: SupabaseClient,
companyId: string,
transaction: Transaction,
mappingResult: MappingResult,
source: CategorizationTemplateSource
): Promise<void> {
const rawName = transaction.merchant_name || transaction.description
if (!rawName) return
const normalized = normalizeCounterpartyName(rawName)
if (!normalized || normalized.length < 2) return
const category = transaction.category !== 'uncategorized' ? transaction.category : null
await insertOrUpdateTemplate(supabase, companyId, {
counterpartyName: normalized,
aliases: [rawName.toLowerCase()],
debitAccount: mappingResult.debit_account,
creditAccount: mappingResult.credit_account,
vatTreatment: mappingResult.vat_lines.length > 0
? detectVatTreatment(mappingResult)
: null,
vatAccount: mappingResult.vat_lines[0]?.account_number || null,
category,
occurrenceCount: 1,
confidence: calculateConfidence(1),
lastSeenDate: transaction.date,
source,
})
}
/**
* Detect VAT treatment from a MappingResult's VAT lines.
*/
function detectVatTreatment(result: MappingResult): string | null {
if (result.vat_lines.length === 0) return null
// Check for reverse charge (2645 debit = fiktiv ingående)
const hasReverseCharge = result.vat_lines.some(
(l) => l.account_number === '2645'
)
if (hasReverseCharge) return 'reverse_charge'
// Check for input VAT (2641 debit)
const inputVat = result.vat_lines.find(
(l) => l.account_number === '2641' && l.debit_amount > 0
)
if (!inputVat) return null
// Derive rate from the line description (generated by generateInputVatLine)
// Format: "Ingående moms 25%", "Ingående moms 12%", "Ingående moms 6%"
const rateMatch = inputVat.description?.match(/(\d+)%/)
if (rateMatch) {
const pct = parseInt(rateMatch[1], 10)
if (pct === 12) return 'reduced_12'
if (pct === 6) return 'reduced_6'
}
return 'standard_25'
}
// ── SIE Voucher Template Population ──────────────────────────
const SIE_SKIP_DESCRIPTIONS = new Set([
'lön', 'löner', 'löneutbetalning', 'arbetsgivaravgifter',
'semesterlöneskuld', 'preliminärskatt', 'momsredovisning', 'moms',
'bokslutsdisposition', 'bokslut', 'bokslutstransaktion', 'årsbokslut',
'avskrivning', 'avskrivningar', 'periodisering',
'upplupna', 'förutbetalda', 'skatteberäkning', 'skattebetalning',
'resultatdisposition', 'årets resultat',
'omföring', 'intern omföring', 'korrigering', 'rättelse', 'avslut',
'öppningsbalans', 'ub', 'ib',
])
function toDateString(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
/** Settlement accounts: bank/cash (19xx), receivables (1510), payables (2440), credit card (2890) */
function isSettlementAccount(account: string): boolean {
return account.startsWith('19') || account === '1510' || account === '2440' || account === '2890'
}
/** Rounding account — excluded from pattern extraction */
function isRoundingAccount(account: string): boolean {
return account === '3740'
}
/** Tax/duty accounts in 24xx range (except 2440 = AP settlement) */
function isTaxAccount(account: string): boolean {
return account.startsWith('24') && account !== '2440'
}
/** Check if a 26xx account is a known VAT account */
function isVatAccount(account: string): boolean {
return account.startsWith('26') && account in VAT_ACCOUNT_TREATMENT
}
/** Get the VAT rate (decimal) from a VAT account treatment string */
function vatTreatmentToRate(treatment: string): number {
if (treatment === 'standard_25') return 0.25
if (treatment === 'reduced_12') return 0.12
if (treatment === 'reduced_6') return 0.06
return 0
}
// ── Extracted voucher line pattern ───────────────────────────
interface VoucherLinePattern {
entries: LinePatternEntry[]
settlementAccount: string
settlementSide: 'debit' | 'credit'
}
/**
* Extract a line pattern from a single SIE voucher.
* Returns null if the voucher can't be represented as a pattern.
*/
function extractVoucherLinePattern(
lines: { account: string; amount: number }[]
): VoucherLinePattern | null {
const settlement: { account: string; amount: number }[] = []
const vat: { account: string; amount: number }[] = []
const business: { account: string; amount: number }[] = []
for (const line of lines) {
if (isSettlementAccount(line.account)) {
settlement.push(line)
} else if (isRoundingAccount(line.account)) {
// Skip 3740 lines — rounding artifacts
continue
} else if (isVatAccount(line.account)) {
vat.push(line)
} else {
business.push(line)
}
}
// Need at least 1 business account and 1 settlement account
if (business.length === 0 || settlement.length === 0) return null
// Skip if too many distinct accounts (likely a complex/manual entry)
const distinctBusiness = new Set(business.map(l => l.account))
if (distinctBusiness.size > 5) return null
const settlementTotal = settlement.reduce((s, l) => s + Math.abs(l.amount), 0)
if (settlementTotal === 0) return null
const vatTotal = vat.reduce((s, l) => s + Math.abs(l.amount), 0)
const nonVatTotal = settlementTotal - vatTotal
if (nonVatTotal <= 0) return null
// Determine settlement side from the first settlement line
const settlementSide: 'debit' | 'credit' = settlement[0].amount >= 0 ? 'debit' : 'credit'
const entries: LinePatternEntry[] = []
// VAT lines: store vat_rate, not ratio
for (const v of vat) {
const treatment = VAT_ACCOUNT_TREATMENT[v.account]
if (!treatment) continue
const rate = vatTreatmentToRate(treatment)
if (rate === 0) continue
entries.push({
account: v.account,
type: 'vat',
side: v.amount >= 0 ? 'debit' : 'credit',
vat_rate: rate,
})
}
// Business/tax lines: compute ratio against nonVatTotal
for (const b of business) {
const ratio = Math.abs(b.amount) / nonVatTotal
entries.push({
account: b.account,
type: isTaxAccount(b.account) ? 'tax' : 'business',
side: b.amount >= 0 ? 'debit' : 'credit',
ratio: Math.round(ratio * 10000) / 10000,
})
}
return {
entries,
settlementAccount: settlement[0].account,
settlementSide,
}
}
// ── Counterparty group types ─────────────────────────────────
interface CounterpartyGroup {
normalizedName: string
aliases: Set<string>
patterns: Map<string, MultiLinePatternCount>
totalCount: number
}
interface MultiLinePatternCount {
accountSet: string // sorted accounts joined by +
voucherPatterns: VoucherLinePattern[] // all individual patterns in this group
count: number
latestDate: Date
}
/**
* Normalize non-VAT ratios in a line pattern to sum to exactly 1.0.
*/
function normalizeRatios(entries: LinePatternEntry[]): LinePatternEntry[] {
const ratioEntries = entries.filter(e => e.ratio !== undefined)
if (ratioEntries.length === 0) return entries
const ratioSum = ratioEntries.reduce((s, e) => s + (e.ratio ?? 0), 0)
if (ratioSum === 0) return entries
const result = entries.map(e => {
if (e.ratio === undefined) return { ...e }
return { ...e, ratio: Math.round((e.ratio / ratioSum) * 10000) / 10000 }
})
// Assign rounding remainder to the largest ratio entry
const normalizedRatioEntries = result.filter(e => e.ratio !== undefined)
const newSum = normalizedRatioEntries.reduce((s, e) => s + (e.ratio ?? 0), 0)
const diff = Math.round((1.0 - newSum) * 10000) / 10000
if (diff !== 0 && normalizedRatioEntries.length > 0) {
const largest = normalizedRatioEntries.reduce((a, b) => ((a.ratio ?? 0) >= (b.ratio ?? 0) ? a : b))
largest.ratio = Math.round(((largest.ratio ?? 0) + diff) * 10000) / 10000
}
return result
}
/**
* Average line patterns from multiple vouchers into a single normalized pattern.
*/
function averageLinePatterns(voucherPatterns: VoucherLinePattern[]): LinePatternEntry[] {
if (voucherPatterns.length === 0) return []
if (voucherPatterns.length === 1) return normalizeRatios(voucherPatterns[0].entries)
// Collect all accounts across all patterns
const accountMap = new Map<string, { type: LinePatternEntry['type']; side: LinePatternEntry['side']; ratios: number[]; vat_rate?: number }>()
for (const vp of voucherPatterns) {
// Normalize per-voucher ratios before averaging
const normalized = normalizeRatios(vp.entries)
for (const entry of normalized) {
const existing = accountMap.get(entry.account)
if (!existing) {
accountMap.set(entry.account, {
type: entry.type,
side: entry.side,
ratios: entry.ratio !== undefined ? [entry.ratio] : [],
vat_rate: entry.vat_rate,
})
} else {
if (entry.ratio !== undefined) {
existing.ratios.push(entry.ratio)
}
}
}
}
const entries: LinePatternEntry[] = []
for (const [account, data] of accountMap) {
const entry: LinePatternEntry = { account, type: data.type, side: data.side }
if (data.vat_rate !== undefined) {
entry.vat_rate = data.vat_rate
}
if (data.ratios.length > 0) {
entry.ratio = Math.round((data.ratios.reduce((s, r) => s + r, 0) / data.ratios.length) * 10000) / 10000
}
entries.push(entry)
}
return normalizeRatios(entries)
}
/**
* Analyze SIE voucher history and create counterparty templates.
*
* Groups vouchers by normalized description and account set, filters by
* dominance and minimum occurrences. Supports both simple (single debit/credit)
* and multi-line patterns (stored as line_pattern JSONB).
*/
export async function populateTemplatesFromSieVouchers(
supabase: SupabaseClient,
companyId: string,
vouchers: SIEVoucher[],
options?: { recencyMonths?: number }
): Promise<number> {
if (vouchers.length === 0) return 0
const recencyMonths = options?.recencyMonths ?? 24
// Step 0: Recency filter
let maxDate = vouchers[0].date
for (const v of vouchers) {
if (v.date > maxDate) maxDate = v.date
}
const cutoff = new Date(maxDate)
cutoff.setMonth(cutoff.getMonth() - recencyMonths)
const recentVouchers = vouchers.filter(v => v.date >= cutoff)
if (recentVouchers.length === 0) return 0
// Step 1: Build counterparty groups
const groups = new Map<string, CounterpartyGroup>()
for (const voucher of recentVouchers) {
const desc = voucher.description?.trim()
if (!desc) continue
const normalized = normalizeCounterpartyName(desc)
if (!normalized || normalized.length < 2) continue
if (SIE_SKIP_DESCRIPTIONS.has(normalized)) continue
// Extract line pattern from voucher
const linePattern = extractVoucherLinePattern(voucher.lines)
if (!linePattern) continue
// Group key: sorted set of non-settlement accounts
const accountSet = linePattern.entries
.map(e => e.account)
.sort()
.join('+')
const groupKey = `${normalized}|${accountSet}`
let group = groups.get(normalized)
if (!group) {
group = { normalizedName: normalized, aliases: new Set(), patterns: new Map(), totalCount: 0 }
groups.set(normalized, group)
}
group.aliases.add(desc.toLowerCase())
group.totalCount += 1
let pattern = group.patterns.get(groupKey)
if (!pattern) {
pattern = { accountSet, voucherPatterns: [], count: 0, latestDate: voucher.date }
group.patterns.set(groupKey, pattern)
}
pattern.voucherPatterns.push(linePattern)
pattern.count += 1
if (voucher.date > pattern.latestDate) {
pattern.latestDate = voucher.date
}
}
// Step 2 & 3: Filter by dominance and compute confidence
const accepted: {
normalizedName: string
aliases: string[]
pattern: MultiLinePatternCount
settlementAccount: string
settlementSide: 'debit' | 'credit'
confidence: number
}[] = []
for (const group of groups.values()) {
if (group.totalCount < 2) continue
// Find dominant pattern
let dominant: MultiLinePatternCount | null = null
for (const p of group.patterns.values()) {
if (!dominant || p.count > dominant.count) {
dominant = p
}
}
if (!dominant) continue
const dominance = dominant.count / group.totalCount
if (dominance < 0.6) continue
const confidence = Math.round(Math.min(0.95, dominance * (1 - 1 / dominant.count)) * 100) / 100
// Get settlement info from the first voucher pattern
const firstVp = dominant.voucherPatterns[0]
accepted.push({
normalizedName: group.normalizedName,
aliases: [...group.aliases],
pattern: dominant,
settlementAccount: firstVp.settlementAccount,
settlementSide: firstVp.settlementSide,
confidence,
})
}
if (accepted.length === 0) return 0
// Step 4: Batch write
const { data: existingTemplates } = await supabase
.from('categorization_templates')
.select('*')
.eq('company_id', companyId)
.eq('is_active', true)
const templateMap = new Map<string, CategorizationTemplate>()
if (existingTemplates) {
for (const t of existingTemplates) {
templateMap.set(t.counterparty_name, t as CategorizationTemplate)
}
}
let count = 0
for (const item of accepted) {
const existing = templateMap.get(item.normalizedName) ?? null
// Average the line patterns from all vouchers in the dominant group
const avgPattern = averageLinePatterns(item.pattern.voucherPatterns)
// Decide: simple (1 business + 0-1 VAT) → legacy fields; otherwise → line_pattern
const businessEntries = avgPattern.filter(e => e.type === 'business')
const vatEntries = avgPattern.filter(e => e.type === 'vat')
const taxEntries = avgPattern.filter(e => e.type === 'tax')
const isSimple = businessEntries.length === 1 && taxEntries.length === 0 && vatEntries.length <= 1
// Determine primary business account and settlement for debit/credit fields
const primaryBusiness = businessEntries.sort((a, b) => (b.ratio ?? 0) - (a.ratio ?? 0))[0]
let debitAccount: string
let creditAccount: string
if (item.settlementSide === 'debit') {
debitAccount = item.settlementAccount
creditAccount = primaryBusiness?.account ?? item.settlementAccount
} else {
debitAccount = primaryBusiness?.account ?? item.settlementAccount
creditAccount = item.settlementAccount
}
// VAT info from first VAT entry (for legacy fields)
const firstVat = vatEntries[0]
const vatAccount = firstVat?.account ?? null
const vatTreatment = vatAccount ? (VAT_ACCOUNT_TREATMENT[vatAccount] ?? null) : null
await insertOrUpdateTemplate(supabase, companyId, {
counterpartyName: item.normalizedName,
aliases: item.aliases,
debitAccount,
creditAccount,
vatTreatment,
vatAccount,
category: null,
occurrenceCount: item.pattern.count,
confidence: item.confidence,
lastSeenDate: toDateString(item.pattern.latestDate),
source: 'sie_import',
linePattern: isSimple ? null : avgPattern,
}, existing)
count += 1
}
return count
}