- {template && (
+ {template && !isCounterpartyTemplate && (
D: {formatAccountWithName(template.debit_account)} → K: {formatAccountWithName(template.credit_account)}
+ {/* Account & VAT — hidden for template bookings (accounts defined by the template) */}
+ {!isTemplateBooking && (
+ <>
+
@@ -335,7 +344,7 @@ export default function QuickReviewDialog({
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts
index 807220b6..f45b5e09 100644
--- a/lib/api/schemas.ts
+++ b/lib/api/schemas.ts
@@ -320,6 +320,7 @@ export const CategorizeTransactionSchema = z.object({
template_id: z.string().optional(),
vat_treatment: VatTreatmentSchema.optional(),
account_override: accountNumber.optional(),
+ counterparty_template_id: z.string().uuid().optional(),
user_description: z.string().max(500).optional(),
inbox_item_id: z.string().uuid().optional(),
})
diff --git a/lib/bookkeeping/__tests__/counterparty-templates.test.ts b/lib/bookkeeping/__tests__/counterparty-templates.test.ts
index eefe0eb8..151790d9 100644
--- a/lib/bookkeeping/__tests__/counterparty-templates.test.ts
+++ b/lib/bookkeeping/__tests__/counterparty-templates.test.ts
@@ -4,6 +4,7 @@ import {
createQueuedMockSupabase,
makeTransaction,
makeCategorizationTemplate,
+ makeSIEVoucher,
} from '@/tests/helpers'
import {
normalizeCounterpartyName,
@@ -11,7 +12,11 @@ import {
findCounterpartyTemplate,
buildMappingResultFromCounterpartyTemplate,
upsertCounterpartyTemplate,
+ resolveSource,
+ insertOrUpdateTemplate,
+ populateTemplatesFromSieVouchers,
} from '../counterparty-templates'
+import type { TemplateUpsertParams } from '../counterparty-templates'
describe('counterparty-templates', () => {
// ── Normalization ──────────────────────────────────────────
@@ -107,11 +112,14 @@ describe('counterparty-templates', () => {
})
it('returns exact alias match with full confidence', async () => {
- const template = makeCategorizationTemplate({ confidence: 0.8 })
+ const template = makeCategorizationTemplate({
+ confidence: 0.8,
+ counterparty_aliases: ['telia sverige ab'],
+ })
const { supabase, enqueue } = createQueuedMockSupabase()
- // Alias match returns the template
- enqueue({ data: template })
+ // Batch query returns all templates
+ enqueue({ data: [template] })
const tx = makeTransaction({ merchant_name: 'Telia Sverige AB' })
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
@@ -122,11 +130,15 @@ describe('counterparty-templates', () => {
})
it('falls through to exact normalized when alias misses', async () => {
- const template = makeCategorizationTemplate({ confidence: 0.8 })
+ const template = makeCategorizationTemplate({
+ counterparty_name: 'telia',
+ confidence: 0.8,
+ counterparty_aliases: ['telia ab'],
+ })
const { supabase, enqueue } = createQueuedMockSupabase()
- enqueue({ data: null }) // Alias miss
- enqueue({ data: template }) // Exact normalized hit
+ // Batch query returns all templates (alias won't match 'Telia')
+ enqueue({ data: [template] })
const tx = makeTransaction({ merchant_name: 'Telia' })
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
@@ -140,12 +152,12 @@ describe('counterparty-templates', () => {
const template = makeCategorizationTemplate({
counterparty_name: 'telia',
confidence: 0.8,
+ counterparty_aliases: ['telia ab'],
})
const { supabase, enqueue } = createQueuedMockSupabase()
- enqueue({ data: null }) // Alias miss
- enqueue({ data: null }) // Exact miss
- enqueue({ data: [template] }) // Fuzzy: all templates
+ // Batch query returns all templates
+ enqueue({ data: [template] })
// "teliq" has Levenshtein distance 1 from "telia"
const tx = makeTransaction({ merchant_name: 'Teliq' })
@@ -161,12 +173,12 @@ describe('counterparty-templates', () => {
const template = makeCategorizationTemplate({
counterparty_name: 'telia',
confidence: 0.8,
+ counterparty_aliases: ['telia ab'],
})
const { supabase, enqueue } = createQueuedMockSupabase()
- enqueue({ data: null }) // Alias miss
- enqueue({ data: null }) // Exact miss
- enqueue({ data: [template] }) // Fuzzy: all templates
+ // Batch query returns all templates
+ enqueue({ data: [template] })
// "xxxxx" has Levenshtein distance 5 from "telia"
const tx = makeTransaction({ merchant_name: 'XXXXX' })
@@ -178,9 +190,8 @@ describe('counterparty-templates', () => {
it('returns null when no templates exist', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
- enqueue({ data: null }) // Alias miss
- enqueue({ data: null }) // Exact miss
- enqueue({ data: [] }) // Fuzzy: empty
+ // Batch query returns empty
+ enqueue({ data: [] })
const tx = makeTransaction({ merchant_name: 'Unknown Company' })
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
@@ -366,4 +377,356 @@ describe('counterparty-templates', () => {
expect(supabase.from).not.toHaveBeenCalled()
})
})
+
+ // ── Source Priority ──────────────────────────────────────────
+
+ describe('resolveSource', () => {
+ it('sie_import does not downgrade user_approved', () => {
+ expect(resolveSource('user_approved', 'sie_import')).toBe('user_approved')
+ })
+
+ it('user_approved upgrades auto_learned', () => {
+ expect(resolveSource('auto_learned', 'user_approved')).toBe('user_approved')
+ })
+
+ it('sie_import upgrades auto_learned', () => {
+ expect(resolveSource('auto_learned', 'sie_import')).toBe('sie_import')
+ })
+
+ it('auto_learned does not upgrade sie_import', () => {
+ expect(resolveSource('sie_import', 'auto_learned')).toBe('sie_import')
+ })
+
+ it('same source returns same source', () => {
+ expect(resolveSource('user_approved', 'user_approved')).toBe('user_approved')
+ expect(resolveSource('sie_import', 'sie_import')).toBe('sie_import')
+ })
+ })
+
+ // ── insertOrUpdateTemplate ───────────────────────────────────
+
+ describe('insertOrUpdateTemplate', () => {
+ const baseParams: TemplateUpsertParams = {
+ counterpartyName: 'telia',
+ aliases: ['telia sverige ab'],
+ debitAccount: '6200',
+ creditAccount: '1930',
+ vatTreatment: 'standard_25',
+ vatAccount: '2641',
+ category: null,
+ occurrenceCount: 1,
+ confidence: 0.45,
+ lastSeenDate: '2024-06-15',
+ source: 'sie_import',
+ }
+
+ it('inserts new template when existingTemplate is null', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: null }) // insert
+
+ await insertOrUpdateTemplate(supabase as never, 'user-1', baseParams, null)
+
+ expect(supabase.from).toHaveBeenCalledWith('categorization_templates')
+ })
+
+ it('uses pre-fetched template without DB lookup', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ const existing = makeCategorizationTemplate({
+ debit_account: '6200',
+ credit_account: '1930',
+ occurrence_count: 5,
+ source: 'auto_learned',
+ })
+
+ enqueue({ data: null }) // update
+
+ await insertOrUpdateTemplate(supabase as never, 'user-1', baseParams, existing)
+
+ // Should NOT have queried for existing — only the update call
+ // The from() call count indicates no select was made before update
+ expect(supabase.from).toHaveBeenCalledTimes(1)
+ })
+
+ it('accumulates occurrence count on re-approval', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ const existing = makeCategorizationTemplate({
+ debit_account: '6200',
+ credit_account: '1930',
+ occurrence_count: 5,
+ })
+
+ enqueue({ data: null }) // update
+
+ const params = { ...baseParams, occurrenceCount: 10 }
+ await insertOrUpdateTemplate(supabase as never, 'user-1', params, existing)
+
+ // Verify supabase.from was called (update happened)
+ expect(supabase.from).toHaveBeenCalledWith('categorization_templates')
+ })
+
+ it('respects source priority — sie_import does not overwrite user_approved', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ const existing = makeCategorizationTemplate({
+ debit_account: '5410', // Different accounts = correction
+ credit_account: '1930',
+ source: 'user_approved',
+ })
+
+ enqueue({ data: null }) // update
+
+ await insertOrUpdateTemplate(supabase as never, 'user-1', baseParams, existing)
+
+ // The source should remain user_approved (not downgraded to sie_import)
+ expect(supabase.from).toHaveBeenCalledWith('categorization_templates')
+ })
+
+ it('does DB lookup when existingTemplate is undefined', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ enqueue({ data: null }) // select returns null
+ enqueue({ data: null }) // insert
+
+ await insertOrUpdateTemplate(supabase as never, 'user-1', baseParams)
+
+ // Two calls: select + insert
+ expect(supabase.from).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ // ── populateTemplatesFromSieVouchers ─────────────────────────
+
+ describe('populateTemplatesFromSieVouchers', () => {
+ it('returns 0 for empty vouchers', async () => {
+ const { supabase } = createMockSupabase()
+ const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', [])
+ expect(result).toBe(0)
+ })
+
+ it('groups vouchers by normalized description', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ const vouchers = [
+ makeSIEVoucher({ description: 'Telia Sverige AB', number: 1 }),
+ makeSIEVoucher({ description: 'TELIA SVERIGE', number: 2 }),
+ makeSIEVoucher({ description: 'telia sverige ab', number: 3 }),
+ ]
+
+ enqueue({ data: [] }) // pre-fetch existing templates
+ enqueue({ data: null }) // insert
+
+ const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
+ expect(result).toBe(1)
+ })
+
+ it('skips groups with fewer than 2 occurrences', async () => {
+ const { supabase } = createQueuedMockSupabase()
+ const vouchers = [
+ makeSIEVoucher({ description: 'Telia', number: 1 }),
+ ]
+
+ const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
+ expect(result).toBe(0)
+ })
+
+ it('accepts groups with sufficient dominance (75% > 60%)', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ const vouchers = [
+ makeSIEVoucher({ description: 'Telia', number: 1, lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }] }),
+ makeSIEVoucher({ description: 'Telia', number: 2, lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }] }),
+ makeSIEVoucher({ description: 'Telia', number: 3, lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }] }),
+ makeSIEVoucher({ description: 'Telia', number: 4, lines: [{ account: '1930', amount: -1000 }, { account: '6230', amount: 1000 }] }),
+ ]
+
+ enqueue({ data: [] }) // pre-fetch
+ enqueue({ data: null }) // insert
+
+ const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
+ expect(result).toBe(1)
+ })
+
+ it('rejects groups with insufficient dominance (50% < 60%)', async () => {
+ const { supabase } = createQueuedMockSupabase()
+ const vouchers = [
+ makeSIEVoucher({ description: 'Telia', number: 1, lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }] }),
+ makeSIEVoucher({ description: 'Telia', number: 2, lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }] }),
+ makeSIEVoucher({ description: 'Telia', number: 3, lines: [{ account: '1930', amount: -1000 }, { account: '6230', amount: 1000 }] }),
+ makeSIEVoucher({ description: 'Telia', number: 4, lines: [{ account: '1930', amount: -1000 }, { account: '6230', amount: 1000 }] }),
+ ]
+
+ const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
+ expect(result).toBe(0)
+ })
+
+ it('skips descriptions in skip set', async () => {
+ const { supabase } = createQueuedMockSupabase()
+ const vouchers = [
+ makeSIEVoucher({ description: 'Lön', number: 1 }),
+ makeSIEVoucher({ description: 'Lön', number: 2 }),
+ makeSIEVoucher({ description: 'Lön', number: 3 }),
+ ]
+
+ const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
+ expect(result).toBe(0)
+ })
+
+ it('skips split bookings with more than 5 business accounts', async () => {
+ const { supabase } = createQueuedMockSupabase()
+ const vouchers = Array.from({ length: 3 }, (_, i) =>
+ makeSIEVoucher({
+ description: 'Complex booking',
+ number: i + 1,
+ lines: [
+ { account: '1930', amount: -6000 },
+ { account: '6200', amount: 1000 },
+ { account: '6210', amount: 1000 },
+ { account: '6220', amount: 1000 },
+ { account: '6230', amount: 1000 },
+ { account: '6240', amount: 1000 },
+ { account: '6250', amount: 1000 },
+ ],
+ })
+ )
+
+ const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
+ expect(result).toBe(0)
+ })
+
+ it('extracts VAT treatment from 2641 line', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ const vouchers = Array.from({ length: 3 }, (_, i) =>
+ makeSIEVoucher({
+ description: 'Kontorsmaterial AB',
+ number: i + 1,
+ lines: [
+ { account: '1930', amount: -1250 },
+ { account: '6100', amount: 1000 },
+ { account: '2641', amount: 250 },
+ ],
+ })
+ )
+
+ enqueue({ data: [] }) // pre-fetch
+ enqueue({ data: null }) // insert
+
+ const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
+ expect(result).toBe(1)
+ })
+
+ it('filters out old vouchers beyond recency window', async () => {
+ const { supabase } = createQueuedMockSupabase()
+ const recentDate = new Date(2024, 5, 15)
+ const oldDate = new Date(2021, 0, 1) // >24 months before recent
+
+ const vouchers = [
+ makeSIEVoucher({ description: 'Old Company', date: oldDate, number: 1 }),
+ makeSIEVoucher({ description: 'Old Company', date: oldDate, number: 2 }),
+ makeSIEVoucher({ description: 'Old Company', date: oldDate, number: 3 }),
+ makeSIEVoucher({ description: 'Recent Company', date: recentDate, number: 4 }),
+ ]
+
+ // Only 1 recent voucher for "Recent Company" → below min count → 0 templates
+ const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
+ expect(result).toBe(0)
+ })
+
+ it('computes SIE confidence formula correctly', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ // 40 occurrences, 38 dominant (95% dominance)
+ const vouchers: ReturnType
[] = []
+ for (let i = 0; i < 38; i++) {
+ vouchers.push(makeSIEVoucher({
+ description: 'Telia',
+ number: i + 1,
+ lines: [{ account: '1930', amount: -1000 }, { account: '6212', amount: 1000 }],
+ }))
+ }
+ for (let i = 0; i < 2; i++) {
+ vouchers.push(makeSIEVoucher({
+ description: 'Telia',
+ number: 39 + i,
+ lines: [{ account: '1930', amount: -1000 }, { account: '6230', amount: 1000 }],
+ }))
+ }
+
+ enqueue({ data: [] }) // pre-fetch
+ enqueue({ data: null }) // insert
+
+ const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
+ expect(result).toBe(1)
+ // dominance = 38/40 = 0.95, confidence = 0.95 * (1 - 1/38) ≈ 0.925 → rounded to 0.92
+ })
+
+ it('recognizes 1510 (receivables) as settlement account', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ const vouchers = Array.from({ length: 3 }, (_, i) =>
+ makeSIEVoucher({
+ description: 'Kund AB',
+ number: i + 1,
+ lines: [
+ { account: '1510', amount: 10000 },
+ { account: '3001', amount: -10000 },
+ ],
+ })
+ )
+
+ enqueue({ data: [] }) // pre-fetch
+ enqueue({ data: null }) // insert
+
+ const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
+ expect(result).toBe(1)
+ })
+
+ it('recognizes 2890 (credit card) as settlement account', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ const vouchers = Array.from({ length: 3 }, (_, i) =>
+ makeSIEVoucher({
+ description: 'Företaget AB',
+ number: i + 1,
+ lines: [
+ { account: '2890', amount: -1000 },
+ { account: '6200', amount: 1000 },
+ ],
+ })
+ )
+
+ enqueue({ data: [] }) // pre-fetch
+ enqueue({ data: null }) // insert
+
+ const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
+ expect(result).toBe(1)
+ })
+
+ it('end-to-end: multiple counterparties, filters correctly', async () => {
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ const vouchers = [
+ // Telia: 5 vouchers, all same pattern → should produce template
+ ...Array.from({ length: 5 }, (_, i) =>
+ makeSIEVoucher({
+ description: 'Telia',
+ number: i + 1,
+ lines: [{ account: '1930', amount: -500 }, { account: '6212', amount: 500 }],
+ })
+ ),
+ // ICA: 4 vouchers, all same pattern → should produce template
+ ...Array.from({ length: 4 }, (_, i) =>
+ makeSIEVoucher({
+ description: 'ICA Maxi',
+ number: 10 + i,
+ lines: [{ account: '1930', amount: -200 }, { account: '4010', amount: 200 }],
+ })
+ ),
+ // Rare: 1 voucher → below minimum, no template
+ makeSIEVoucher({
+ description: 'Rare Supplier',
+ number: 20,
+ lines: [{ account: '1930', amount: -100 }, { account: '6590', amount: 100 }],
+ }),
+ ]
+
+ enqueue({ data: [] }) // pre-fetch
+ enqueue({ data: null }) // insert 1
+ enqueue({ data: null }) // insert 2
+
+ const result = await populateTemplatesFromSieVouchers(supabase as never, 'user-1', vouchers)
+ expect(result).toBe(2)
+ })
+ })
})
diff --git a/lib/bookkeeping/counterparty-templates.ts b/lib/bookkeeping/counterparty-templates.ts
index 15923de3..8a20f14b 100644
--- a/lib/bookkeeping/counterparty-templates.ts
+++ b/lib/bookkeeping/counterparty-templates.ts
@@ -12,11 +12,13 @@ import type {
CategorizationTemplate,
CategorizationTemplateSource,
EntityType,
+ LinePatternEntry,
MappingResult,
Transaction,
VatJournalLine,
VatTreatment,
} from '@/types'
+import type { SIEVoucher } from '@/lib/import/types'
// ── Normalization ──────────────────────────────────────────────
@@ -45,6 +47,22 @@ export function normalizeCounterpartyName(raw: string): string {
// ── Confidence ─────────────────────────────────────────────────
+// ── Display ───────────────────────────────────────────────────
+
+/** Swedish company suffixes that should be uppercased */
+const UPPER_SUFFIXES = new Set(['ab', 'hb', 'kb', 'ek', 'ef', 'uf'])
+
+/**
+ * Capitalize a normalized counterparty name for display.
+ * "telia sverige ab" → "Telia Sverige AB"
+ */
+export function formatCounterpartyName(name: string): string {
+ return name
+ .split(' ')
+ .map(w => UPPER_SUFFIXES.has(w) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1))
+ .join(' ')
+}
+
/**
* Logarithmic confidence formula.
* Starts low, grows slowly, caps at 0.95. Early corrections are cheap,
@@ -55,6 +73,42 @@ export function calculateConfidence(occurrenceCount: number): number {
return Math.round(Math.min(raw, 0.95) * 100) / 100
}
+// ── Source Priority ───────────────────────────────────────────
+
+const SOURCE_PRIORITY: Record = {
+ sni_default: 0,
+ auto_learned: 1,
+ sie_import: 2,
+ user_approved: 3,
+}
+
+export function resolveSource(
+ existing: CategorizationTemplateSource,
+ incoming: CategorizationTemplateSource
+): CategorizationTemplateSource {
+ return SOURCE_PRIORITY[incoming] >= SOURCE_PRIORITY[existing] ? incoming : existing
+}
+
+// ── Counterparty Template ID Convention ──────────────────────
+
+export const COUNTERPARTY_PREFIX = 'counterparty:'
+export function isCounterpartyTemplateId(id: string): boolean { return id.startsWith(COUNTERPARTY_PREFIX) }
+export function extractCounterpartyId(id: string): string { return id.slice(COUNTERPARTY_PREFIX.length) }
+export function toCounterpartyTemplateId(id: string): string { return COUNTERPARTY_PREFIX + id }
+
+// ── VAT Account Mapping ──────────────────────────────────────
+
+const VAT_ACCOUNT_TREATMENT: Record = {
+ '2611': 'standard_25',
+ '2621': 'reduced_12',
+ '2631': 'reduced_6',
+ '2641': 'standard_25',
+ '2645': 'reverse_charge',
+ '2614': 'reverse_charge',
+ '2624': 'reverse_charge',
+ '2634': 'reverse_charge',
+}
+
// ── Lookup ─────────────────────────────────────────────────────
export interface CounterpartyTemplateMatch {
@@ -66,9 +120,9 @@ export interface CounterpartyTemplateMatch {
/**
* Find a counterparty template matching a transaction.
*
- * Three-tier matching:
- * 1. Exact alias match — GIN index on counterparty_aliases array
- * 2. Exact normalized name match — UNIQUE index
+ * Three-tier matching (delegated to batch version with single-element array):
+ * 1. Exact alias match
+ * 2. Exact normalized name match
* 3. Fuzzy Levenshtein — distance ≤2 for short names, ≤3 for long names
*/
export async function findCounterpartyTemplate(
@@ -76,78 +130,96 @@ export async function findCounterpartyTemplate(
userId: string,
transaction: Transaction
): Promise {
- const rawName = transaction.merchant_name || transaction.description
- if (!rawName) return null
+ const results = await findCounterpartyTemplatesBatch(supabase, userId, [transaction])
+ return results.get(transaction.id) ?? null
+}
- const normalized = normalizeCounterpartyName(rawName)
- if (!normalized || normalized.length < 2) return null
+/**
+ * Batch counterparty template matching for multiple transactions.
+ * One DB query, all matching done in memory.
+ */
+export async function findCounterpartyTemplatesBatch(
+ supabase: SupabaseClient,
+ userId: string,
+ transactions: Transaction[]
+): Promise