86f0b70fdd
* fix(vat): complete account treatment enforcement * docs(api): refresh account endpoint skill * fix(mcp): preserve ruta 05 compatibility * test(vat): seed migration constraint fixtures * docs(vat): clarify treatment precedence --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
299 lines
11 KiB
TypeScript
299 lines
11 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'
|
|
import {
|
|
defaultRateForVatTreatment,
|
|
isAccountVatTreatment,
|
|
isVatTreatmentAllowedForAccountClass,
|
|
type AccountVatTreatment,
|
|
} from '@/lib/vat/account-vat-treatment'
|
|
|
|
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,
|
|
vatDefaults?: { treatment: AccountVatTreatment | null; rate: number | null },
|
|
) {
|
|
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,
|
|
default_vat_treatment: vatDefaults?.treatment ?? null,
|
|
default_vat_rate: vatDefaults?.rate ?? null,
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
default_vat_treatment: vatDefaults?.treatment ?? null,
|
|
default_vat_rate: vatDefaults?.rate ?? null,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
}
|
|
|
|
const vatDefaults = new Map<string, { treatment: AccountVatTreatment | null; rate: number | null }>()
|
|
for (const mapping of mappings) {
|
|
if (
|
|
!mapping.vatTreatmentReviewed ||
|
|
!mapping.targetAccount ||
|
|
mapping.sourceAccount !== mapping.targetAccount
|
|
) continue
|
|
if (
|
|
mapping.defaultVatTreatment !== null &&
|
|
mapping.defaultVatTreatment !== undefined &&
|
|
!isAccountVatTreatment(mapping.defaultVatTreatment)
|
|
) {
|
|
result.error = `Invalid VAT treatment for account ${mapping.sourceAccount}`
|
|
return result
|
|
}
|
|
const accountClass = Number(mapping.targetAccount.charAt(0))
|
|
if (
|
|
mapping.defaultVatTreatment &&
|
|
!isVatTreatmentAllowedForAccountClass(mapping.defaultVatTreatment, accountClass)
|
|
) {
|
|
result.error = `VAT treatment is not valid for account ${mapping.sourceAccount}`
|
|
return result
|
|
}
|
|
vatDefaults.set(mapping.targetAccount, {
|
|
treatment: mapping.defaultVatTreatment ?? null,
|
|
rate: mapping.defaultVatTreatment && mapping.defaultVatRate == null
|
|
? defaultRateForVatTreatment(mapping.defaultVatTreatment, accountClass)
|
|
: mapping.defaultVatRate ?? null,
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
// Stable total order on the unique account_number: paging is only
|
|
// correct with a deterministic order, else rows duplicate/skip across
|
|
// pages (see fetch-all.ts ordering invariant). The result is read into
|
|
// a Map below, so this order is invisible to callers.
|
|
.order('account_number', { ascending: true })
|
|
.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, vatDefaults.get(num))
|
|
})
|
|
|
|
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
|
|
}
|
|
|
|
const existingVatUpdates = [...vatDefaults]
|
|
.filter(([account]) => existingByNumber.has(account))
|
|
for (const [account, defaults] of existingVatUpdates) {
|
|
const { error: vatUpdateError } = await supabase
|
|
.from('chart_of_accounts')
|
|
.update({
|
|
default_vat_treatment: defaults.treatment,
|
|
default_vat_rate: defaults.rate,
|
|
})
|
|
.eq('company_id', companyId)
|
|
.eq('account_number', account)
|
|
if (vatUpdateError) {
|
|
result.error = vatUpdateError.message
|
|
return result
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|