Files
accounted/lib/import/account-sync.ts
T
Jakob Wennberg f7cd1b86e7 fix(import): preserve customized SIE #KONTO account names (#669)
* feat(import): add syncMappedAccounts helper for account create + rename

Single home for the create-missing-accounts logic that exists in three
near-identical copies (executeSIEImport, the SIE execute route, and the
arcim-migration extension), plus a new rename pass that carries customized
SIE #KONTO names into accounts that already exist (e.g. K1-seeded defaults).

The file's name applies only to identity mappings (source === target);
remapped targets keep their BAS/current name. With updateAccountNames=false
the behavior matches the legacy code exactly. Not wired up yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(import): preserve SIE #KONTO account names; add updateAccountNames option

Customer report: account names customized in Fortnox did not follow into
Accounted via SIE import. The import always used BAS default names for
accounts in the BAS reference and never touched accounts that already
existed (the K1-seeded chart), so the file's names were silently dropped.

executeSIEImport now routes account creation through syncMappedAccounts,
which prefers the file's #KONTO name for identity-mapped accounts and
renames existing accounts whose name differs (surfaced as a warning).
New option updateAccountNames (default true) restores the old behavior
when disabled. The duplicated pre-create blocks in the execute route and
the arcim-migration extension are removed — executeSIEImport owns account
sync on every path now, including the Fortnox re-sync (idempotent renames).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(mcp): expose update_account_names on gnubok_import_sie

Optional boolean on the tool schema, staged into the pending operation and
threaded through commitImportSie to executeSIEImport. Defaults to true at
both stage and commit time — the commit-side default also covers operations
staged before the param existed (Boolean(undefined) would have silently
flipped it off).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): v1 SIE import generated no account mappings

The route passed [] as mappings to executeSIEImport, which the
mapping-coverage guard (added in #613) rejects for any real file — and
before that guard, every voucher was silently skipped as unmapped. The
route has never produced a working import for files with vouchers.

Generate mappings server-side from the file's #KONTO records plus stored
per-company overrides (same as the dashboard execute route), reject
unmappable files with a clean 400 before the operation row is created,
and expose options.updateAccountNames (default true).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(import): "Använd kontonamn från filen" toggle in import review step

New switch (default on) controlling whether the SIE file's #KONTO names
are carried into the chart of accounts. Helper text shows how many
identity-mapped accounts carry names that differ from the BAS defaults.
The page already serializes the whole options object to the execute
route, so no further wiring is needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(import): address PR #669 review — parallel renames, rename audit trail

- Rename pass now runs UPDATEs concurrently in bounded batches of 25
  (greptile P2): a re-sync with many custom names no longer serializes
  N round trips, and a pathological full-chart rename cannot stampede
  the API. Per-rename failures stay non-fatal via Promise.allSettled.
- Persist the per-account rename detail (number, from, to) into
  sie_imports.migration_documentation as accountRenames — the
  behandlingshistorik record per BFNAR 2013:2 (swedish-compliance
  review); the result warnings only carry the count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:35:49 +02:00

235 lines
8.7 KiB
TypeScript

/**
* Chart-of-accounts synchronization for SIE imports.
*
* Single home for the "ensure every mapped target account exists" logic that
* previously lived in three near-identical copies (executeSIEImport, the
* /api/import/sie/execute route, and the arcim-migration extension), plus the
* rename pass that carries customized #KONTO names from the SIE file into
* accounts that already exist (e.g. the K1-seeded defaults).
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { getBASReference, type BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
import { computeSRUCode } from '@/lib/bookkeeping/bas-data/sru-mapping'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { AccountMapping } from './types'
export interface AccountSyncResult {
/** Accounts inserted into chart_of_accounts */
created: number
/** Existing accounts whose name was updated from the SIE file */
renamed: number
/** Detail of each rename, for warnings/logging */
renamedAccounts: Array<{ accountNumber: string; from: string; to: string }>
/** Renames that failed (non-fatal — the import proceeds with old names) */
renameFailed: number
/** Fatal error from the create pass; null on success */
error: string | null
}
function emptyResult(): AccountSyncResult {
return { created: 0, renamed: 0, renamedAccounts: [], renameFailed: 0, error: null }
}
/**
* Build a chart_of_accounts insert row with the richest metadata available:
* BAS reference when the number is in BAS_REFERENCE (incl. description and
* k2_excluded), otherwise derived from the account number.
*/
function buildInsertRow(
accountNumber: string,
accountName: string,
basRef: BASReferenceAccount | undefined,
companyId: string,
userId: string,
) {
const sortOrder = /^\d+$/.test(accountNumber) ? parseInt(accountNumber, 10) : null
if (basRef) {
return {
user_id: userId,
company_id: companyId,
account_number: accountNumber,
account_name: accountName,
account_class: basRef.account_class,
account_group: basRef.account_group,
account_type: basRef.account_type,
normal_balance: basRef.normal_balance,
sru_code: basRef.sru_code ?? computeSRUCode(accountNumber),
k2_excluded: basRef.k2_excluded,
plan_type: 'full_bas' as const,
is_active: true,
is_system_account: false,
description: basRef.description,
sort_order: sortOrder,
}
}
// Sub-account not in the BAS reference (e.g. 1932 Sparkonto). Derive
// metadata from the account number.
const classified = classifyAccount(accountNumber)
return {
user_id: userId,
company_id: companyId,
account_number: accountNumber,
account_name: accountName,
account_class: parseInt(accountNumber.charAt(0), 10),
account_group: accountNumber.substring(0, 2),
account_type: classified.account_type,
normal_balance: classified.normal_balance,
sru_code: computeSRUCode(accountNumber),
plan_type: 'full_bas' as const,
is_active: true,
is_system_account: false,
description: accountName,
sort_order: sortOrder,
}
}
/**
* Ensure every mapped target account exists in chart_of_accounts and,
* when `updateAccountNames` is true, carry the SIE file's #KONTO names into
* the chart.
*
* Name resolution: the file's name applies only to IDENTITY mappings
* (sourceAccount === targetAccount with a non-empty sourceName) — when the
* user remaps a source to a different target, the file name describes the
* source account, not the target, so the target keeps its BAS/current name.
*
* - Create: account_name = file name (identity, flag on)
* ?? BAS reference name
* ?? targetName/sourceName fallback (non-BAS numbers)
* ?? `Konto ${number}`.
* - Rename (flag on only): existing accounts that are identity targets and
* whose stored name differs from the file name get a scoped UPDATE of
* account_name — and nothing else. Applies to is_system_account rows too
* (K1-seeded defaults); the flag itself is never touched. Equal names are
* a no-op, so replace-mode re-imports (Fortnox re-sync) are idempotent.
*
* When `updateAccountNames` is false the behavior matches the legacy code
* exactly: BAS defaults on create, existing accounts untouched.
*/
export async function syncMappedAccounts(
supabase: SupabaseClient,
companyId: string,
userId: string,
mappings: AccountMapping[],
updateAccountNames: boolean,
): Promise<AccountSyncResult> {
const result = emptyResult()
const targetAccounts = [...new Set(
mappings.filter((m) => m.targetAccount).map((m) => m.targetAccount)
)]
if (targetAccounts.length === 0) return result
// The SIE file's name for each identity-mapped account. Last write wins on
// duplicate #KONTO records (rare, benign).
const desiredNames = new Map<string, string>()
if (updateAccountNames) {
for (const m of mappings) {
const name = m.sourceName?.trim()
if (name && m.targetAccount && m.sourceAccount === m.targetAccount) {
desiredNames.set(m.targetAccount, name)
}
}
}
// Legacy create-time fallback for numbers outside the BAS reference.
const fallbackNames = new Map<string, string>()
for (const m of mappings) {
const fallback = m.targetName || m.sourceName
if (m.targetAccount && fallback) fallbackNames.set(m.targetAccount, fallback)
}
// Fetch the company's chart once (paged) and filter in JS — avoids a huge
// .in() URL for full-chart imports and the silent 1000-row PostgREST cap.
let existingByNumber: Map<string, string>
try {
const targetSet = new Set(targetAccounts)
const allAccounts = await fetchAllRows<{ account_number: string; account_name: string }>(
({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('company_id', companyId)
.range(from, to)
)
existingByNumber = new Map(
allAccounts
.filter((a) => targetSet.has(a.account_number))
.map((a) => [a.account_number, a.account_name])
)
} catch (err) {
result.error = err instanceof Error ? err.message : 'Failed to load chart of accounts'
return result
}
// Create pass: insert missing target accounts.
const missing = targetAccounts.filter((num) => !existingByNumber.has(num))
if (missing.length > 0) {
const inserts = missing.map((num) => {
const basRef = getBASReference(num)
const name =
desiredNames.get(num) ??
basRef?.account_name ??
fallbackNames.get(num) ??
`Konto ${num}`
return buildInsertRow(num, name, basRef, companyId, userId)
})
const { error: insertError } = await supabase.from('chart_of_accounts').insert(inserts)
// A duplicate means a concurrent import (or the replace flow) created the
// account between our read and write — the account exists, which is all
// this pass guarantees.
if (insertError && !insertError.message.includes('duplicate')) {
result.error = insertError.message
return result
}
result.created = missing.length
}
// Rename pass: carry the file's names into existing accounts. The diff set
// is small (only names that actually changed), so the UPDATEs run
// concurrently in bounded batches — a full-chart re-sync must not serialize
// N round trips, but also must not stampede the API with 1000+ in flight.
if (updateAccountNames) {
const renames: Array<{ num: string; from: string; to: string }> = []
for (const [num, currentName] of existingByNumber) {
const desired = desiredNames.get(num)
if (desired && desired !== currentName) {
renames.push({ num, from: currentName, to: desired })
}
}
const RENAME_BATCH_SIZE = 25
for (let i = 0; i < renames.length; i += RENAME_BATCH_SIZE) {
const batch = renames.slice(i, i + RENAME_BATCH_SIZE)
const outcomes = await Promise.allSettled(
batch.map(async ({ num, to }) => {
const { error: updateError } = await supabase
.from('chart_of_accounts')
.update({ account_name: to })
.eq('company_id', companyId)
.eq('account_number', num)
if (updateError) throw new Error(updateError.message)
})
)
outcomes.forEach((outcome, idx) => {
if (outcome.status === 'rejected') {
// Non-fatal: the import is still correct with the old name.
result.renameFailed++
return
}
result.renamed++
const { num, from, to } = batch[idx]
result.renamedAccounts.push({ accountNumber: num, from, to })
})
}
}
return result
}