Files
accounted/lib/import/account-mapper.ts
T
Jakob Wennberg 39e407644d 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>
2026-02-25 16:59:02 +01:00

246 lines
6.6 KiB
TypeScript

/**
* Account Mapping Engine
*
* Maps accounts from an imported SIE file to the user's BAS chart of accounts.
* 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 {
SIEAccount,
AccountMapping,
AccountMatchType,
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.
* 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: MappableAccount[],
existingOverride?: AccountMapping
): AccountMapping | null {
// If there's a user override, use it
if (existingOverride) {
return {
...existingOverride,
isOverride: true,
}
}
// Exact account number match against reference
const exactMatch = basAccounts.find(
(target) => source.number === target.account_number
)
if (exactMatch) {
return {
sourceAccount: source.number,
sourceName: source.name,
targetAccount: exactMatch.account_number,
targetName: exactMatch.account_name,
confidence: 1.0,
matchType: 'exact',
isOverride: false,
}
}
// 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
}
/**
* Suggest mappings for all source accounts
*/
export function suggestMappings(
sourceAccounts: SIEAccount[],
basAccounts: MappableAccount[],
existingMappings?: SIEAccountMappingRecord[]
): AccountMapping[] {
// Convert existing mappings to a lookup map
const overrideMap = new Map<string, AccountMapping>()
if (existingMappings) {
for (const mapping of existingMappings) {
const basAccount = basAccounts.find((a) => a.account_number === mapping.target_account)
overrideMap.set(mapping.source_account, {
sourceAccount: mapping.source_account,
sourceName: mapping.source_name || '',
targetAccount: mapping.target_account,
targetName: basAccount?.account_name || mapping.target_account,
confidence: mapping.confidence,
matchType: mapping.match_type,
isOverride: true,
})
}
}
const mappings: AccountMapping[] = []
for (const source of sourceAccounts) {
const existingOverride = overrideMap.get(source.number)
const mapping = findBestMatch(source, basAccounts, existingOverride)
if (mapping) {
mappings.push(mapping)
} else {
// No match found - add with null target
mappings.push({
sourceAccount: source.number,
sourceName: source.name,
targetAccount: '',
targetName: '',
confidence: 0,
matchType: 'manual',
isOverride: false,
})
}
}
// Sort by confidence (low to high) so users see problematic mappings first
mappings.sort((a, b) => a.confidence - b.confidence)
return mappings
}
/**
* Validate that all accounts are mapped
*/
export function validateMappings(mappings: AccountMapping[]): {
valid: boolean
unmappedAccounts: string[]
lowConfidenceAccounts: string[]
} {
const unmapped = mappings.filter((m) => !m.targetAccount)
const lowConfidence = mappings.filter((m) => m.targetAccount && m.confidence < 0.5)
return {
valid: unmapped.length === 0,
unmappedAccounts: unmapped.map((m) => m.sourceAccount),
lowConfidenceAccounts: lowConfidence.map((m) => m.sourceAccount),
}
}
/**
* Get mapping statistics
*/
export function getMappingStats(mappings: AccountMapping[]): {
total: number
mapped: number
unmapped: number
exact: number
basRange: number
name: number
class: number
manual: number
lowConfidence: number
averageConfidence: number
} {
const total = mappings.length
const mapped = mappings.filter((m) => m.targetAccount).length
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
const lowConfidence = mappings.filter((m) => m.targetAccount && m.confidence < 0.5).length
const confidenceSum = mappings
.filter((m) => m.targetAccount)
.reduce((sum, m) => sum + m.confidence, 0)
const averageConfidence = mapped > 0 ? confidenceSum / mapped : 0
return {
total,
mapped,
unmapped,
exact,
basRange,
name,
class: classMatch,
manual,
lowConfidence,
averageConfidence,
}
}
/**
* Apply a user override to the mappings
*/
export function applyMappingOverride(
mappings: AccountMapping[],
sourceAccount: string,
targetAccount: string,
targetName: string
): AccountMapping[] {
return mappings.map((m) => {
if (m.sourceAccount === sourceAccount) {
return {
...m,
targetAccount,
targetName,
confidence: 1.0,
matchType: 'manual' as AccountMatchType,
isOverride: true,
}
}
return m
})
}
/**
* Convert mappings to a lookup map for quick access during import
*/
export function mappingsToMap(mappings: AccountMapping[]): Map<string, string> {
const map = new Map<string, string>()
for (const mapping of mappings) {
if (mapping.targetAccount) {
map.set(mapping.sourceAccount, mapping.targetAccount)
}
}
return map
}