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:
co-authored by
Claude Opus 4.6
parent
d8e0a22495
commit
d9c95a7b59
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user