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:
Jakob Wennberg
2026-03-24 13:41:12 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 3050743013
commit d8e0a22495
33 changed files with 3097 additions and 158 deletions
@@ -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()
+355
View File
@@ -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'
}
+44 -2
View File
@@ -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
*/
+2 -2
View File
@@ -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,
+11 -4
View File
@@ -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
+60
View File
@@ -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')}`
}
+11
View File
@@ -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++
}
}