Files
accounted/extensions/general/arcim-migration/lib/mapping-targets.ts
T
Pierre Grönberg bac9e01e2d fix(arcim): offer the company's own accounts as mapping targets (#2164)
The Fortnox migration's account mapping step builds its target dropdown
from BAS_REFERENCE alone: the 1290 standard accounts. Any account a
company created outside the standard cannot be selected as a target.

Seen on a live Fortnox migration: 3005 "Provisioner inom Sverige" was
active in the chart, returned by /api/bookkeeping/accounts, visible
everywhere else in the app, and missing from this one list, because BAS
defines 3000-3004 and stops there.

The plain SIE import at app/(dashboard)/import already used the
company's own chart (fetchAccounts(false)), so the two routes into the
same AccountMappingStep disagreed about what could be mapped onto.

Targets are now the company chart unioned with BAS, deduplicated by
account number with the company row winning: its name is whatever the
user renamed the account to, and that is the label they look for. BAS
stays for standard accounts a first migration is about to create, and a
failed chart read degrades to BAS rather than throwing, since an
incomplete list still lets the migration proceed while an exception
stops it.
2026-09-04 08:56:56 +02:00

75 lines
2.9 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
/** What the mapping step offers as a target, and what it renders per option. */
export interface MappingTarget {
account_number: string
account_name: string
/** Grouping in the dropdown. Derived from the number when a row lacks it. */
account_class?: number
}
/** BAS numbers are 4 digits and the first is the class: 3005 is class 3. */
function classOf(accountNumber: string): number | undefined {
const first = Number.parseInt(accountNumber.slice(0, 1), 10)
return Number.isFinite(first) ? first : undefined
}
/**
* The accounts a Fortnox source account may be mapped onto: the company's own
* chart first, then the BAS catalogue for standard accounts it has not created
* yet.
*
* Why both. BAS alone hides every account a company added outside the
* standard, and there are plenty: BAS defines 3000-3004 and stops, so an
* account like 3005 "Provisioner inom Sverige" can be active in the chart,
* listed everywhere else in the app, and still impossible to select as a
* mapping target. The company
* chart alone would be wrong in the other direction: on a first migration it
* can be nearly empty, and the user is mapping onto standard accounts the
* import is about to create.
*
* On a collision the company row wins. Its name is whatever the user renamed
* the account to, and that is the label they are looking for in the list.
*
* A failed read degrades to BAS rather than throwing: an incomplete list still
* lets the migration proceed, an error stops it dead.
*/
export async function buildMappingTargets(
supabase: SupabaseClient,
companyId: string,
): Promise<MappingTarget[]> {
let own: MappingTarget[] = []
try {
const rows = await fetchAllRows(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, account_class')
.eq('company_id', companyId)
.order('account_number')
.range(from, to),
)
own = (rows as Array<Record<string, unknown>>).map((r) => ({
account_number: String(r.account_number),
account_name: String(r.account_name ?? ''),
account_class:
typeof r.account_class === 'number' ? r.account_class : classOf(String(r.account_number)),
}))
} catch {
own = []
}
const seen = new Set(own.map((a) => a.account_number))
const fromBas = BAS_REFERENCE.filter((b) => !seen.has(b.account_number)).map((b) => ({
account_number: b.account_number,
account_name: b.account_name,
account_class: classOf(b.account_number),
}))
// Sorted by number so the dropdown's per-class groups read in order
// regardless of which source a given account came from.
return [...own, ...fromBas].sort((a, b) => a.account_number.localeCompare(b.account_number))
}