Files
accounted/lib/import/account-mapper.ts
T
Jakob Wennberg ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

282 lines
8.0 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.
*/
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).
if (isValidBASRange(source.number) && source.name) {
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
}