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:
co-authored by
Claude Opus 4.6
parent
6956a757f3
commit
39e407644d
@@ -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')],
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user