feat: unified document inbox, full BAS 2026, and document-transaction matching

- Expand BAS reference from ~180 to ~1,276 accounts (full BAS Kontoplan 2026)
  with K2 exclusion flags, per-class data files, and computed SRU codes
- Evolve invoice inbox into unified document inbox handling invoices, receipts,
  and government letters with AI-powered classification (Claude Haiku Vision)
- Add multi-pass document-to-transaction matching engine with greedy assignment
  for both supplier invoices (reference/amount/date/name) and receipts
  (weighted amount/merchant/date scoring)
- Add supplier invoice matching in transaction ingest pipeline
- Inject booking template suggestions into AI extraction prompts
- Surface matched documents in swipe categorization UI with one-tap booking
- Auto-activate missing BAS accounts during SIE import against full reference
- Add K2 filter toggle in Chart of Accounts manager
- Add receipt confirmation route with BFNAR representation fields
- Add database migrations for K2 support and document matching columns
- Remove obsolete extension migration scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-25 16:59:02 +01:00
co-authored by Claude Opus 4.6
parent 6956a757f3
commit 39e407644d
68 changed files with 19234 additions and 2412 deletions
+114 -18
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import type { BASAccount } from '@/types'
import type { BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
import type { SIEAccount, SIEAccountMappingRecord } from '../types'
import {
suggestMappings,
@@ -36,6 +37,7 @@ function makeBASAccount(number: string, name: string): BASAccount {
default_vat_code: null,
description: null,
sru_code: null,
k2_excluded: false,
sort_order: parseInt(number, 10),
created_at: '2024-01-01',
updated_at: '2024-01-01',
@@ -73,7 +75,7 @@ describe('suggestMappings', () => {
expect(result[0].isOverride).toBe(false)
})
it('returns unmapped entry when no match exists', () => {
it('returns unmapped entry for out-of-range accounts', () => {
const source = [makeSIEAccount('9999', 'Okänt konto')]
const result = suggestMappings(source, basAccounts)
@@ -84,19 +86,40 @@ describe('suggestMappings', () => {
expect(result[0].matchType).toBe('manual')
})
it('does not fuzzy match accounts with similar names', () => {
// 3400 should NOT match 3001 or 3002 despite being in same class
it('self-maps valid BAS-range accounts not in reference via bas_range fallback', () => {
// 3400 is a valid BAS-range account (1000-8999) but not in the fixture list
const source = [makeSIEAccount('3400', 'Försäljning tjänster')]
const result = suggestMappings(source, basAccounts)
expect(result).toHaveLength(1)
expect(result[0].targetAccount).toBe('3400')
expect(result[0].targetName).toBe('Försäljning tjänster')
expect(result[0].confidence).toBe(0.9)
expect(result[0].matchType).toBe('bas_range')
})
it('self-maps sub-accounts not in reference (e.g. 1241 Personbilar)', () => {
const source = [makeSIEAccount('1241', 'Personbilar')]
const result = suggestMappings(source, basAccounts)
expect(result).toHaveLength(1)
expect(result[0].targetAccount).toBe('1241')
expect(result[0].targetName).toBe('Personbilar')
expect(result[0].confidence).toBe(0.9)
expect(result[0].matchType).toBe('bas_range')
})
it('does not self-map accounts outside BAS range (9000+)', () => {
const source = [makeSIEAccount('9100', 'Internt konto')]
const result = suggestMappings(source, basAccounts)
expect(result).toHaveLength(1)
expect(result[0].targetAccount).toBe('')
expect(result[0].confidence).toBe(0)
})
it('does not fuzzy match accounts with similar numbers', () => {
// 2510 should NOT match 2440 despite being in same class
const source = [makeSIEAccount('2510', 'Skatteskulder')]
it('does not self-map non-4-digit account numbers', () => {
const source = [makeSIEAccount('12345', 'Felaktigt kontonummer')]
const result = suggestMappings(source, basAccounts)
expect(result).toHaveLength(1)
@@ -128,21 +151,25 @@ describe('suggestMappings', () => {
expect(result[0].matchType).toBe('manual')
})
it('sorts unmapped accounts first (lowest confidence)', () => {
it('sorts by confidence (lowest first)', () => {
const source = [
makeSIEAccount('1510', 'Kundfordringar'),
makeSIEAccount('9999', 'Okänt konto'),
makeSIEAccount('3400', 'Försäljning tjänster'),
makeSIEAccount('1930', 'Företagskonto'),
]
const result = suggestMappings(source, basAccounts)
expect(result).toHaveLength(3)
expect(result).toHaveLength(4)
// Unmapped (confidence 0) should come first
expect(result[0].sourceAccount).toBe('9999')
expect(result[0].confidence).toBe(0)
// Exact matches (confidence 1.0) come after
expect(result[1].confidence).toBe(1.0)
// bas_range (confidence 0.9) next
expect(result[1].sourceAccount).toBe('3400')
expect(result[1].confidence).toBe(0.9)
// Exact matches (confidence 1.0) come last
expect(result[2].confidence).toBe(1.0)
expect(result[3].confidence).toBe(1.0)
})
it('handles multiple accounts with mixed results', () => {
@@ -155,10 +182,10 @@ describe('suggestMappings', () => {
expect(result).toHaveLength(3)
// All 3 should be mapped: 1510 and 5010 exact, 3400 bas_range
const mapped = result.filter((m) => m.targetAccount)
const unmapped = result.filter((m) => !m.targetAccount)
expect(mapped).toHaveLength(2)
expect(unmapped).toHaveLength(1)
expect(mapped).toHaveLength(3)
expect(mapped.find((m) => m.sourceAccount === '3400')?.matchType).toBe('bas_range')
})
it('handles empty source accounts', () => {
@@ -174,6 +201,48 @@ describe('suggestMappings', () => {
expect(result[0].targetAccount).toBe('')
expect(result[0].confidence).toBe(0)
})
it('accepts BASReferenceAccount objects (full BAS reference)', () => {
const refAccounts: BASReferenceAccount[] = [
{
account_number: '1510',
account_name: 'Kundfordringar',
account_class: 1,
account_group: '15',
account_type: 'asset',
normal_balance: 'debit',
description: 'Kundfordringar',
sru_code: null,
k2_excluded: false,
},
{
account_number: '2440',
account_name: 'Leverantörsskulder',
account_class: 2,
account_group: '24',
account_type: 'liability',
normal_balance: 'credit',
description: 'Leverantörsskulder',
sru_code: null,
k2_excluded: false,
},
]
const source = [
makeSIEAccount('1510', 'Kundfordringar'),
makeSIEAccount('2440', 'Leverantörsskulder'),
makeSIEAccount('9999', 'Okänt konto'),
]
const result = suggestMappings(source, refAccounts)
expect(result).toHaveLength(3)
const mapped = result.filter((m) => m.targetAccount)
const unmapped = result.filter((m) => !m.targetAccount)
expect(mapped).toHaveLength(2)
expect(unmapped).toHaveLength(1)
expect(mapped.find((m) => m.sourceAccount === '1510')?.confidence).toBe(1.0)
})
})
describe('validateMappings', () => {
@@ -188,7 +257,7 @@ describe('validateMappings', () => {
expect(validation.unmappedAccounts).toHaveLength(0)
})
it('returns invalid when accounts are unmapped', () => {
it('returns invalid when out-of-range accounts are unmapped', () => {
const mappings = suggestMappings(
[makeSIEAccount('1510', 'Kundfordringar'), makeSIEAccount('9999', 'Okänt konto')],
basAccounts
@@ -200,6 +269,17 @@ describe('validateMappings', () => {
expect(validation.unmappedAccounts).toHaveLength(1)
})
it('returns valid when all accounts mapped via exact + bas_range', () => {
const mappings = suggestMappings(
[makeSIEAccount('1510', 'Kundfordringar'), makeSIEAccount('1241', 'Personbilar')],
basAccounts
)
const validation = validateMappings(mappings)
expect(validation.valid).toBe(true)
expect(validation.unmappedAccounts).toHaveLength(0)
})
it('detects low confidence accounts', () => {
// With exact-match-only mapper, low confidence only comes from existing overrides
const mappings = [
@@ -236,18 +316,20 @@ describe('getMappingStats', () => {
expect(stats.unmapped).toBe(1)
})
it('counts match types correctly', () => {
it('counts match types correctly including bas_range', () => {
const mappings = suggestMappings(
[
makeSIEAccount('1510', 'Kundfordringar'),
makeSIEAccount('9999', 'Okänt konto'),
makeSIEAccount('1510', 'Kundfordringar'), // exact
makeSIEAccount('1241', 'Personbilar'), // bas_range
makeSIEAccount('9999', 'Okänt konto'), // manual (unmapped)
],
basAccounts
)
const stats = getMappingStats(mappings)
expect(stats.exact).toBe(1)
expect(stats.manual).toBe(1) // unmapped gets matchType 'manual'
expect(stats.basRange).toBe(1)
expect(stats.manual).toBe(1)
expect(stats.name).toBe(0)
expect(stats.class).toBe(0)
})
@@ -267,6 +349,20 @@ describe('getMappingStats', () => {
expect(stats.averageConfidence).toBe(1.0)
})
it('includes bas_range in average confidence calculation', () => {
const mappings = suggestMappings(
[
makeSIEAccount('1510', 'Kundfordringar'), // exact, confidence 1.0
makeSIEAccount('1241', 'Personbilar'), // bas_range, confidence 0.9
],
basAccounts
)
const stats = getMappingStats(mappings)
// Average of (1.0 + 0.9) / 2 = 0.95
expect(stats.averageConfidence).toBe(0.95)
})
it('returns 0 average confidence when nothing is mapped', () => {
const mappings = suggestMappings(
[makeSIEAccount('9999', 'Okänt konto')],
+46 -6
View File
@@ -2,12 +2,12 @@
* Account Mapping Engine
*
* Maps accounts from an imported SIE file to the user's BAS chart of accounts.
* Uses exact account number matching only — no fuzzy/heuristic matching.
* Uses exact account number matching against the BAS reference, with a fallback
* for valid BAS-range sub-accounts (1000-8999) not in the reference.
* This aligns with Swedish industry standard (e.g. Fortnox): exact match,
* create new, or let the user map manually.
*/
import type { BASAccount } from '@/types'
import type {
SIEAccount,
AccountMapping,
@@ -15,13 +15,35 @@ import type {
SIEAccountMappingRecord,
} from './types'
/**
* Minimal account shape needed for mapping.
* Both BASAccount (from user chart) and BASReferenceAccount (from reference data)
* satisfy this interface.
*/
export type MappableAccount = {
account_number: string
account_name: string
}
/**
* Check if an account number is in the valid BAS range (1000-8999).
* Standard Swedish BAS accounts are 4-digit numbers in classes 1-8.
*/
function isValidBASRange(accountNumber: string): boolean {
if (!/^\d{4}$/.test(accountNumber)) return false
const num = parseInt(accountNumber, 10)
return num >= 1000 && num <= 8999
}
/**
* Find the best matching BAS account for a source account.
* Only matches on exact account number — no fuzzy matching.
* First tries exact match against the reference, then falls back to
* self-mapping for valid BAS-range accounts not in the reference
* (common for sub-accounts like 1241 Personbilar under 1240).
*/
function findBestMatch(
source: SIEAccount,
basAccounts: BASAccount[],
basAccounts: MappableAccount[],
existingOverride?: AccountMapping
): AccountMapping | null {
// If there's a user override, use it
@@ -32,7 +54,7 @@ function findBestMatch(
}
}
// Exact account number match
// Exact account number match against reference
const exactMatch = basAccounts.find(
(target) => source.number === target.account_number
)
@@ -49,6 +71,21 @@ function findBestMatch(
}
}
// Fallback: if the account is a valid BAS-range number (1000-8999),
// self-map it using the name from the SIE file. These are standard
// BAS sub-accounts not in our reference (e.g. 1241 Personbilar).
if (isValidBASRange(source.number) && source.name) {
return {
sourceAccount: source.number,
sourceName: source.name,
targetAccount: source.number,
targetName: source.name,
confidence: 0.9,
matchType: 'bas_range',
isOverride: false,
}
}
// No match found
return null
}
@@ -58,7 +95,7 @@ function findBestMatch(
*/
export function suggestMappings(
sourceAccounts: SIEAccount[],
basAccounts: BASAccount[],
basAccounts: MappableAccount[],
existingMappings?: SIEAccountMappingRecord[]
): AccountMapping[] {
// Convert existing mappings to a lookup map
@@ -132,6 +169,7 @@ export function getMappingStats(mappings: AccountMapping[]): {
mapped: number
unmapped: number
exact: number
basRange: number
name: number
class: number
manual: number
@@ -143,6 +181,7 @@ export function getMappingStats(mappings: AccountMapping[]): {
const unmapped = total - mapped
const exact = mappings.filter((m) => m.matchType === 'exact').length
const basRange = mappings.filter((m) => m.matchType === 'bas_range').length
const name = mappings.filter((m) => m.matchType === 'name').length
const classMatch = mappings.filter((m) => m.matchType === 'class').length
const manual = mappings.filter((m) => m.matchType === 'manual').length
@@ -159,6 +198,7 @@ export function getMappingStats(mappings: AccountMapping[]): {
mapped,
unmapped,
exact,
basRange,
name,
class: classMatch,
manual,
+1 -1
View File
@@ -15,7 +15,7 @@ export type SIEEncoding = 'cp437' | 'utf8'
export type SIEImportStatus = 'pending' | 'mapped' | 'completed' | 'failed'
// Match type for account mapping
export type AccountMatchType = 'exact' | 'name' | 'class' | 'manual'
export type AccountMatchType = 'exact' | 'name' | 'class' | 'manual' | 'bas_range'
// Parse issue severity
export type ParseIssueSeverity = 'error' | 'warning' | 'info'