feat: counterparty templates, Skatteverket extension, complete VAT form (#117)
* 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> --------- 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
3050743013
commit
d8e0a22495
@@ -0,0 +1,369 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockSupabase,
|
||||
createQueuedMockSupabase,
|
||||
makeTransaction,
|
||||
makeCategorizationTemplate,
|
||||
} from '@/tests/helpers'
|
||||
import {
|
||||
normalizeCounterpartyName,
|
||||
calculateConfidence,
|
||||
findCounterpartyTemplate,
|
||||
buildMappingResultFromCounterpartyTemplate,
|
||||
upsertCounterpartyTemplate,
|
||||
} from '../counterparty-templates'
|
||||
|
||||
describe('counterparty-templates', () => {
|
||||
// ── Normalization ──────────────────────────────────────────
|
||||
|
||||
describe('normalizeCounterpartyName', () => {
|
||||
it('strips KORTKÖP prefix', () => {
|
||||
expect(normalizeCounterpartyName('KORTKÖP ICA MAXI')).toBe('ica maxi')
|
||||
})
|
||||
|
||||
it('strips SWISH prefix', () => {
|
||||
expect(normalizeCounterpartyName('SWISH ANDERS JOHANSSON')).toBe('anders johansson')
|
||||
})
|
||||
|
||||
it('strips BANKGIRO prefix', () => {
|
||||
expect(normalizeCounterpartyName('BANKGIRO TELIA SVERIGE AB')).toBe('telia sverige')
|
||||
})
|
||||
|
||||
it('strips AUTOGIRO prefix', () => {
|
||||
expect(normalizeCounterpartyName('AUTOGIRO FOLKSAM')).toBe('folksam')
|
||||
})
|
||||
|
||||
it('strips trailing dates (YYYYMMDD)', () => {
|
||||
expect(normalizeCounterpartyName('ICA MAXI 20240615')).toBe('ica maxi')
|
||||
})
|
||||
|
||||
it('strips trailing dates (YYYY-MM-DD)', () => {
|
||||
expect(normalizeCounterpartyName('TELIA 2024-06-15')).toBe('telia')
|
||||
})
|
||||
|
||||
it('strips inline dates', () => {
|
||||
expect(normalizeCounterpartyName('SPOTIFY 20240615 PREMIUM')).toBe('spotify premium')
|
||||
})
|
||||
|
||||
it('strips invoice references', () => {
|
||||
expect(normalizeCounterpartyName('LEVERANTÖR F2024001')).toBe('leverantör')
|
||||
})
|
||||
|
||||
it('strips trailing digit sequences (4+)', () => {
|
||||
expect(normalizeCounterpartyName('CLAS OHLSON 12345')).toBe('clas ohlson')
|
||||
})
|
||||
|
||||
it('strips Swedish company suffixes (AB, HB, KB)', () => {
|
||||
expect(normalizeCounterpartyName('SPOTIFY AB')).toBe('spotify')
|
||||
})
|
||||
|
||||
it('handles combined prefixes and dates', () => {
|
||||
expect(normalizeCounterpartyName('KORTKÖP TELIA SVERIGE AB 20240615')).toBe('telia sverige')
|
||||
})
|
||||
|
||||
it('preserves meaningful content', () => {
|
||||
expect(normalizeCounterpartyName('HEMKÖP LINNÉ')).toBe('hemköp linné')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Confidence ─────────────────────────────────────────────
|
||||
|
||||
describe('calculateConfidence', () => {
|
||||
it('returns ~0.45 for occurrence_count = 1', () => {
|
||||
const c = calculateConfidence(1)
|
||||
expect(c).toBeCloseTo(0.45, 1)
|
||||
})
|
||||
|
||||
it('grows logarithmically', () => {
|
||||
const c1 = calculateConfidence(1)
|
||||
const c5 = calculateConfidence(5)
|
||||
const c10 = calculateConfidence(10)
|
||||
expect(c5).toBeGreaterThan(c1)
|
||||
expect(c10).toBeGreaterThan(c5)
|
||||
// Growth should slow down
|
||||
expect(c10 - c5).toBeLessThan(c5 - c1)
|
||||
})
|
||||
|
||||
it('caps at 0.95', () => {
|
||||
expect(calculateConfidence(100)).toBe(0.95)
|
||||
expect(calculateConfidence(1000)).toBe(0.95)
|
||||
})
|
||||
|
||||
it('never exceeds 0.95', () => {
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
expect(calculateConfidence(i)).toBeLessThanOrEqual(0.95)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── Lookup ─────────────────────────────────────────────────
|
||||
|
||||
describe('findCounterpartyTemplate', () => {
|
||||
it('returns null for transaction without merchant name', async () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
const tx = makeTransaction({ merchant_name: null, description: '' })
|
||||
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns exact alias match with full confidence', async () => {
|
||||
const template = makeCategorizationTemplate({ confidence: 0.8 })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
|
||||
// Alias match returns the template
|
||||
enqueue({ data: template })
|
||||
|
||||
const tx = makeTransaction({ merchant_name: 'Telia Sverige AB' })
|
||||
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.matchMethod).toBe('exact_alias')
|
||||
expect(result!.confidence).toBe(0.8)
|
||||
})
|
||||
|
||||
it('falls through to exact normalized when alias misses', async () => {
|
||||
const template = makeCategorizationTemplate({ confidence: 0.8 })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
|
||||
enqueue({ data: null }) // Alias miss
|
||||
enqueue({ data: template }) // Exact normalized hit
|
||||
|
||||
const tx = makeTransaction({ merchant_name: 'Telia' })
|
||||
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.matchMethod).toBe('exact_normalized')
|
||||
expect(result!.confidence).toBeCloseTo(0.76, 1) // 0.8 * 0.95
|
||||
})
|
||||
|
||||
it('falls through to fuzzy match within Levenshtein threshold', async () => {
|
||||
const template = makeCategorizationTemplate({
|
||||
counterparty_name: 'telia',
|
||||
confidence: 0.8,
|
||||
})
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
|
||||
enqueue({ data: null }) // Alias miss
|
||||
enqueue({ data: null }) // Exact miss
|
||||
enqueue({ data: [template] }) // Fuzzy: all templates
|
||||
|
||||
// "teliq" has Levenshtein distance 1 from "telia"
|
||||
const tx = makeTransaction({ merchant_name: 'Teliq' })
|
||||
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.matchMethod).toBe('fuzzy')
|
||||
expect(result!.confidence).toBeLessThan(0.8)
|
||||
expect(result!.confidence).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('returns null when fuzzy match exceeds threshold', async () => {
|
||||
const template = makeCategorizationTemplate({
|
||||
counterparty_name: 'telia',
|
||||
confidence: 0.8,
|
||||
})
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
|
||||
enqueue({ data: null }) // Alias miss
|
||||
enqueue({ data: null }) // Exact miss
|
||||
enqueue({ data: [template] }) // Fuzzy: all templates
|
||||
|
||||
// "xxxxx" has Levenshtein distance 5 from "telia"
|
||||
const tx = makeTransaction({ merchant_name: 'XXXXX' })
|
||||
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
const tx = makeTransaction({ merchant_name: 'Unknown Company' })
|
||||
const result = await findCounterpartyTemplate(supabase as never, 'user-1', tx)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Build MappingResult ────────────────────────────────────
|
||||
|
||||
describe('buildMappingResultFromCounterpartyTemplate', () => {
|
||||
it('builds correct MappingResult for expense with VAT', () => {
|
||||
const template = makeCategorizationTemplate({
|
||||
debit_account: '6200',
|
||||
credit_account: '1930',
|
||||
vat_treatment: 'standard_25',
|
||||
occurrence_count: 10,
|
||||
})
|
||||
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.85 }
|
||||
const tx = makeTransaction({ amount: -1250 })
|
||||
|
||||
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
||||
|
||||
expect(result.debit_account).toBe('6200')
|
||||
expect(result.credit_account).toBe('1930')
|
||||
expect(result.confidence).toBe(0.85)
|
||||
expect(result.vat_lines.length).toBe(1)
|
||||
expect(result.vat_lines[0].account_number).toBe('2641')
|
||||
expect(result.vat_lines[0].debit_amount).toBeGreaterThan(0)
|
||||
expect(result.rule).toBeNull()
|
||||
expect(result.description).toContain('telia')
|
||||
expect(result.description).toContain('10 ggr')
|
||||
})
|
||||
|
||||
it('builds correct MappingResult for expense without VAT', () => {
|
||||
const template = makeCategorizationTemplate({
|
||||
debit_account: '6570',
|
||||
credit_account: '1930',
|
||||
vat_treatment: null,
|
||||
})
|
||||
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.9 }
|
||||
const tx = makeTransaction({ amount: -50 })
|
||||
|
||||
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
||||
|
||||
expect(result.debit_account).toBe('6570')
|
||||
expect(result.vat_lines).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('builds correct MappingResult for reverse charge', () => {
|
||||
const template = makeCategorizationTemplate({
|
||||
debit_account: '6540',
|
||||
credit_account: '1930',
|
||||
vat_treatment: 'reverse_charge',
|
||||
})
|
||||
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.8 }
|
||||
const tx = makeTransaction({ amount: -5000 })
|
||||
|
||||
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'aktiebolag')
|
||||
|
||||
expect(result.vat_lines.length).toBe(2)
|
||||
expect(result.vat_lines.some(l => l.account_number === '2645')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not generate VAT lines for income transactions', () => {
|
||||
const template = makeCategorizationTemplate({
|
||||
debit_account: '1930',
|
||||
credit_account: '3001',
|
||||
vat_treatment: 'standard_25',
|
||||
})
|
||||
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.8 }
|
||||
const tx = makeTransaction({ amount: 10000 })
|
||||
|
||||
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
||||
|
||||
expect(result.vat_lines).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('detects private accounts', () => {
|
||||
const template = makeCategorizationTemplate({
|
||||
debit_account: '2013',
|
||||
credit_account: '1930',
|
||||
})
|
||||
const match = { template, matchMethod: 'exact_alias' as const, confidence: 0.8 }
|
||||
const tx = makeTransaction({ amount: -500 })
|
||||
|
||||
const result = buildMappingResultFromCounterpartyTemplate(match, tx, 'enskild_firma')
|
||||
|
||||
expect(result.default_private).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Upsert ─────────────────────────────────────────────────
|
||||
|
||||
describe('upsertCounterpartyTemplate', () => {
|
||||
const mappingResult = {
|
||||
rule: null,
|
||||
debit_account: '5410',
|
||||
credit_account: '1930',
|
||||
risk_level: 'NONE' as const,
|
||||
confidence: 0.9,
|
||||
requires_review: false,
|
||||
default_private: false,
|
||||
vat_lines: [],
|
||||
description: 'Test',
|
||||
}
|
||||
|
||||
it('inserts new template for unknown counterparty', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const tx = makeTransaction({ merchant_name: 'New Company AB', date: '2024-06-15' })
|
||||
|
||||
enqueue({ data: null }) // No existing template
|
||||
enqueue({ data: null }) // Insert succeeds
|
||||
|
||||
await upsertCounterpartyTemplate(
|
||||
supabase as never, 'user-1', tx, mappingResult, 'user_approved'
|
||||
)
|
||||
|
||||
expect(supabase.from).toHaveBeenCalledWith('categorization_templates')
|
||||
})
|
||||
|
||||
it('does not throw on insert error', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const tx = makeTransaction({ merchant_name: 'New Company AB' })
|
||||
|
||||
enqueue({ data: null }) // No existing
|
||||
enqueue({ error: { message: 'constraint violation' } })
|
||||
|
||||
// Should not throw
|
||||
await upsertCounterpartyTemplate(
|
||||
supabase as never, 'user-1', tx, mappingResult, 'user_approved'
|
||||
)
|
||||
})
|
||||
|
||||
it('updates existing template on re-approval', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const existing = makeCategorizationTemplate({
|
||||
debit_account: '5410',
|
||||
credit_account: '1930',
|
||||
occurrence_count: 3,
|
||||
counterparty_aliases: ['ica maxi'],
|
||||
})
|
||||
const tx = makeTransaction({ merchant_name: 'ICA Maxi', date: '2024-07-01' })
|
||||
|
||||
enqueue({ data: existing }) // Existing found
|
||||
enqueue({ data: null }) // Update succeeds
|
||||
|
||||
await upsertCounterpartyTemplate(
|
||||
supabase as never, 'user-1', tx, mappingResult, 'user_approved'
|
||||
)
|
||||
|
||||
expect(supabase.from).toHaveBeenCalledWith('categorization_templates')
|
||||
})
|
||||
|
||||
it('resets occurrence_count on correction', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const existing = makeCategorizationTemplate({
|
||||
debit_account: '6200', // Different from mappingResult's 5410
|
||||
credit_account: '1930',
|
||||
occurrence_count: 10,
|
||||
confidence: 0.85,
|
||||
})
|
||||
const tx = makeTransaction({ merchant_name: 'Telia Sverige AB', date: '2024-07-01' })
|
||||
|
||||
enqueue({ data: existing }) // Existing found (different accounts = correction)
|
||||
enqueue({ data: null }) // Update succeeds
|
||||
|
||||
await upsertCounterpartyTemplate(
|
||||
supabase as never, 'user-1', tx, mappingResult, 'user_approved'
|
||||
)
|
||||
|
||||
expect(supabase.from).toHaveBeenCalledWith('categorization_templates')
|
||||
})
|
||||
|
||||
it('skips upsert for transactions without merchant name', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const tx = makeTransaction({ merchant_name: null, description: '' })
|
||||
|
||||
await upsertCounterpartyTemplate(
|
||||
supabase as never, 'user-1', tx, mappingResult, 'user_approved'
|
||||
)
|
||||
|
||||
expect(supabase.from).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,12 @@ vi.mock('../booking-templates', () => ({
|
||||
buildMappingResultFromTemplate: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock counterparty-templates (needed by evaluateMappingRules)
|
||||
vi.mock('../counterparty-templates', () => ({
|
||||
findCounterpartyTemplate: vi.fn().mockResolvedValue(null),
|
||||
buildMappingResultFromCounterpartyTemplate: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('mapping-engine', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import {
|
||||
normalizeMerchantName,
|
||||
levenshteinDistance,
|
||||
} from '@/lib/documents/core-receipt-matcher'
|
||||
import {
|
||||
generateInputVatLine,
|
||||
generateReverseChargeLines,
|
||||
getVatRate,
|
||||
} from './vat-entries'
|
||||
import type {
|
||||
CategorizationTemplate,
|
||||
CategorizationTemplateSource,
|
||||
EntityType,
|
||||
MappingResult,
|
||||
Transaction,
|
||||
VatJournalLine,
|
||||
VatTreatment,
|
||||
} from '@/types'
|
||||
|
||||
// ── Normalization ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalize a transaction description to a canonical counterparty name.
|
||||
*
|
||||
* Strips bank transfer prefixes, trailing dates, invoice references,
|
||||
* and trailing digit sequences, then delegates to normalizeMerchantName()
|
||||
* for Swedish company suffix removal and lowercasing.
|
||||
*/
|
||||
export function normalizeCounterpartyName(raw: string): string {
|
||||
const cleaned = raw
|
||||
// Strip common bank transfer prefixes
|
||||
.replace(/^(BANKGIRO|SWISH|KORTKÖP|KORT\s*KÖP|PG|BG|AUTOGIRO|PLUSGIRO)\s*/i, '')
|
||||
// Strip dates (20240615, 2024-06-15, 24-06-15)
|
||||
.replace(/\b\d{2,4}[-/]?\d{2}[-/]?\d{2}\b/g, '')
|
||||
// Strip invoice/reference numbers (F2024-001, #12345, INV-123)
|
||||
.replace(/\b[F#]?\d{4,}\S*/gi, '')
|
||||
.replace(/\bINV[-]?\d+/gi, '')
|
||||
// Strip trailing sequences of 4+ digits (card numbers, transaction refs)
|
||||
.replace(/\s+\d{4,}\s*$/g, '')
|
||||
.trim()
|
||||
|
||||
return normalizeMerchantName(cleaned)
|
||||
}
|
||||
|
||||
// ── Confidence ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Logarithmic confidence formula.
|
||||
* Starts low, grows slowly, caps at 0.95. Early corrections are cheap,
|
||||
* later corrections are appropriately alarming.
|
||||
*/
|
||||
export function calculateConfidence(occurrenceCount: number): number {
|
||||
const raw = 0.3 + Math.log2(occurrenceCount + 1) * 0.15
|
||||
return Math.round(Math.min(raw, 0.95) * 100) / 100
|
||||
}
|
||||
|
||||
// ── Lookup ─────────────────────────────────────────────────────
|
||||
|
||||
export interface CounterpartyTemplateMatch {
|
||||
template: CategorizationTemplate
|
||||
matchMethod: 'exact_alias' | 'exact_normalized' | 'fuzzy'
|
||||
confidence: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 3. Fuzzy Levenshtein — distance ≤2 for short names, ≤3 for long names
|
||||
*/
|
||||
export async function findCounterpartyTemplate(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
transaction: Transaction
|
||||
): Promise<CounterpartyTemplateMatch | null> {
|
||||
const rawName = transaction.merchant_name || transaction.description
|
||||
if (!rawName) return null
|
||||
|
||||
const normalized = normalizeCounterpartyName(rawName)
|
||||
if (!normalized || normalized.length < 2) return null
|
||||
|
||||
// 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
|
||||
|
||||
let bestMatch: CategorizationTemplate | null = null
|
||||
let bestDistance = Infinity
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Build MappingResult ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert a counterparty template match into a MappingResult
|
||||
* (same shape the mapping engine expects).
|
||||
*/
|
||||
export function buildMappingResultFromCounterpartyTemplate(
|
||||
match: CounterpartyTemplateMatch,
|
||||
transaction: Transaction,
|
||||
entityType: EntityType
|
||||
): MappingResult {
|
||||
const tmpl = match.template
|
||||
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
|
||||
if (vatTreatment === 'reverse_charge') {
|
||||
const rcLines = generateReverseChargeLines(absAmount)
|
||||
for (const rcl of rcLines) {
|
||||
vatLines.push({
|
||||
account_number: rcl.account_number,
|
||||
debit_amount: rcl.debit_amount,
|
||||
credit_amount: rcl.credit_amount,
|
||||
description: rcl.line_description || '',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const vatRate = getVatRate(vatTreatment)
|
||||
if (vatRate > 0) {
|
||||
const vatLine = generateInputVatLine(absAmount, vatRate)
|
||||
if (vatLine) {
|
||||
vatLines.push({
|
||||
account_number: vatLine.account_number,
|
||||
debit_amount: vatLine.debit_amount,
|
||||
credit_amount: vatLine.credit_amount,
|
||||
description: vatLine.line_description || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if private (debit to private account means non-business)
|
||||
const privateAccounts = ['2013', '2893']
|
||||
const isPrivate = privateAccounts.includes(tmpl.debit_account)
|
||||
|
||||
return {
|
||||
rule: null,
|
||||
debit_account: tmpl.debit_account,
|
||||
credit_account: tmpl.credit_account,
|
||||
risk_level: 'NONE',
|
||||
confidence: match.confidence,
|
||||
requires_review: false,
|
||||
default_private: isPrivate,
|
||||
vat_lines: vatLines,
|
||||
description: `Motpart: ${tmpl.counterparty_name} (${tmpl.occurrence_count} ggr)`,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feedback / Upsert ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export async function upsertCounterpartyTemplate(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
transaction: Transaction,
|
||||
mappingResult: MappingResult,
|
||||
source: CategorizationTemplateSource
|
||||
): Promise<void> {
|
||||
const rawName = transaction.merchant_name || transaction.description
|
||||
if (!rawName) return
|
||||
|
||||
const normalized = normalizeCounterpartyName(rawName)
|
||||
if (!normalized || normalized.length < 2) return
|
||||
|
||||
const 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect VAT treatment from a MappingResult's VAT lines.
|
||||
*/
|
||||
function detectVatTreatment(result: MappingResult): string | null {
|
||||
if (result.vat_lines.length === 0) return null
|
||||
|
||||
// Check for reverse charge (2645 debit = fiktiv ingående)
|
||||
const hasReverseCharge = result.vat_lines.some(
|
||||
(l) => l.account_number === '2645'
|
||||
)
|
||||
if (hasReverseCharge) return 'reverse_charge'
|
||||
|
||||
// Check for input VAT (2641 debit)
|
||||
const inputVat = result.vat_lines.find(
|
||||
(l) => l.account_number === '2641' && l.debit_amount > 0
|
||||
)
|
||||
if (!inputVat) return null
|
||||
|
||||
// Derive rate from the line description (generated by generateInputVatLine)
|
||||
// Format: "Ingående moms 25%", "Ingående moms 12%", "Ingående moms 6%"
|
||||
const rateMatch = inputVat.description?.match(/(\d+)%/)
|
||||
if (rateMatch) {
|
||||
const pct = parseInt(rateMatch[1], 10)
|
||||
if (pct === 12) return 'reduced_12'
|
||||
if (pct === 6) return 'reduced_6'
|
||||
}
|
||||
return 'standard_25'
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
generateReverseChargeLines,
|
||||
} from './vat-entries'
|
||||
import { findMatchingTemplates, buildMappingResultFromTemplate } from './booking-templates'
|
||||
import {
|
||||
findCounterpartyTemplate,
|
||||
buildMappingResultFromCounterpartyTemplate,
|
||||
} from './counterparty-templates'
|
||||
import type {
|
||||
MappingRule,
|
||||
MappingResult,
|
||||
@@ -38,7 +42,9 @@ function getCapitalizationThreshold(year: number): number {
|
||||
* 2. MCC code rules (priority 50-69)
|
||||
* 3. Merchant name pattern rules (priority 70-89)
|
||||
* 4. Amount threshold rules (priority 90-99)
|
||||
* 5. Risk fallback (priority 100) → 2013 (private)
|
||||
* 5. Counterparty templates (learned from history, fuzzy matching)
|
||||
* 6. Static booking templates (keyword/MCC matching)
|
||||
* 7. Default fallback (uncategorized)
|
||||
*/
|
||||
export async function evaluateMappingRules(
|
||||
supabase: SupabaseClient,
|
||||
@@ -55,7 +61,10 @@ export async function evaluateMappingRules(
|
||||
.order('priority', { ascending: true })
|
||||
|
||||
if (error || !rules || rules.length === 0) {
|
||||
// Try template-based matching before default fallback
|
||||
// Try counterparty templates before static template fallback
|
||||
const counterpartyResult = await evaluateCounterpartyTemplates(supabase, userId, transaction, entityType)
|
||||
if (counterpartyResult) return counterpartyResult
|
||||
|
||||
const templateResult = evaluateTemplateRules(transaction, entityType)
|
||||
if (templateResult) return templateResult
|
||||
return getDefaultResult(transaction)
|
||||
@@ -68,6 +77,10 @@ export async function evaluateMappingRules(
|
||||
}
|
||||
}
|
||||
|
||||
// Try counterparty templates before static template fallback
|
||||
const counterpartyResult = await evaluateCounterpartyTemplates(supabase, userId, transaction, entityType)
|
||||
if (counterpartyResult) return counterpartyResult
|
||||
|
||||
// Try template-based matching before default fallback
|
||||
const templateResult = evaluateTemplateRules(transaction, entityType)
|
||||
if (templateResult) return templateResult
|
||||
@@ -97,6 +110,35 @@ function evaluateTemplateRules(
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate counterparty templates as a fallback when no DB mapping rule matches.
|
||||
* Source-aware threshold: auto_learned needs 0.6 (require more evidence),
|
||||
* user_approved/sie_import use 0.4 (human has validated the pattern).
|
||||
*/
|
||||
async function evaluateCounterpartyTemplates(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
transaction: Transaction,
|
||||
entityType?: EntityType
|
||||
): Promise<MappingResult | null> {
|
||||
try {
|
||||
const match = await findCounterpartyTemplate(supabase, userId, transaction)
|
||||
if (!match) return null
|
||||
|
||||
const threshold = match.template.source === 'auto_learned' ? 0.6 : 0.4
|
||||
if (match.confidence < threshold) return null
|
||||
|
||||
return buildMappingResultFromCounterpartyTemplate(
|
||||
match,
|
||||
transaction,
|
||||
entityType || 'enskild_firma'
|
||||
)
|
||||
} catch {
|
||||
// Non-critical — fall through to next fallback
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a transaction matches a mapping rule
|
||||
*/
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('sectors registry', () => {
|
||||
})
|
||||
|
||||
it('should have 8 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(11)
|
||||
expect(getAllExtensions().length).toBe(12)
|
||||
})
|
||||
|
||||
it('should have unique slugs within each sector', () => {
|
||||
@@ -94,7 +94,7 @@ describe('sectors registry', () => {
|
||||
|
||||
it('getExtensionsBySector returns extensions for a sector', () => {
|
||||
const extensions = getExtensionsBySector('general')
|
||||
expect(extensions.length).toBe(11)
|
||||
expect(extensions.length).toBe(12)
|
||||
})
|
||||
|
||||
it('all extensions have required fields', () => {
|
||||
|
||||
@@ -93,6 +93,7 @@ describe('formatPeriodLabel', () => {
|
||||
|
||||
describe('getVatDeclarationSummary', () => {
|
||||
const emptyRc = { ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0, ruta30: 0, ruta31: 0, ruta32: 0 }
|
||||
const zeroExtras = { ruta08: 0, ruta35: 0, ruta36: 0, ruta37: 0, ruta38: 0, ruta41: 0, ruta42: 0, ruta50: 0, ruta60: 0, ruta61: 0, ruta62: 0 }
|
||||
|
||||
it('calculates totals and detects payment', () => {
|
||||
const declaration: VatDeclaration = {
|
||||
@@ -104,6 +105,7 @@ describe('getVatDeclarationSummary', () => {
|
||||
ruta30: 0, ruta31: 0, ruta32: 0,
|
||||
ruta39: 0, ruta40: 0,
|
||||
ruta48: 1000, ruta49: 1500,
|
||||
...zeroExtras,
|
||||
},
|
||||
invoiceCount: 5,
|
||||
transactionCount: 10,
|
||||
@@ -132,6 +134,7 @@ describe('getVatDeclarationSummary', () => {
|
||||
ruta30: 0, ruta31: 0, ruta32: 0,
|
||||
ruta39: 0, ruta40: 0,
|
||||
ruta48: 3000, ruta49: -2500,
|
||||
...zeroExtras,
|
||||
},
|
||||
invoiceCount: 1,
|
||||
transactionCount: 20,
|
||||
@@ -158,6 +161,7 @@ describe('getVatDeclarationSummary', () => {
|
||||
ruta30: 1250, ruta31: 0, ruta32: 0,
|
||||
ruta39: 0, ruta40: 0,
|
||||
ruta48: 2250, ruta49: 1500,
|
||||
...zeroExtras,
|
||||
},
|
||||
invoiceCount: 2,
|
||||
transactionCount: 0,
|
||||
|
||||
@@ -164,12 +164,14 @@ export async function calculateVatDeclaration(
|
||||
|
||||
// Map account balances to momsdeklaration boxes
|
||||
const rutor: VatDeclarationRutor = {
|
||||
ruta05: 0, ruta06: 0, ruta07: 0,
|
||||
ruta05: 0, ruta06: 0, ruta07: 0, ruta08: 0,
|
||||
ruta10: 0, ruta11: 0, ruta12: 0,
|
||||
ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0,
|
||||
ruta30: 0, ruta31: 0, ruta32: 0,
|
||||
ruta39: 0, ruta40: 0,
|
||||
ruta35: 0, ruta36: 0, ruta37: 0, ruta38: 0,
|
||||
ruta39: 0, ruta40: 0, ruta41: 0, ruta42: 0,
|
||||
ruta48: 0, ruta49: 0,
|
||||
ruta50: 0, ruta60: 0, ruta61: 0, ruta62: 0,
|
||||
}
|
||||
|
||||
for (const [account, mapping] of Object.entries(ACCOUNT_RUTA)) {
|
||||
@@ -181,9 +183,11 @@ export async function calculateVatDeclaration(
|
||||
rutor[mapping.box] = round(rutor[mapping.box] + balance)
|
||||
}
|
||||
|
||||
// FK009: summaMoms = (10 + 11 + 12 + 30 + 31 + 32 + 60 + 61 + 62) - 48
|
||||
rutor.ruta49 = round(
|
||||
rutor.ruta10 + rutor.ruta11 + rutor.ruta12 +
|
||||
rutor.ruta30 + rutor.ruta31 + rutor.ruta32 -
|
||||
rutor.ruta30 + rutor.ruta31 + rutor.ruta32 +
|
||||
rutor.ruta60 + rutor.ruta61 + rutor.ruta62 -
|
||||
rutor.ruta48
|
||||
)
|
||||
|
||||
@@ -370,7 +374,10 @@ export function getVatDeclarationSummary(declaration: VatDeclaration): {
|
||||
declaration.rutor.ruta12 +
|
||||
declaration.rutor.ruta30 +
|
||||
declaration.rutor.ruta31 +
|
||||
declaration.rutor.ruta32
|
||||
declaration.rutor.ruta32 +
|
||||
declaration.rutor.ruta60 +
|
||||
declaration.rutor.ruta61 +
|
||||
declaration.rutor.ruta62
|
||||
)
|
||||
|
||||
const totalInputVat = declaration.rutor.ruta48
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { VatPeriodType } from '@/types'
|
||||
|
||||
/**
|
||||
* Convert a gnubok org_number to Skatteverket's 12-digit "redovisare" format.
|
||||
*
|
||||
* Rules:
|
||||
* - Organisationsnummer (10 digits, e.g. 5020000013): prefix with "16" → 165020000013
|
||||
* - Personnummer (10 digits, e.g. 8501011234): prefix with "19" or "20" based on century
|
||||
* - Strip any hyphens before processing
|
||||
*/
|
||||
export function formatRedovisare(
|
||||
orgNumber: string,
|
||||
entityType: 'enskild_firma' | 'aktiebolag'
|
||||
): string {
|
||||
const clean = orgNumber.replace(/-/g, '')
|
||||
|
||||
if (clean.length === 12) return clean
|
||||
|
||||
if (clean.length !== 10) {
|
||||
throw new Error(`Ogiltigt organisationsnummer: ${orgNumber} (förväntar 10 eller 12 siffror)`)
|
||||
}
|
||||
|
||||
if (entityType === 'aktiebolag') return `16${clean}`
|
||||
|
||||
// Enskild firma — personnummer
|
||||
const yearDigits = parseInt(clean.substring(0, 2), 10)
|
||||
const currentTwoDigitYear = new Date().getFullYear() % 100
|
||||
const prefix = yearDigits > currentTwoDigitYear ? '19' : '20'
|
||||
return `${prefix}${clean}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert gnubok period parameters to Skatteverket's YYYYMM format.
|
||||
*
|
||||
* Skatteverket expects the last month of the period.
|
||||
* - monthly period 3, year 2025 → "202503"
|
||||
* - quarterly period 1, year 2025 → "202503" (Q1 ends in March)
|
||||
* - yearly period 1, year 2025 → "202512"
|
||||
*/
|
||||
export function formatRedovisningsperiod(
|
||||
periodType: VatPeriodType,
|
||||
year: number,
|
||||
period: number
|
||||
): string {
|
||||
let lastMonth: number
|
||||
|
||||
switch (periodType) {
|
||||
case 'monthly':
|
||||
lastMonth = period
|
||||
break
|
||||
case 'quarterly':
|
||||
lastMonth = period * 3
|
||||
break
|
||||
case 'yearly':
|
||||
lastMonth = 12
|
||||
break
|
||||
}
|
||||
|
||||
return `${year}${String(lastMonth).padStart(2, '0')}`
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching'
|
||||
import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matching'
|
||||
import { tryReconcileTransaction, fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation'
|
||||
@@ -336,6 +337,16 @@ export async function ingestTransactions(
|
||||
})
|
||||
.eq('id', newTransaction.id)
|
||||
|
||||
// Upsert counterparty template (auto-learned, lower confidence)
|
||||
try {
|
||||
await upsertCounterpartyTemplate(
|
||||
supabase, userId, newTransaction as Transaction,
|
||||
mappingResult, 'auto_learned'
|
||||
)
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
result.auto_categorized++
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user