Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1026 lines
35 KiB
TypeScript
1026 lines
35 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import {
|
|
normalizeMerchantName,
|
|
levenshteinDistance,
|
|
} from '@/lib/documents/core-receipt-matcher'
|
|
import {
|
|
generateInputVatLine,
|
|
generateReverseChargeLines,
|
|
getVatRate,
|
|
} from './vat-entries'
|
|
import { dimensionsBagKey } from './dimension-resolver'
|
|
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: '',
|
|
// Dimensions PR7: business lines carry the pattern's learned bag;
|
|
// VAT/tax/rounding lines stay untagged.
|
|
...(entry.type === 'business' && entry.dimensions
|
|
? { dimensions: entry.dimensions }
|
|
: {}),
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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; dimensions?: Record<string, string> }[]
|
|
): VoucherLinePattern | null {
|
|
type PatternLine = { account: string; amount: number; dimensions?: Record<string, string> }
|
|
const settlement: PatternLine[] = []
|
|
const vat: PatternLine[] = []
|
|
const business: PatternLine[] = []
|
|
|
|
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,
|
|
// Dimensions PR7: carry the source line's bag so SIE-learned templates
|
|
// keep tagging like the history did (dropped in averaging on conflict).
|
|
...(b.dimensions && Object.keys(b.dimensions).length > 0
|
|
? { dimensions: b.dimensions }
|
|
: {}),
|
|
})
|
|
}
|
|
|
|
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
|
|
// Dimensions PR7: conservative: a bag survives averaging only when EVERY
|
|
// occurrence of the account carries the identical bag. A single
|
|
// disagreeing (or untagged) voucher drops it: a template must never
|
|
// invent a tag history doesn't consistently support.
|
|
dimensions?: Record<string, string>
|
|
dimensionsConsistent: boolean
|
|
}>()
|
|
|
|
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,
|
|
dimensions: entry.dimensions,
|
|
dimensionsConsistent: true,
|
|
})
|
|
} else {
|
|
if (entry.ratio !== undefined) {
|
|
existing.ratios.push(entry.ratio)
|
|
}
|
|
if (
|
|
existing.dimensionsConsistent &&
|
|
dimensionsBagKey(existing.dimensions) !== dimensionsBagKey(entry.dimensions)
|
|
) {
|
|
existing.dimensionsConsistent = false
|
|
existing.dimensions = undefined
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
if (data.dimensionsConsistent && data.dimensions && Object.keys(data.dimensions).length > 0) {
|
|
entry.dimensions = data.dimensions
|
|
}
|
|
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
|
|
}
|