From d9c95a7b597e596890bab5421dbba9ae300849d0 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:54:53 +0100 Subject: [PATCH] feat: counterparty templates with multi-line patterns, batch matching, and settings cleanup (#118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: separate AR/AP/accounting into distinct nav groups (#92) Split the flat "Finans" sidebar group into three visually distinct sections — Försäljning (AR), Inköp (AP), and Redovisning — so users coming from Fortnox immediately find customer invoicing and supplier invoices as top-level concepts. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: journal entry detail view, correction chain, and account name display - Add journal entry detail page at /bookkeeping/[id] with full entry view - Add correction chain API and component showing storno relationships - Add JournalEntryStatusBadge component for entry status display - Show debit/credit account names in template picker and review dialogs - Expand client-side BAS account name mapping with additional accounts - Show account codes on transaction inbox suggestion buttons Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address review feedback — N+1 query, duplicate name, nav dedup - Batch reverse-lookup into single query per BFS iteration (was N+1) - Differentiate account 2393 from 2893 in display names - Extract shared loop for desktop/mobile nav group rendering Co-Authored-By: Claude Opus 4.6 (1M context) * feat: counterparty templates, Skatteverket extension, VAT form completeness, and UI cleanup - Add counterparty-based categorization templates (learned from user approvals and auto-ingestion) with fuzzy matching in the mapping engine - Add Skatteverket extension for direct VAT declaration submission via API - Complete VAT declaration form with all 30 SKV 4700 boxes (ruta 08, 35-42, 50, 60-62) - Fix ruta 49 formula to include import VAT (ruta 60+61+62) - Simplify dashboard UI: remove redundant icons from stat cards, customer cards, invoice list, supplier invoices; use Badge variants consistently - Add SkatteverketPanel component to reports page - Add categorization_templates and skatteverket_tokens migrations - Update tests and helpers for new types Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address PR review feedback — VAT detection, migration timestamps, dedup - Fix detectVatTreatment to derive actual rate (12%/6%) from VAT line description instead of hardcoding standard_25 - Rename skatteverket_tokens migration to 20260324120001 to avoid duplicate timestamp with categorization_templates (fixes Supabase deployment failure) - Make refreshAccessToken accept previousRefreshCount param to enforce refresh limit contract at the type level - Fix rate limiter TOCTOU by claiming slot before await - Extract formatRedovisare/formatRedovisningsperiod to shared lib/skatteverket/format.ts — eliminates duplication between mappers.ts and SkatteverketPanel.tsx Co-Authored-By: Claude Opus 4.6 (1M context) * feat: multi-line counterparty templates, batch matching, settings restructure Counterparty template engine: - Multi-line booking patterns (line_pattern JSONB) for complex entries with split VAT, tax accounts, and ratio-based allocation - Batch matching (findCounterpartyTemplatesBatch) — 1 DB query for all transactions instead of up to 3 per transaction - SIE voucher population (populateTemplatesFromSieVouchers) — extracts patterns from historical vouchers on import with dominance filtering - Source priority system (user_approved > sie_import > auto_learned) - Centralized counterparty: prefix helpers to prevent string fragility - Fix: re-approval path now updates line_pattern Transaction categorization: - /describe route returns counterparty_match in parallel with templates/AI - /categorize route accepts counterparty_template_id for direct booking - /suggest-categories uses batch matching, injects counterparty suggestions - transaction-entries supports all_lines_complete for multi-line patterns UI: - TemplatePicker shows "Tidigare motparter" section (no AI extension needed) - DescribeTransactionDialog shows counterparty match card with detail - QuickReviewDialog supports counterparty line patterns - JournalEntryPreview renders multi-line patterns with VAT/ratio math - Inline LinePatternEntry types replaced with shared import from @/types Settings restructure: - 8 tabs → 5: merged Säkerhet + Utseende + Kalender into Konto - Renamed "Motparter" → "Mallar" - CounterpartyTemplatesPanel: click-to-expand detail view with account lines, VAT, confidence, aliases, and delete Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address PR review — account_override guard, DELETE body parsing, stale test - Block account_override when counterparty_template_id is set (prevents corrupting stored template via override → upsert correction path) - Wrap DELETE request.json() in try-catch for malformed body (400 not 500) - Clean up stale 3-query mock enqueues in test for batch-based find Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- app/(dashboard)/settings/page.tsx | 160 +--- app/(dashboard)/transactions/page.tsx | 118 ++- .../settings/counterparty-templates/route.ts | 44 + app/api/transactions/[id]/categorize/route.ts | 31 +- .../[id]/describe/__tests__/route.test.ts | 6 + app/api/transactions/[id]/describe/route.ts | 39 +- .../transactions/suggest-categories/route.ts | 28 + .../settings/CounterpartyTemplatesPanel.tsx | 259 +++++ .../DescribeTransactionDialog.tsx | 101 +- .../transactions/JournalEntryPreview.tsx | 48 +- components/transactions/QuickReviewDialog.tsx | 107 ++- components/transactions/TemplatePicker.tsx | 58 +- .../transactions/TransactionInboxCard.tsx | 15 +- lib/api/schemas.ts | 1 + .../__tests__/counterparty-templates.test.ts | 393 +++++++- lib/bookkeeping/counterparty-templates.ts | 897 +++++++++++++++--- lib/bookkeeping/transaction-entries.ts | 48 +- lib/import/sie-import.ts | 15 + lib/transactions/category-suggestions.ts | 3 +- ..._categorization_templates_line_pattern.sql | 9 + tests/helpers.ts | 19 + types/index.ts | 11 + 22 files changed, 2022 insertions(+), 388 deletions(-) create mode 100644 app/api/settings/counterparty-templates/route.ts create mode 100644 components/settings/CounterpartyTemplatesPanel.tsx create mode 100644 supabase/migrations/20260324120002_categorization_templates_line_pattern.sql diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 55d592e3..32ea6eea 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -39,6 +39,7 @@ import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry' import { SecuritySettings } from '@/components/settings/SecuritySettings' import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel' +import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' const BankingPanel = getSettingsPanel('enable-banking') @@ -70,12 +71,10 @@ export default function SettingsPage() { const settingsTabs = [ { value: 'company', label: 'Företag', show: true }, - { value: 'banking', label: 'Bank (PSD2)', show: !settings?.is_sandbox }, - { value: 'calendar', label: 'Kalender', show: hasCalendarExtension }, - { value: 'security', label: 'Säkerhet', show: true }, - { value: 'appearance', label: 'Utseende', show: true }, - { value: 'api', label: 'API', show: hasMcpExtension }, + { value: 'banking', label: 'Bank (PSD2)', show: !settings?.is_sandbox && hasBankingExtension }, + { value: 'templates', label: 'Mallar', show: true }, { value: 'account', label: 'Konto', show: true }, + { value: 'api', label: 'API', show: hasMcpExtension }, ].filter(t => t.show) useEffect(() => { @@ -670,113 +669,9 @@ export default function SettingsPage() { )} - {/* Calendar feed settings */} - {hasCalendarExtension && ( - - - - )} - - {/* Security settings */} - - - - - {/* Appearance settings */} - - - - Utseende - - Välj hur applikationen ska se ut - - - - {mounted && ( -
- {/* Light */} - - - {/* Dark */} - - - {/* System */} - -
- )} -
-
+ {/* Counterparty templates */} + + {/* API keys */} @@ -786,8 +681,47 @@ export default function SettingsPage() {
)} - {/* Account settings */} + {/* Account — security, appearance, calendar, logout, delete */} + {/* Appearance */} + + + Utseende + + + {mounted && ( +
+ {([ + { value: 'light', label: 'Ljust', icon: Sun }, + { value: 'dark', label: 'Mörkt', icon: Moon }, + { value: 'system', label: 'System', icon: Monitor }, + ] as const).map(({ value, label, icon: Icon }) => ( + + ))} +
+ )} +
+
+ + {/* Security */} + + + {/* Calendar feed */} + {hasCalendarExtension && } + + {/* Logout & delete */} Kontoinställningar diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 1d58f604..221d6101 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -25,11 +25,22 @@ import TemplatePicker from '@/components/transactions/TemplatePicker' import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions/transaction-types' import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping' import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates' +import { isCounterpartyTemplateId, extractCounterpartyId } from '@/lib/bookkeeping/counterparty-templates' import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types' -import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem, EntityType } from '@/types' +import { formatCurrency, formatDate } from '@/lib/utils' +import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem, EntityType, LinePatternEntry } from '@/types' import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' +interface QuickReviewState { + transaction: TransactionWithInvoice + category: TransactionCategory + label: string + template: BookingTemplate | null + templateId: string | undefined + linePattern: LinePatternEntry[] | null +} + export default function TransactionsPage() { const [transactions, setTransactions] = useState([]) const [isLoading, setIsLoading] = useState(true) @@ -63,11 +74,7 @@ export default function TransactionsPage() { // Quick review dialog (suggestion review before booking) const [quickReviewOpen, setQuickReviewOpen] = useState(false) - const [quickReviewTransaction, setQuickReviewTransaction] = useState(null) - const [quickReviewCategory, setQuickReviewCategory] = useState(null) - const [quickReviewLabel, setQuickReviewLabel] = useState('') - const [quickReviewTemplate, setQuickReviewTemplate] = useState(null) - const [quickReviewTemplateId, setQuickReviewTemplateId] = useState(undefined) + const [quickReview, setQuickReview] = useState(null) // Describe dialog const [describeDialogOpen, setDescribeDialogOpen] = useState(false) @@ -615,9 +622,7 @@ export default function TransactionsPage() { function handleOpenQuickReview(transaction: TransactionWithInvoice, suggestion: SuggestedCategory) { const allCategories = [...EXPENSE_CATEGORIES, ...INCOME_CATEGORIES] const label = allCategories.find((c) => c.value === suggestion.category)?.label || suggestion.label - setQuickReviewTransaction(transaction) - setQuickReviewCategory(suggestion.category) - setQuickReviewLabel(label) + setQuickReview({ transaction, category: suggestion.category, label, template: null, templateId: undefined, linePattern: null }) setQuickReviewOpen(true) } @@ -625,29 +630,43 @@ export default function TransactionsPage() { setTemplatePickerOpen(false) const tx = templatePickerTransaction if (!tx) return - setQuickReviewTransaction(tx) - setQuickReviewCategory(template.fallback_category) - setQuickReviewLabel(template.name_sv) - setQuickReviewTemplate(template) - setQuickReviewTemplateId(template.id) + setQuickReview({ transaction: tx, category: template.fallback_category, label: template.name_sv, template, templateId: template.id, linePattern: null }) setQuickReviewOpen(true) } function handleOpenTemplateReview(transaction: TransactionWithInvoice, templateId: string) { + if (isCounterpartyTemplateId(templateId)) { + const cpSuggestion = templateSuggestions[transaction.id]?.find(ts => ts.template_id === templateId) + if (!cpSuggestion) return + setQuickReview({ + transaction, + category: transaction.amount < 0 ? 'expense_other' : 'income_services', + label: cpSuggestion.name_sv, + template: { id: templateId, name_sv: cpSuggestion.name_sv } as BookingTemplate, + templateId: undefined, + linePattern: cpSuggestion.line_pattern ?? null, + }) + setQuickReviewOpen(true) + return + } + const template = getTemplateById(templateId) if (!template) return - setQuickReviewTransaction(transaction) - setQuickReviewCategory(template.fallback_category) - setQuickReviewLabel(template.name_sv) - setQuickReviewTemplate(template) - setQuickReviewTemplateId(template.id) + setQuickReview({ + transaction, + category: template.fallback_category, + label: template.name_sv, + template, + templateId: template.id, + linePattern: null, + }) setQuickReviewOpen(true) } function handleChangeTemplate() { setQuickReviewOpen(false) - if (quickReviewTransaction) { - setTemplatePickerTransaction(quickReviewTransaction) + if (quickReview?.transaction) { + setTemplatePickerTransaction(quickReview.transaction) setTemplatePickerOpen(true) } } @@ -667,13 +686,30 @@ export default function TransactionsPage() { accountOverride: string | undefined, templateId?: string ): Promise { - const journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride, templateId) + let journalEntryId: string | null + if (!templateId && quickReview?.template?.id && isCounterpartyTemplateId(quickReview.template.id)) { + const cpTemplateId = extractCounterpartyId(quickReview.template.id) + const response = await fetch(`/api/transactions/${id}/categorize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + is_business: true, + counterparty_template_id: cpTemplateId, + }), + }) + const result = await response.json() + if (!response.ok) { + toast({ title: 'Kategorisering misslyckades', description: result.error || 'Försök igen.', variant: 'destructive' }) + return null + } + setExitingIds((prev) => new Set(prev).add(id)) + journalEntryId = result.journal_entry_id || null + } else { + journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride, templateId) + } if (journalEntryId) { setQuickReviewOpen(false) - setQuickReviewTransaction(null) - setQuickReviewCategory(null) - setQuickReviewTemplate(null) - setQuickReviewTemplateId(undefined) + setQuickReview(null) } return journalEntryId } @@ -854,11 +890,24 @@ export default function TransactionsPage() { Välj mall + {templatePickerTransaction && ( +
+ {templatePickerTransaction.description} + + {templatePickerTransaction.amount > 0 ? '+' : ''}{formatCurrency(templatePickerTransaction.amount, templatePickerTransaction.currency)} + +
+ )} { + if (!templatePickerTransaction) return + setTemplatePickerOpen(false) + handleOpenTemplateReview(templatePickerTransaction, templateId) + }} />
+ + {/* Expanded detail */} + {isExpanded && ( +
+ {/* Account lines */} +
+

Kontering

+ {isMultiLine ? ( +
+ {t.line_pattern!.map((lp, i) => ( +
+ + {lp.side === 'debit' ? 'Debet' : 'Kredit'} + + {formatAccountWithName(lp.account)} + {lp.type === 'vat' && lp.vat_rate && ( + ({Math.round(lp.vat_rate * 100)}% moms) + )} + {lp.ratio !== undefined && ( + ({Math.round(lp.ratio * 100)}%) + )} +
+ ))} +
+ ) : ( +
+
+ Debet + {formatAccountWithName(t.debit_account)} +
+
+ Kredit + {formatAccountWithName(t.credit_account)} +
+
+ )} +
+ + {/* Metadata */} +
+ {t.vat_treatment && ( +
+ Moms + {VAT_LABELS[t.vat_treatment] || t.vat_treatment} +
+ )} +
+ Antal bokföringar + {t.occurrence_count} +
+
+ Säkerhet + + {Math.round(Number(t.confidence) * 100)}% + +
+
+ Senast använd + {formatDate(t.last_seen_date)} +
+ {t.counterparty_aliases && t.counterparty_aliases.length > 1 && ( +
+ Alias + {t.counterparty_aliases.slice(0, 3).join(', ')}{t.counterparty_aliases.length > 3 ? ` +${t.counterparty_aliases.length - 3}` : ''} +
+ )} +
+ + {/* Delete */} +
+ +
+
+ )} +
+ ) + })} + + )} + +
+ + ) +} diff --git a/components/transactions/DescribeTransactionDialog.tsx b/components/transactions/DescribeTransactionDialog.tsx index 03d0187e..ad8f9b89 100644 --- a/components/transactions/DescribeTransactionDialog.tsx +++ b/components/transactions/DescribeTransactionDialog.tsx @@ -28,6 +28,7 @@ import { import JournalEntryPreview from './JournalEntryPreview' import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names' import type { TransactionWithInvoice } from './transaction-types' +import type { LinePatternEntry } from '@/types' interface TemplateMatch { template_id: string @@ -46,6 +47,18 @@ interface TemplateMatch { risk_level: string } +interface CounterpartyMatch { + id: string + counterparty_name: string + debit_account: string + credit_account: string + vat_treatment: string | null + confidence: number + occurrence_count: number + source: string + line_pattern: LinePatternEntry[] | null +} + interface AiSuggestion { debit_account: string credit_account: string @@ -59,6 +72,7 @@ interface AiSuggestion { interface DescribeResult { templates: TemplateMatch[] + counterparty_match: CounterpartyMatch | null ai_suggestion: AiSuggestion | null needs_more_detail: boolean user_description: string @@ -75,7 +89,7 @@ interface DescribeTransactionDialogProps { } type Step = 'describe' | 'pick' | 'batch' -type Selection = { type: 'template'; templateId: string } | { type: 'ai' } +type Selection = { type: 'template'; templateId: string } | { type: 'ai' } | { type: 'counterparty' } function getExamplePrompts(transaction: TransactionWithInvoice): string[] { const desc = (transaction.description || '').toLowerCase() @@ -191,7 +205,14 @@ export default function DescribeTransactionDialog({ // Build categorize request based on selection type let body: Record - if (selection.type === 'template') { + if (selection.type === 'counterparty') { + const cp = describeResult.counterparty_match! + body = { + is_business: true, + counterparty_template_id: cp.id, + user_description: describeResult.user_description, + } + } else if (selection.type === 'template') { body = { is_business: true, template_id: selection.templateId, @@ -323,6 +344,8 @@ export default function DescribeTransactionDialog({ if (!transaction) return null const isIncome = transaction.amount > 0 + const counterpartyMatch = describeResult?.counterparty_match + const isCounterpartySelected = selection?.type === 'counterparty' const aiSuggestion = describeResult?.ai_suggestion // Check if AI agrees with top template const topTemplate = describeResult?.templates[0] @@ -424,6 +447,66 @@ export default function DescribeTransactionDialog({ )}
+ {/* Counterparty Match Card */} + {counterpartyMatch && ( + setSelection({ type: 'counterparty' })} + > + +
+
+
+ + Tidigare bokföring + +
+

{counterpartyMatch.counterparty_name}

+
+ {counterpartyMatch.line_pattern && counterpartyMatch.line_pattern.length > 0 ? ( + counterpartyMatch.line_pattern.map((lp, i) => ( + + {formatAccountWithName(lp.account)} + + )) + ) : ( + <> + + D: {formatAccountWithName(counterpartyMatch.debit_account)} + + + K: {formatAccountWithName(counterpartyMatch.credit_account)} + + {counterpartyMatch.vat_treatment && counterpartyMatch.vat_treatment !== 'exempt' && ( + + Moms {Math.round(getVatRateFromTreatment(counterpartyMatch.vat_treatment) * 100)}% + + )} + + )} +
+

+ {counterpartyMatch.occurrence_count} tidigare bokföringar +

+
+
+ = 0.7 ? 'default' : 'outline'} + className="text-[10px] px-1.5 py-0" + > + {Math.round(counterpartyMatch.confidence * 100)}% + + {isCounterpartySelected && ( + + )} +
+
+
+
+ )} + {/* AI Suggestion Card */} {aiSuggestion && ( - {/* Journal entry preview for selected template or AI suggestion */} + {/* Journal entry preview for selected template, AI suggestion, or counterparty match */} {selection && (() => { + if (selection.type === 'counterparty' && counterpartyMatch) { + return ( + + ) + } if (selection.type === 'ai' && aiSuggestion) { return ( { const result: PreviewLine[] = [] const absAmount = Math.abs(amount) + // Multi-line counterparty template preview + if (linePattern && linePattern.length > 0) { + const isIncome = amount > 0 + const settlementSide = isIncome ? 'debet' : 'kredit' + + // Settlement line + result.push({ side: settlementSide, account: settlementAccount, amount: absAmount }) + + // VAT lines first (from rate) + let totalVat = 0 + for (const entry of linePattern) { + if (entry.type === 'vat' && entry.vat_rate) { + const vatAmt = Math.round(absAmount * entry.vat_rate / (1 + entry.vat_rate) * 100) / 100 + totalVat += vatAmt + result.push({ side: entry.side === 'debit' ? 'debet' : 'kredit', account: entry.account, amount: vatAmt }) + } + } + + // Business/tax lines (from ratio against non-VAT amount) + const nonVatAmt = Math.round((absAmount - totalVat) * 100) / 100 + let allocated = 0 + const ratioEntries = linePattern.filter(e => e.ratio !== undefined) + for (const entry of ratioEntries) { + const amt = Math.round(nonVatAmt * (entry.ratio ?? 0) * 100) / 100 + allocated += amt + result.push({ side: entry.side === 'debit' ? 'debet' : 'kredit', account: entry.account, amount: amt }) + } + + // Rounding difference to 3740 + const totalAllocated = Math.round((totalVat + allocated) * 100) / 100 + const diff = Math.round((absAmount - totalAllocated) * 100) / 100 + if (diff !== 0) { + const businessSide = linePattern.find(e => e.type === 'business')?.side ?? 'credit' + result.push({ side: businessSide === 'debit' ? 'debet' : 'kredit', account: '3740', amount: Math.abs(diff) }) + } + + return result + } + // Template-based preview if (templateDebitAccount && templateCreditAccount) { const vatRate = templateVatRate ?? 0 @@ -93,7 +137,7 @@ export default function JournalEntryPreview({ } return result - }, [amount, category, vatTreatment, accountOverride, entityType, templateDebitAccount, templateCreditAccount, templateVatRate]) + }, [amount, category, vatTreatment, accountOverride, entityType, templateDebitAccount, templateCreditAccount, templateVatRate, linePattern, settlementAccount]) if (lines.length === 0) return null diff --git a/components/transactions/QuickReviewDialog.tsx b/components/transactions/QuickReviewDialog.tsx index f007108c..1c1378b4 100644 --- a/components/transactions/QuickReviewDialog.tsx +++ b/components/transactions/QuickReviewDialog.tsx @@ -17,7 +17,7 @@ import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' import VatTreatmentSelect from './VatTreatmentSelect' import { VAT_TREATMENT_OPTIONS } from './transaction-types' import type { TransactionWithInvoice } from './transaction-types' -import type { TransactionCategory, VatTreatment, BASAccount, EntityType } from '@/types' +import type { TransactionCategory, VatTreatment, BASAccount, EntityType, LinePatternEntry } from '@/types' interface QuickReviewDialogProps { open: boolean @@ -30,6 +30,7 @@ interface QuickReviewDialogProps { entityType?: EntityType template?: BookingTemplate | null templateId?: string + counterpartyLinePattern?: LinePatternEntry[] | null onConfirm: ( id: string, category: TransactionCategory, @@ -51,6 +52,7 @@ export default function QuickReviewDialog({ entityType, template, templateId, + counterpartyLinePattern, onConfirm, onChangeTemplate, }: QuickReviewDialogProps) { @@ -91,6 +93,8 @@ export default function QuickReviewDialog({ if (!transaction || !category) return null const isIncome = transaction.amount > 0 + const isCounterpartyTemplate = !!(counterpartyLinePattern && counterpartyLinePattern.length > 0) + const isTemplateBooking = !!templateId || isCounterpartyTemplate const isLiabilityAccount = accountOverride.startsWith('2') async function handleConfirm() { @@ -151,7 +155,7 @@ export default function QuickReviewDialog({ Granska bokföring - Kontrollera konto och moms innan du bokför + {isTemplateBooking ? 'Granska verifikationen innan du bokför' : 'Kontrollera konto och moms innan du bokför'} @@ -179,13 +183,13 @@ export default function QuickReviewDialog({ {/* Template or Category */}
{template ? template.name_sv : categoryLabel} - {onChangeTemplate && ( + {onChangeTemplate && !isCounterpartyTemplate && (
- {template && ( + {template && !isCounterpartyTemplate && (

D: {formatAccountWithName(template.debit_account)} → K: {formatAccountWithName(template.credit_account)}

@@ -236,52 +240,57 @@ export default function QuickReviewDialog({ - {/* Account */} -
- -
- -
-
+ {/* Account & VAT — hidden for template bookings (accounts defined by the template) */} + {!isTemplateBooking && ( + <> +
+ +
+ +
+
- {/* VAT treatment */} -
- -
- {isLiabilityAccount ? ( -

- Ingen moms för skuld-/eget kapital-konton -

- ) : showVatDropdown ? ( - - ) : ( -

- {VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)?.label || 'Ingen moms'} - {' '} - -

- )} -
-
+
+ +
+ {isLiabilityAccount ? ( +

+ Ingen moms för skuld-/eget kapital-konton +

+ ) : showVatDropdown ? ( + + ) : ( +

+ {VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)?.label || 'Ingen moms'} + {' '} + +

+ )} +
+
+ + )} {/* Document upload */}
@@ -335,7 +344,7 @@ export default function QuickReviewDialog({ + ))} +
+
+ )} + {/* Suggested templates */} {hasSuggestions && (

Föreslagna

- {suggestedTemplates!.slice(0, 5).map((s) => { + {resolvedSuggestions.slice(0, 5).map((s) => { // Find the full template object const fullTemplate = allCommon.find((t) => t.id === s.template_id) || allAdvanced.find((t) => t.id === s.template_id) diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx index 12a44743..03fc357d 100644 --- a/components/transactions/TransactionInboxCard.tsx +++ b/components/transactions/TransactionInboxCard.tsx @@ -8,8 +8,9 @@ import { Checkbox } from '@/components/ui/checkbox' import { cn, formatCurrency, formatDate } from '@/lib/utils' import { ArrowUpRight, ArrowDownRight, FileText, Loader2, MessageSquareText, Paperclip } from 'lucide-react' import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/info-tooltip' -import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names' +import { getAccountName, formatAccountWithName } from '@/lib/bookkeeping/client-account-names' import { getTemplateById } from '@/lib/bookkeeping/booking-templates' +import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates' import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types' import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions' @@ -178,7 +179,8 @@ export default function TransactionInboxCard({ ) : templateSuggestions && templateSuggestions.length > 0 ? ( <> {templateSuggestions.slice(0, 2).map((ts, idx) => { - const tmpl = getTemplateById(ts.template_id) + const isCounterparty = isCounterpartyTemplateId(ts.template_id) + const tmpl = isCounterparty ? null : getTemplateById(ts.template_id) return (
- - D: {formatAccountWithName(ts.debit_account)} → K: {formatAccountWithName(ts.credit_account)} + + {isCounterparty + ? `${ts.description_sv}` + : getAccountName(tmpl?.debit_account || ts.debit_account) + }
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 807220b6..f45b5e09 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -320,6 +320,7 @@ export const CategorizeTransactionSchema = z.object({ template_id: z.string().optional(), vat_treatment: VatTreatmentSchema.optional(), account_override: accountNumber.optional(), + counterparty_template_id: z.string().uuid().optional(), user_description: z.string().max(500).optional(), inbox_item_id: z.string().uuid().optional(), }) diff --git a/lib/bookkeeping/__tests__/counterparty-templates.test.ts b/lib/bookkeeping/__tests__/counterparty-templates.test.ts index eefe0eb8..151790d9 100644 --- a/lib/bookkeeping/__tests__/counterparty-templates.test.ts +++ b/lib/bookkeeping/__tests__/counterparty-templates.test.ts @@ -4,6 +4,7 @@ import { createQueuedMockSupabase, makeTransaction, makeCategorizationTemplate, + makeSIEVoucher, } from '@/tests/helpers' import { normalizeCounterpartyName, @@ -11,7 +12,11 @@ import { findCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate, upsertCounterpartyTemplate, + resolveSource, + insertOrUpdateTemplate, + populateTemplatesFromSieVouchers, } from '../counterparty-templates' +import type { TemplateUpsertParams } from '../counterparty-templates' describe('counterparty-templates', () => { // ── Normalization ────────────────────────────────────────── @@ -107,11 +112,14 @@ describe('counterparty-templates', () => { }) it('returns exact alias match with full confidence', async () => { - const template = makeCategorizationTemplate({ confidence: 0.8 }) + const template = makeCategorizationTemplate({ + confidence: 0.8, + counterparty_aliases: ['telia sverige ab'], + }) const { supabase, enqueue } = createQueuedMockSupabase() - // Alias match returns the template - enqueue({ data: template }) + // Batch query returns all templates + enqueue({ data: [template] }) const tx = makeTransaction({ merchant_name: 'Telia Sverige AB' }) const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx) @@ -122,11 +130,15 @@ describe('counterparty-templates', () => { }) it('falls through to exact normalized when alias misses', async () => { - const template = makeCategorizationTemplate({ confidence: 0.8 }) + const template = makeCategorizationTemplate({ + counterparty_name: 'telia', + confidence: 0.8, + counterparty_aliases: ['telia ab'], + }) const { supabase, enqueue } = createQueuedMockSupabase() - enqueue({ data: null }) // Alias miss - enqueue({ data: template }) // Exact normalized hit + // Batch query returns all templates (alias won't match 'Telia') + enqueue({ data: [template] }) const tx = makeTransaction({ merchant_name: 'Telia' }) const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx) @@ -140,12 +152,12 @@ describe('counterparty-templates', () => { const template = makeCategorizationTemplate({ counterparty_name: 'telia', confidence: 0.8, + counterparty_aliases: ['telia ab'], }) const { supabase, enqueue } = createQueuedMockSupabase() - enqueue({ data: null }) // Alias miss - enqueue({ data: null }) // Exact miss - enqueue({ data: [template] }) // Fuzzy: all templates + // Batch query returns all templates + enqueue({ data: [template] }) // "teliq" has Levenshtein distance 1 from "telia" const tx = makeTransaction({ merchant_name: 'Teliq' }) @@ -161,12 +173,12 @@ describe('counterparty-templates', () => { const template = makeCategorizationTemplate({ counterparty_name: 'telia', confidence: 0.8, + counterparty_aliases: ['telia ab'], }) const { supabase, enqueue } = createQueuedMockSupabase() - enqueue({ data: null }) // Alias miss - enqueue({ data: null }) // Exact miss - enqueue({ data: [template] }) // Fuzzy: all templates + // Batch query returns all templates + enqueue({ data: [template] }) // "xxxxx" has Levenshtein distance 5 from "telia" const tx = makeTransaction({ merchant_name: 'XXXXX' }) @@ -178,9 +190,8 @@ describe('counterparty-templates', () => { it('returns null when no templates exist', async () => { const { supabase, enqueue } = createQueuedMockSupabase() - enqueue({ data: null }) // Alias miss - enqueue({ data: null }) // Exact miss - enqueue({ data: [] }) // Fuzzy: empty + // Batch query returns empty + enqueue({ data: [] }) const tx = makeTransaction({ merchant_name: 'Unknown Company' }) const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx) @@ -366,4 +377,356 @@ describe('counterparty-templates', () => { expect(supabase.from).not.toHaveBeenCalled() }) }) + + // ── Source Priority ────────────────────────────────────────── + + describe('resolveSource', () => { + it('sie_import does not downgrade user_approved', () => { + expect(resolveSource('user_approved', 'sie_import')).toBe('user_approved') + }) + + it('user_approved upgrades auto_learned', () => { + expect(resolveSource('auto_learned', 'user_approved')).toBe('user_approved') + }) + + it('sie_import upgrades auto_learned', () => { + expect(resolveSource('auto_learned', 'sie_import')).toBe('sie_import') + }) + + it('auto_learned does not upgrade sie_import', () => { + expect(resolveSource('sie_import', 'auto_learned')).toBe('sie_import') + }) + + it('same source returns same source', () => { + expect(resolveSource('user_approved', 'user_approved')).toBe('user_approved') + expect(resolveSource('sie_import', 'sie_import')).toBe('sie_import') + }) + }) + + // ── insertOrUpdateTemplate ─────────────────────────────────── + + describe('insertOrUpdateTemplate', () => { + const baseParams: TemplateUpsertParams = { + counterpartyName: 'telia', + aliases: ['telia sverige ab'], + debitAccount: '6200', + creditAccount: '1930', + vatTreatment: 'standard_25', + vatAccount: '2641', + category: null, + occurrenceCount: 1, + confidence: 0.45, + lastSeenDate: '2024-06-15', + source: 'sie_import', + } + + it('inserts new template when existingTemplate is null', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null }) // insert + + await insertOrUpdateTemplate(supabase as never, 'user-1', baseParams, null) + + expect(supabase.from).toHaveBeenCalledWith('categorization_templates') + }) + + it('uses pre-fetched template without DB lookup', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const existing = makeCategorizationTemplate({ + debit_account: '6200', + credit_account: '1930', + occurrence_count: 5, + source: 'auto_learned', + }) + + enqueue({ data: null }) // update + + await insertOrUpdateTemplate(supabase as never, 'user-1', baseParams, existing) + + // Should NOT have queried for existing — only the update call + // The from() call count indicates no select was made before update + expect(supabase.from).toHaveBeenCalledTimes(1) + }) + + it('accumulates occurrence count on re-approval', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const existing = makeCategorizationTemplate({ + debit_account: '6200', + credit_account: '1930', + occurrence_count: 5, + }) + + enqueue({ data: null }) // update + + const params = { ...baseParams, occurrenceCount: 10 } + await insertOrUpdateTemplate(supabase as never, 'user-1', params, existing) + + // Verify supabase.from was called (update happened) + expect(supabase.from).toHaveBeenCalledWith('categorization_templates') + }) + + it('respects source priority — sie_import does not overwrite user_approved', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const existing = makeCategorizationTemplate({ + debit_account: '5410', // Different accounts = correction + credit_account: '1930', + source: 'user_approved', + }) + + enqueue({ data: null }) // update + + await insertOrUpdateTemplate(supabase as never, 'user-1', baseParams, existing) + + // The source should remain user_approved (not downgraded to sie_import) + expect(supabase.from).toHaveBeenCalledWith('categorization_templates') + }) + + it('does DB lookup when existingTemplate is undefined', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: null }) // select returns null + enqueue({ data: null }) // insert + + await insertOrUpdateTemplate(supabase as never, 'user-1', baseParams) + + // Two calls: select + insert + expect(supabase.from).toHaveBeenCalledTimes(2) + }) + }) + + // ── populateTemplatesFromSieVouchers ───────────────────────── + + describe('populateTemplatesFromSieVouchers', () => { + it('returns 0 for empty vouchers', async () => { + const { supabase } = createMockSupabase() + const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', []) + expect(result).toBe(0) + }) + + it('groups vouchers by normalized description', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const vouchers = [ + makeSIEVoucher({ description: 'Telia Sverige AB', number: 1 }), + makeSIEVoucher({ description: 'TELIA SVERIGE', number: 2 }), + makeSIEVoucher({ description: 'telia sverige ab', number: 3 }), + ] + + enqueue({ data: [] }) // pre-fetch existing templates + enqueue({ data: null }) // insert + + const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers) + expect(result).toBe(1) + }) + + it('skips groups with fewer than 2 occurrences', async () => { + const { supabase } = createQueuedMockSupabase() + const vouchers = [ + makeSIEVoucher({ description: 'Telia', number: 1 }), + ] + + const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers) + expect(result).toBe(0) + }) + + it('accepts groups with sufficient dominance (75% > 60%)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const vouchers = [ + makeSIEVoucher({ description: 'Telia', number: 1, lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }] }), + makeSIEVoucher({ description: 'Telia', number: 2, lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }] }), + makeSIEVoucher({ description: 'Telia', number: 3, lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }] }), + makeSIEVoucher({ description: 'Telia', number: 4, lines: [{ account: '1930', amount: -1000 }, { account: '6230', amount: 1000 }] }), + ] + + enqueue({ data: [] }) // pre-fetch + enqueue({ data: null }) // insert + + const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers) + expect(result).toBe(1) + }) + + it('rejects groups with insufficient dominance (50% < 60%)', async () => { + const { supabase } = createQueuedMockSupabase() + const vouchers = [ + makeSIEVoucher({ description: 'Telia', number: 1, lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }] }), + makeSIEVoucher({ description: 'Telia', number: 2, lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }] }), + makeSIEVoucher({ description: 'Telia', number: 3, lines: [{ account: '1930', amount: -1000 }, { account: '6230', amount: 1000 }] }), + makeSIEVoucher({ description: 'Telia', number: 4, lines: [{ account: '1930', amount: -1000 }, { account: '6230', amount: 1000 }] }), + ] + + const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers) + expect(result).toBe(0) + }) + + it('skips descriptions in skip set', async () => { + const { supabase } = createQueuedMockSupabase() + const vouchers = [ + makeSIEVoucher({ description: 'Lön', number: 1 }), + makeSIEVoucher({ description: 'Lön', number: 2 }), + makeSIEVoucher({ description: 'Lön', number: 3 }), + ] + + const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers) + expect(result).toBe(0) + }) + + it('skips split bookings with more than 5 business accounts', async () => { + const { supabase } = createQueuedMockSupabase() + const vouchers = Array.from({ length: 3 }, (_, i) => + makeSIEVoucher({ + description: 'Complex booking', + number: i + 1, + lines: [ + { account: '1930', amount: -6000 }, + { account: '6200', amount: 1000 }, + { account: '6210', amount: 1000 }, + { account: '6220', amount: 1000 }, + { account: '6230', amount: 1000 }, + { account: '6240', amount: 1000 }, + { account: '6250', amount: 1000 }, + ], + }) + ) + + const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers) + expect(result).toBe(0) + }) + + it('extracts VAT treatment from 2641 line', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const vouchers = Array.from({ length: 3 }, (_, i) => + makeSIEVoucher({ + description: 'Kontorsmaterial AB', + number: i + 1, + lines: [ + { account: '1930', amount: -1250 }, + { account: '6100', amount: 1000 }, + { account: '2641', amount: 250 }, + ], + }) + ) + + enqueue({ data: [] }) // pre-fetch + enqueue({ data: null }) // insert + + const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers) + expect(result).toBe(1) + }) + + it('filters out old vouchers beyond recency window', async () => { + const { supabase } = createQueuedMockSupabase() + const recentDate = new Date(2024, 5, 15) + const oldDate = new Date(2021, 0, 1) // >24 months before recent + + const vouchers = [ + makeSIEVoucher({ description: 'Old Company', date: oldDate, number: 1 }), + makeSIEVoucher({ description: 'Old Company', date: oldDate, number: 2 }), + makeSIEVoucher({ description: 'Old Company', date: oldDate, number: 3 }), + makeSIEVoucher({ description: 'Recent Company', date: recentDate, number: 4 }), + ] + + // Only 1 recent voucher for "Recent Company" → below min count → 0 templates + const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers) + expect(result).toBe(0) + }) + + it('computes SIE confidence formula correctly', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + // 40 occurrences, 38 dominant (95% dominance) + const vouchers: ReturnType[] = [] + for (let i = 0; i < 38; i++) { + vouchers.push(makeSIEVoucher({ + description: 'Telia', + number: i + 1, + lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }], + })) + } + for (let i = 0; i < 2; i++) { + vouchers.push(makeSIEVoucher({ + description: 'Telia', + number: 39 + i, + lines: [{ account: '1930', amount: -1000 }, { account: '6230', amount: 1000 }], + })) + } + + enqueue({ data: [] }) // pre-fetch + enqueue({ data: null }) // insert + + const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers) + expect(result).toBe(1) + // dominance = 38/40 = 0.95, confidence = 0.95 * (1 - 1/38) ≈ 0.925 → rounded to 0.92 + }) + + it('recognizes 1510 (receivables) as settlement account', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const vouchers = Array.from({ length: 3 }, (_, i) => + makeSIEVoucher({ + description: 'Kund AB', + number: i + 1, + lines: [ + { account: '1510', amount: 10000 }, + { account: '3001', amount: -10000 }, + ], + }) + ) + + enqueue({ data: [] }) // pre-fetch + enqueue({ data: null }) // insert + + const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers) + expect(result).toBe(1) + }) + + it('recognizes 2890 (credit card) as settlement account', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const vouchers = Array.from({ length: 3 }, (_, i) => + makeSIEVoucher({ + description: 'Företaget AB', + number: i + 1, + lines: [ + { account: '2890', amount: -1000 }, + { account: '6200', amount: 1000 }, + ], + }) + ) + + enqueue({ data: [] }) // pre-fetch + enqueue({ data: null }) // insert + + const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers) + expect(result).toBe(1) + }) + + it('end-to-end: multiple counterparties, filters correctly', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + const vouchers = [ + // Telia: 5 vouchers, all same pattern → should produce template + ...Array.from({ length: 5 }, (_, i) => + makeSIEVoucher({ + description: 'Telia', + number: i + 1, + lines: [{ account: '1930', amount: -500 }, { account: '6212', amount: 500 }], + }) + ), + // ICA: 4 vouchers, all same pattern → should produce template + ...Array.from({ length: 4 }, (_, i) => + makeSIEVoucher({ + description: 'ICA Maxi', + number: 10 + i, + lines: [{ account: '1930', amount: -200 }, { account: '4010', amount: 200 }], + }) + ), + // Rare: 1 voucher → below minimum, no template + makeSIEVoucher({ + description: 'Rare Supplier', + number: 20, + lines: [{ account: '1930', amount: -100 }, { account: '6590', amount: 100 }], + }), + ] + + enqueue({ data: [] }) // pre-fetch + enqueue({ data: null }) // insert 1 + enqueue({ data: null }) // insert 2 + + const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers) + expect(result).toBe(2) + }) + }) }) diff --git a/lib/bookkeeping/counterparty-templates.ts b/lib/bookkeeping/counterparty-templates.ts index 15923de3..8a20f14b 100644 --- a/lib/bookkeeping/counterparty-templates.ts +++ b/lib/bookkeeping/counterparty-templates.ts @@ -12,11 +12,13 @@ import type { CategorizationTemplate, CategorizationTemplateSource, EntityType, + LinePatternEntry, MappingResult, Transaction, VatJournalLine, VatTreatment, } from '@/types' +import type { SIEVoucher } from '@/lib/import/types' // ── Normalization ────────────────────────────────────────────── @@ -45,6 +47,22 @@ export function normalizeCounterpartyName(raw: string): string { // ── 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, @@ -55,6 +73,42 @@ export function calculateConfidence(occurrenceCount: number): number { return Math.round(Math.min(raw, 0.95) * 100) / 100 } +// ── Source Priority ─────────────────────────────────────────── + +const SOURCE_PRIORITY: Record = { + sni_default: 0, + auto_learned: 1, + sie_import: 2, + user_approved: 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 = { + '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 { @@ -66,9 +120,9 @@ export interface CounterpartyTemplateMatch { /** * Find a counterparty template matching a transaction. * - * Three-tier matching: - * 1. Exact alias match — GIN index on counterparty_aliases array - * 2. Exact normalized name match — UNIQUE index + * 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( @@ -76,78 +130,96 @@ export async function findCounterpartyTemplate( userId: string, transaction: Transaction ): Promise { - const rawName = transaction.merchant_name || transaction.description - if (!rawName) return null + const results = await findCounterpartyTemplatesBatch(supabase, userId, [transaction]) + return results.get(transaction.id) ?? null +} - const normalized = normalizeCounterpartyName(rawName) - if (!normalized || normalized.length < 2) return null +/** + * Batch counterparty template matching for multiple transactions. + * One DB query, all matching done in memory. + */ +export async function findCounterpartyTemplatesBatch( + supabase: SupabaseClient, + userId: string, + transactions: Transaction[] +): Promise> { + const result = new Map() - // 1. Exact alias match (highest confidence multiplier) - const { data: aliasMatch } = await supabase - .from('categorization_templates') - .select('*') - .eq('user_id', userId) - .eq('is_active', true) - .contains('counterparty_aliases', [rawName.toLowerCase()]) - .limit(1) - .maybeSingle() - - if (aliasMatch) { - return { - template: aliasMatch as CategorizationTemplate, - matchMethod: 'exact_alias', - confidence: Math.min(Number(aliasMatch.confidence) * 1.0, 1), - } - } - - // 2. Exact normalized name match - const { data: exactMatch } = await supabase - .from('categorization_templates') - .select('*') - .eq('user_id', userId) - .eq('is_active', true) - .eq('counterparty_name', normalized) - .maybeSingle() - - if (exactMatch) { - return { - template: exactMatch as CategorizationTemplate, - matchMethod: 'exact_normalized', - confidence: Math.round(Number(exactMatch.confidence) * 0.95 * 100) / 100, - } - } - - // 3. Fuzzy match — fetch all active templates, compute Levenshtein const { data: allTemplates } = await supabase .from('categorization_templates') .select('*') .eq('user_id', userId) .eq('is_active', true) - if (!allTemplates || allTemplates.length === 0) return null + if (!allTemplates || allTemplates.length === 0) return result - let bestMatch: CategorizationTemplate | null = null - let bestDistance = Infinity + const templates = allTemplates as CategorizationTemplate[] - for (const tmpl of allTemplates) { - const dist = levenshteinDistance(normalized, tmpl.counterparty_name) - const maxAllowed = normalized.length <= 10 ? 2 : 3 - if (dist <= maxAllowed && dist < bestDistance) { - bestDistance = dist - bestMatch = tmpl as CategorizationTemplate + // Build alias lookup: lowercase alias → template + const aliasMap = new Map() + for (const tmpl of templates) { + for (const alias of tmpl.counterparty_aliases || []) { + aliasMap.set(alias, tmpl) } } - if (bestMatch) { - const similarity = 1 - bestDistance / Math.max(normalized.length, bestMatch.counterparty_name.length) - return { - template: bestMatch, - matchMethod: 'fuzzy', - confidence: Math.round(Number(bestMatch.confidence) * similarity * 100) / 100, + // Build normalized name lookup + const nameMap = new Map() + 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 null + return result } // ── Build MappingResult ──────────────────────────────────────── @@ -159,13 +231,19 @@ export async function findCounterpartyTemplate( export function buildMappingResultFromCounterpartyTemplate( match: CounterpartyTemplateMatch, transaction: Transaction, - entityType: EntityType + _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 - // Generate VAT lines if applicable const vatLines: VatJournalLine[] = [] if (isExpense && tmpl.vat_treatment) { const vatTreatment = tmpl.vat_treatment as VatTreatment @@ -195,7 +273,6 @@ export function buildMappingResultFromCounterpartyTemplate( } } - // Determine if private (debit to private account means non-business) const privateAccounts = ['2013', '2893'] const isPrivate = privateAccounts.includes(tmpl.debit_account) @@ -212,15 +289,206 @@ export function buildMappingResultFromCounterpartyTemplate( } } +/** + * 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, + userId: string, + params: TemplateUpsertParams, + existingTemplate?: CategorizationTemplate | null +): Promise { + // Resolve existing template + let existing: CategorizationTemplate | null = null + if (existingTemplate === undefined) { + const { data } = await supabase + .from('categorization_templates') + .select('*') + .eq('user_id', userId) + .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({ + user_id: userId, + 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. - * - * - New counterparty: insert with starting confidence based on source - * - Re-approval (same accounts): increment occurrence_count, recalc confidence - * - Correction (different accounts): update accounts, reset occurrence_count to 1 - * - Always: add raw name alias if not present, update last_seen_date + * Thin wrapper around insertOrUpdateTemplate for single-transaction callers. */ export async function upsertCounterpartyTemplate( supabase: SupabaseClient, @@ -235,94 +503,23 @@ export async function upsertCounterpartyTemplate( const normalized = normalizeCounterpartyName(rawName) if (!normalized || normalized.length < 2) return - const rawNameLower = rawName.toLowerCase() const category = transaction.category !== 'uncategorized' ? transaction.category : null - const txDate = transaction.date - // Check for existing template - const { data: existing } = await supabase - .from('categorization_templates') - .select('*') - .eq('user_id', userId) - .eq('counterparty_name', normalized) - .maybeSingle() - - if (existing) { - const isCorrection = - existing.debit_account !== mappingResult.debit_account || - existing.credit_account !== mappingResult.credit_account - - // Build updated aliases array (add raw name if not present) - const aliases: string[] = existing.counterparty_aliases || [] - if (!aliases.includes(rawNameLower)) { - aliases.push(rawNameLower) - } - - if (isCorrection) { - // Correction: update accounts, reset occurrence to 1 - const newConfidence = calculateConfidence(1) - await supabase - .from('categorization_templates') - .update({ - debit_account: mappingResult.debit_account, - credit_account: mappingResult.credit_account, - vat_treatment: mappingResult.vat_lines.length > 0 - ? detectVatTreatment(mappingResult) - : existing.vat_treatment, - vat_account: mappingResult.vat_lines[0]?.account_number || existing.vat_account, - category: category || existing.category, - occurrence_count: 1, - confidence: newConfidence, - last_seen_date: txDate, - source: source === 'auto_learned' ? existing.source : source, - counterparty_aliases: aliases, - }) - .eq('id', existing.id) - } else { - // Re-approval: increment count, recalc confidence - const newCount = existing.occurrence_count + 1 - const newConfidence = calculateConfidence(newCount) - // Upgrade source if human approves an auto-learned template - const newSource = - source === 'user_approved' && existing.source === 'auto_learned' - ? 'user_approved' - : existing.source - - await supabase - .from('categorization_templates') - .update({ - occurrence_count: newCount, - confidence: newConfidence, - last_seen_date: txDate, - source: newSource, - counterparty_aliases: aliases, - category: category || existing.category, - }) - .eq('id', existing.id) - } - } else { - // New template - const startingConfidence = source === 'auto_learned' - ? calculateConfidence(1) // ~0.45 - : calculateConfidence(1) // same formula, but source flag matters for threshold - - await supabase - .from('categorization_templates') - .insert({ - user_id: userId, - counterparty_name: normalized, - counterparty_aliases: [rawNameLower], - debit_account: mappingResult.debit_account, - credit_account: mappingResult.credit_account, - vat_treatment: detectVatTreatment(mappingResult), - vat_account: mappingResult.vat_lines[0]?.account_number || null, - category: category || null, - occurrence_count: 1, - confidence: startingConfidence, - last_seen_date: txDate, - source, - }) - } + await insertOrUpdateTemplate(supabase, userId, { + 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, + }) } /** @@ -353,3 +550,397 @@ function detectVatTreatment(result: MappingResult): string | null { } 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 + patterns: Map + 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() + + 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, + userId: string, + vouchers: SIEVoucher[], + options?: { recencyMonths?: number } +): Promise { + 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() + + 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('user_id', userId) + .eq('is_active', true) + + const templateMap = new Map() + 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, userId, { + 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 +} diff --git a/lib/bookkeeping/transaction-entries.ts b/lib/bookkeeping/transaction-entries.ts index 8edc4818..2b1ec31b 100644 --- a/lib/bookkeeping/transaction-entries.ts +++ b/lib/bookkeeping/transaction-entries.ts @@ -86,8 +86,52 @@ export async function createTransactionJournalEntry( line_description: transaction.description, } ) + } else if (mappingResult.all_lines_complete) { + // Multi-line pattern: vat_lines contains ALL non-settlement lines with correct amounts. + // Settlement line = full absAmount on the appropriate side. + const settlementAccount = isExpense + ? (mappingResult.credit_account || '1930') + : (mappingResult.debit_account || '1930') + + if (isExpense) { + // All non-settlement lines (business, VAT, tax, rounding) + for (const line of mappingResult.vat_lines) { + lines.push({ + account_number: line.account_number, + debit_amount: line.debit_amount, + credit_amount: line.credit_amount, + line_description: line.description || transaction.description, + }) + } + // Credit bank for full amount + lines.push({ + account_number: settlementAccount, + debit_amount: 0, + credit_amount: absAmount, + line_description: transaction.description, + ...(settlementAccount === '1930' ? currencyMeta : {}), + }) + } else { + // Debit bank for full amount + lines.push({ + account_number: settlementAccount, + debit_amount: absAmount, + credit_amount: 0, + line_description: transaction.description, + ...(settlementAccount === '1930' ? currencyMeta : {}), + }) + // All non-settlement lines + for (const line of mappingResult.vat_lines) { + lines.push({ + account_number: line.account_number, + debit_amount: line.debit_amount, + credit_amount: line.credit_amount, + line_description: line.description || transaction.description, + }) + } + } } else if (isExpense) { - // Business expense + // Business expense (legacy single debit/credit path) const debitAccount = mappingResult.debit_account const creditAccount = mappingResult.credit_account || '1930' @@ -134,7 +178,7 @@ export async function createTransactionJournalEntry( ...(creditAccount === '1930' ? currencyMeta : {}), }) } else { - // Income + // Income (legacy single debit/credit path) const debitAccount = mappingResult.debit_account || '1930' const creditAccount = mappingResult.credit_account diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts index 10c21e53..f3e04547 100644 --- a/lib/import/sie-import.ts +++ b/lib/import/sie-import.ts @@ -21,6 +21,7 @@ import { mappingsToMap, getMappingStats } from './account-mapper' import { calculateFileHash } from './sie-parser' import { getBASReference } from '@/lib/bookkeeping/bas-reference' import { computeSRUCode } from '@/lib/bookkeeping/bas-data/sru-mapping' +import { populateTemplatesFromSieVouchers } from '@/lib/bookkeeping/counterparty-templates' /** * Format a date to ISO date string (YYYY-MM-DD) @@ -1532,6 +1533,20 @@ export async function executeSIEImport( documentation ) + // Populate counterparty templates from voucher patterns (non-blocking) + if (result.success && parsed.vouchers.length > 0) { + try { + const templateCount = await populateTemplatesFromSieVouchers( + supabase, userId, parsed.vouchers + ) + if (templateCount > 0) { + console.info(`[sie-import] ${templateCount} counterparty templates extracted from voucher history`) + } + } catch (templateError) { + console.error('[sie-import] Failed to populate counterparty templates:', templateError) + } + } + // Add warnings for any issues for (const issue of parsed.issues) { if (issue.severity === 'warning') { diff --git a/lib/transactions/category-suggestions.ts b/lib/transactions/category-suggestions.ts index 2de71739..b12c28a8 100644 --- a/lib/transactions/category-suggestions.ts +++ b/lib/transactions/category-suggestions.ts @@ -2,7 +2,7 @@ import { suggestCategory } from '@/lib/tax/expense-warnings' import { getExpenseAccountForCategory } from '@/lib/bookkeeping/category-mapping' import { findMatchingTemplates, getTemplateById, type TemplateMatch } from '@/lib/bookkeeping/booking-templates' import { extensionRegistry } from '@/lib/extensions/registry' -import type { Transaction, TransactionCategory, EntityType, MappingRule } from '@/types' +import type { Transaction, TransactionCategory, EntityType, MappingRule, LinePatternEntry } from '@/types' export interface SuggestedCategory { category: TransactionCategory @@ -244,6 +244,7 @@ export interface SuggestedTemplate { description_sv: string risk_level: string requires_review: boolean + line_pattern?: LinePatternEntry[] | null } /** diff --git a/supabase/migrations/20260324120002_categorization_templates_line_pattern.sql b/supabase/migrations/20260324120002_categorization_templates_line_pattern.sql new file mode 100644 index 00000000..b285daee --- /dev/null +++ b/supabase/migrations/20260324120002_categorization_templates_line_pattern.sql @@ -0,0 +1,9 @@ +-- Add multi-line booking pattern support to categorization templates. +-- Null = legacy single debit/credit pair. Array of {account, type, side, ratio?, vat_rate?}. +-- VAT lines store vat_rate (computed exactly), business/tax lines store ratio (of non-VAT amount). + +ALTER TABLE public.categorization_templates + ADD COLUMN line_pattern JSONB; + +COMMENT ON COLUMN public.categorization_templates.line_pattern IS + 'Multi-line booking pattern. Null = use debit_account/credit_account. Array of {account, type, side, ratio?, vat_rate?}. Ratios are relative to the non-VAT subtotal.'; diff --git a/tests/helpers.ts b/tests/helpers.ts index 5ed3e676..c3bac1f7 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -19,6 +19,7 @@ import type { InvoiceInboxItem, CategorizationTemplate, } from '@/types' +import type { SIEVoucher, SIETransactionLine } from '@/lib/import/types' // ============================================================ // Chainable Supabase mock @@ -664,9 +665,27 @@ export function makeCategorizationTemplate( confidence: 0.7, last_seen_date: '2024-06-15', source: 'user_approved', + line_pattern: null, is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-06-15T00:00:00Z', ...overrides, } } + +export function makeSIEVoucher( + overrides: Partial> & { lines?: SIETransactionLine[] } = {} +): SIEVoucher { + return { + series: 'A', + number: 1, + date: new Date(2024, 5, 15), // June 15, 2024 + description: 'Faktura', + lines: [ + { account: '1930', amount: -1000 }, + { account: '6200', amount: 800 }, + { account: '2641', amount: 200 }, + ], + ...overrides, + } +} diff --git a/types/index.ts b/types/index.ts index 54618af6..398d38ab 100644 --- a/types/index.ts +++ b/types/index.ts @@ -926,6 +926,7 @@ export interface MappingResult { requires_review: boolean default_private: boolean vat_lines: VatJournalLine[] + all_lines_complete?: boolean // when true, vat_lines contains ALL non-settlement lines description: string } @@ -940,6 +941,15 @@ export interface VatJournalLine { // Categorization template source export type CategorizationTemplateSource = 'sie_import' | 'user_approved' | 'sni_default' | 'auto_learned' +// Multi-line booking pattern entry +export interface LinePatternEntry { + account: string + type: 'business' | 'vat' | 'tax' + side: 'debit' | 'credit' + ratio?: number // proportion of NON-VAT amount (business + tax ratios sum to ~1.0) + vat_rate?: number // applied to FULL amount via rate/(1+rate) (vat type only) +} + // Per-tenant counterparty-based categorization template export interface CategorizationTemplate { id: string @@ -951,6 +961,7 @@ export interface CategorizationTemplate { vat_treatment: VatTreatment | null vat_account: string | null category: TransactionCategory | null + line_pattern: LinePatternEntry[] | null occurrence_count: number confidence: number last_seen_date: string | null