Files
accounted/lib/import/account-mapper.ts
T
162de2128a fix(import): show "created on import" as the mapping target for source accounts the chart lacks (#2342)
The guided Fortnox import self-mapped a source account that exists in
neither the company chart nor BAS (4599) and then rendered its Malkonto
select blank, because the dropdown only knew chart + BAS accounts. The
row looked unmapped and unmappable while the import created the account
correctly. A nameless account (referenced by #TRANS without #KONTO) was
worse: the mapper refused the self-map, so it stayed unmapped with no
self-target to pick.

- account-mapper: the bas_range self-map no longer requires a #KONTO
  name; unmapped now means exactly "outside 1000-8999". isValidBASRange
  exported as the auto-create boundary.
- AccountMappingStep (shared by both wizards): a target the list cannot
  name is an explicit "<nr> <name> (skapas vid importen)" option, a
  "nya konton skapas" badge/filter lists them, out-of-range accounts
  that block Continue are named, nameless sources say so.
- sie-import: skippedVouchers.unmappedAccounts (per account, voucher
  count) via summarizeUnmappedSkips; warning names the accounts.
- Migration result step: names created accounts and the accounts behind
  "med ej kopplade konton".

Closes #2212


Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:39:36 +02:00

295 lines
8.8 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
}
// Group header accounts that should redirect to their posting sub-account.
// These are BAS group headers not meant for direct posting.
const GROUP_HEADER_REDIRECTS: Record<string, string> = {
'2640': '2641', // Ingående moms → Debiterad ingående moms
}
/**
* Check if an account is a source-system internal account that should be
* excluded from import. BAS accounts use classes 1-8 (1000-8999). Account
* numbers starting with 0 (e.g. Fortnox 0099) are internal system accounts
* with no BAS equivalent: they should be silently filtered out rather than
* forcing the user to map them.
*/
export function isSystemAccount(accountNumber: string): boolean {
if (!/^\d{4}$/.test(accountNumber)) return false
const num = parseInt(accountNumber, 10)
return num < 1000
}
/**
* 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.
*
* This is the auto-create boundary: a source account in this range can
* always be carried into the chart under its own number (the importer derives
* class and type from the number), so it never needs a manual target. Outside
* it (class 9, 5-digit numbers) the user must pick a target.
*/
export 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) {
// Redirect group header accounts to their posting sub-account
const redirect = GROUP_HEADER_REDIRECTS[exactMatch.account_number]
if (redirect) {
const redirectTarget = basAccounts.find((a) => a.account_number === redirect)
if (redirectTarget) {
return {
sourceAccount: source.number,
sourceName: source.name,
targetAccount: redirectTarget.account_number,
targetName: redirectTarget.account_name,
confidence: 1.0,
matchType: 'exact',
isOverride: false,
}
}
}
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), or
// accounts a source system kept outside BAS (e.g. a Fortnox chart's 4599).
//
// A missing name is not a reason to refuse: an account referenced only by
// #TRANS/#IB (no #KONTO row) arrives nameless, and the parser has already
// told the user it will be created. The number alone determines class and
// type; the importer names it "Konto <nr>" when the file has no name.
// Leaving it unmapped offered no way forward except merging it into a
// different account, which is wrong for a ledger migration (issue #2212).
if (isValidBASRange(source.number)) {
return {
sourceAccount: source.number,
sourceName: source.name,
targetAccount: source.number,
targetName: source.name,
confidence: 0.7,
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
}