feat: counterparty templates with multi-line patterns, batch matching, and settings cleanup (#118)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-25 14:54:53 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent d8e0a22495
commit d9c95a7b59
22 changed files with 2022 additions and 388 deletions
+1
View File
@@ -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(),
})
@@ -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<typeof makeSIEVoucher>[] = []
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)
})
})
})
+744 -153
View File
@@ -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<CategorizationTemplateSource, number> = {
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<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 {
@@ -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<CounterpartyTemplateMatch | null> {
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<Map<string, CounterpartyTemplateMatch>> {
const result = new Map<string, CounterpartyTemplateMatch>()
// 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<string, CategorizationTemplate>()
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<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 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<void> {
// 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<string>
patterns: Map<string, MultiLinePatternCount>
totalCount: number
}
interface MultiLinePatternCount {
accountSet: string // sorted accounts joined by +
voucherPatterns: VoucherLinePattern[] // all individual patterns in this group
count: number
latestDate: Date
}
/**
* Normalize non-VAT ratios in a line pattern to sum to exactly 1.0.
*/
function normalizeRatios(entries: LinePatternEntry[]): LinePatternEntry[] {
const ratioEntries = entries.filter(e => e.ratio !== undefined)
if (ratioEntries.length === 0) return entries
const ratioSum = ratioEntries.reduce((s, e) => s + (e.ratio ?? 0), 0)
if (ratioSum === 0) return entries
const result = entries.map(e => {
if (e.ratio === undefined) return { ...e }
return { ...e, ratio: Math.round((e.ratio / ratioSum) * 10000) / 10000 }
})
// Assign rounding remainder to the largest ratio entry
const normalizedRatioEntries = result.filter(e => e.ratio !== undefined)
const newSum = normalizedRatioEntries.reduce((s, e) => s + (e.ratio ?? 0), 0)
const diff = Math.round((1.0 - newSum) * 10000) / 10000
if (diff !== 0 && normalizedRatioEntries.length > 0) {
const largest = normalizedRatioEntries.reduce((a, b) => ((a.ratio ?? 0) >= (b.ratio ?? 0) ? a : b))
largest.ratio = Math.round(((largest.ratio ?? 0) + diff) * 10000) / 10000
}
return result
}
/**
* Average line patterns from multiple vouchers into a single normalized pattern.
*/
function averageLinePatterns(voucherPatterns: VoucherLinePattern[]): LinePatternEntry[] {
if (voucherPatterns.length === 0) return []
if (voucherPatterns.length === 1) return normalizeRatios(voucherPatterns[0].entries)
// Collect all accounts across all patterns
const accountMap = new Map<string, { type: LinePatternEntry['type']; side: LinePatternEntry['side']; ratios: number[]; vat_rate?: number }>()
for (const vp of voucherPatterns) {
// Normalize per-voucher ratios before averaging
const normalized = normalizeRatios(vp.entries)
for (const entry of normalized) {
const existing = accountMap.get(entry.account)
if (!existing) {
accountMap.set(entry.account, {
type: entry.type,
side: entry.side,
ratios: entry.ratio !== undefined ? [entry.ratio] : [],
vat_rate: entry.vat_rate,
})
} else {
if (entry.ratio !== undefined) {
existing.ratios.push(entry.ratio)
}
}
}
}
const entries: LinePatternEntry[] = []
for (const [account, data] of accountMap) {
const entry: LinePatternEntry = { account, type: data.type, side: data.side }
if (data.vat_rate !== undefined) {
entry.vat_rate = data.vat_rate
}
if (data.ratios.length > 0) {
entry.ratio = Math.round((data.ratios.reduce((s, r) => s + r, 0) / data.ratios.length) * 10000) / 10000
}
entries.push(entry)
}
return normalizeRatios(entries)
}
/**
* Analyze SIE voucher history and create counterparty templates.
*
* Groups vouchers by normalized description and account set, filters by
* dominance and minimum occurrences. Supports both simple (single debit/credit)
* and multi-line patterns (stored as line_pattern JSONB).
*/
export async function populateTemplatesFromSieVouchers(
supabase: SupabaseClient,
userId: 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('user_id', userId)
.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, 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
}
+46 -2
View File
@@ -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
+15
View File
@@ -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') {
+2 -1
View File
@@ -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
}
/**