feat(enable-banking): per-account ledger mapping for multicurrency setups (#443) — rebase of #487 (#488)
* feat(enable-banking): per-account ledger mapping for multicurrency setups (#443) Today every bank account routes to BAS 1930. Multicurrency setups (Wise, SEB foreign-currency sub-accounts) get pooled into a single SEK ledger account, making year-end FX revaluation a mess. This change lets each bank account under a PSD2 consent map to its own BAS account (SEK→1930, EUR→1932, USD→1933, etc.). The mapping engine already honors IngestOptions.settlementAccount (lib/bookkeeping/mapping-engine.ts:55-57) so the wiring is small: - StoredAccount gains an optional ledger_account field (no migration — bank_connections.accounts_data is already JSONB). - syncAccountTransactions passes account.ledger_account through as settlementAccount so the bank-side leg routes to the right BAS account. - PATCH /accounts accepts account_mappings, validates 4-digit BAS format, and verifies each ledger_account exists in chart_of_accounts before persisting. Selection edits without account_mappings preserve existing values for back-compat. - AccountPickerDialog shows a per-account "Bokför till konto" combobox populated from the company's 19xx accounts, with currency-based defaults (SEK→1930, EUR→1932, USD→1933, GBP→1934) and a non-blocking warning when two enabled accounts route to the same BAS account with different currencies. Reconciliation still scans 1930 only — foreign-currency accounts skip auto-matching until follow-up PR 4 (filed in the plan). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(enable-banking): address review feedback on PR #487 Two real bugs flagged in review: 1. AccountPickerDialog row wrapped a Radix <Checkbox> (which renders as its own <button role="checkbox">) inside <button onClick={toggle}>. Browsers silently flatten nested interactive elements, the Checkbox lost its onCheckedChange handler, and the toggle interaction was effectively broken. Reverted to <label> + <Checkbox onCheckedChange> with the <Select> as a sibling outside the label so clicking it doesn't also toggle. 2. BAS_ACCOUNT_PATTERN was /^[0-9]{4}$/ — accepted 3001 (revenue), 2640 (input VAT), or any 4-digit account that happens to exist in the chart. Direct API calls would bypass the UI's 19% picker filter and silently misroute every bank-side journal-entry leg into the wrong BAS class, corrupting both the ledger and momsdeklaration. Tightened to /^19[0-9]{2}$/ (kassa/bank only). Tests updated to assert non-19xx rejection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(enable-banking): validate account_mappings UIDs against accounts_data A typo'd UID in account_mappings was silently dropped — the entry never landed in the resulting accounts_data while the response was still 200, leaving the client to believe the mapping was applied. Mirror the existing enabled_uids guard: reject unknown UIDs with 400 + unknown_uids in the body. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(enable-banking): catch syncPromise rejections to prevent process crash When the 60s timeout wins the Promise.race, the underlying Promise.all keeps running. A subsequent bank-API rejection has no registered handler, which surfaces as an unhandledRejection — Node 22 (the self-hosted Docker runtime) terminates the process by default on those. The cron self-heals via initial_sync_completed_at IS NULL, so a no-op catch is the right policy: drop the late rejection, let the cron retry. Caught by Greptile review on PR #488. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
04f902fe8f
commit
722b22972d
@@ -456,6 +456,7 @@ export const enableBankingExtension: Extension = {
|
||||
const connection_id = body?.connection_id
|
||||
const enabled_uids = body?.enabled_uids
|
||||
const rawLookback = body?.initial_lookback_days
|
||||
const account_mappings = body?.account_mappings
|
||||
|
||||
if (typeof connection_id !== 'string' || !connection_id) {
|
||||
return NextResponse.json({ error: 'connection_id krävs' }, { status: 400 })
|
||||
@@ -476,6 +477,42 @@ export const enableBankingExtension: Extension = {
|
||||
)
|
||||
}
|
||||
|
||||
// account_mappings is optional. When present, it's an array of
|
||||
// { uid, ledger_account } pairs that route per-account ingest to a
|
||||
// specific BAS account (e.g. EUR account → 1932 instead of the default 1930).
|
||||
// Restrict to BAS class 19 (kassa/bank). Accepting e.g. 3001 (revenue)
|
||||
// or 2640 (input VAT) here would silently misroute every bank-side
|
||||
// journal-entry leg into a revenue/VAT account, corrupting both the
|
||||
// ledger and momsdeklaration. The chart-of-accounts existence check
|
||||
// below is necessary but not sufficient — those accounts likely do
|
||||
// exist in the chart, but they're the wrong class.
|
||||
const BAS_ACCOUNT_PATTERN = /^19[0-9]{2}$/
|
||||
type AccountMapping = { uid: string; ledger_account?: string | null }
|
||||
let mappings: AccountMapping[] = []
|
||||
if (account_mappings !== undefined) {
|
||||
if (!Array.isArray(account_mappings)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'account_mappings måste vara en lista' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
for (const m of account_mappings) {
|
||||
if (!m || typeof m !== 'object' || typeof m.uid !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'account_mappings: varje post kräver uid (sträng)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (m.ledger_account != null && (typeof m.ledger_account !== 'string' || !BAS_ACCOUNT_PATTERN.test(m.ledger_account))) {
|
||||
return NextResponse.json(
|
||||
{ error: 'account_mappings: ledger_account måste vara ett BAS-konto i klass 19 (1900–1999)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
mappings = account_mappings as AccountMapping[]
|
||||
}
|
||||
|
||||
// initial_lookback_days only applies on the pending_selection→active transition.
|
||||
// Default 90 (PSD2 standard); clamp to [30, 365]. Ignored for selection edits.
|
||||
const initialLookbackDays = (() => {
|
||||
@@ -511,11 +548,58 @@ export const enableBankingExtension: Extension = {
|
||||
)
|
||||
}
|
||||
|
||||
// Mirror the enabled_uids guard for account_mappings — without this,
|
||||
// a typo'd UID in the mapping list is silently dropped (the entry
|
||||
// never lands in the resulting accounts_data) while the response is
|
||||
// still 200, leaving the client to believe the mapping was applied.
|
||||
const unknownMappingUids = mappings.map(m => m.uid).filter(uid => !knownUids.has(uid))
|
||||
if (unknownMappingUids.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'account_mappings innehåller okända konto-uid.', unknown_uids: unknownMappingUids },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify any provided ledger_account values actually exist in the
|
||||
// company's chart of accounts. Prevents users from typing arbitrary
|
||||
// numbers via the API and breaking journal entry creation later.
|
||||
const requestedLedgerAccounts = mappings
|
||||
.map(m => m.ledger_account)
|
||||
.filter((a): a is string => typeof a === 'string')
|
||||
if (requestedLedgerAccounts.length > 0) {
|
||||
const { data: chartRows } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number')
|
||||
.eq('company_id', companyId)
|
||||
.in('account_number', requestedLedgerAccounts)
|
||||
const validAccountNumbers = new Set((chartRows || []).map(r => r.account_number as string))
|
||||
const invalid = requestedLedgerAccounts.filter(a => !validAccountNumbers.has(a))
|
||||
if (invalid.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Ett eller flera bokföringskonton finns inte i kontoplanen.',
|
||||
invalid_accounts: invalid,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const enabledSet = new Set(enabled_uids)
|
||||
const updatedAccounts: StoredAccount[] = existing.map(a => ({
|
||||
...a,
|
||||
enabled: enabledSet.has(a.uid),
|
||||
}))
|
||||
const mappingsByUid = new Map(mappings.map(m => [m.uid, m]))
|
||||
const updatedAccounts: StoredAccount[] = existing.map(a => {
|
||||
const mapping = mappingsByUid.get(a.uid)
|
||||
return {
|
||||
...a,
|
||||
enabled: enabledSet.has(a.uid),
|
||||
// Apply ledger_account from mapping when present. Explicit null clears it.
|
||||
// Absent mapping leaves the existing ledger_account untouched (back-compat
|
||||
// with selection-edit calls that don't include account_mappings).
|
||||
...(mapping
|
||||
? { ledger_account: mapping.ledger_account ?? undefined }
|
||||
: {}),
|
||||
}
|
||||
})
|
||||
|
||||
// State machine: only transition pending_selection → active. Once
|
||||
// active, the status field is omitted from the update so the same
|
||||
@@ -622,6 +706,14 @@ export const enableBankingExtension: Extension = {
|
||||
{ strategy: 'longest' }
|
||||
))
|
||||
)
|
||||
// If the timeout wins the race, the underlying Promise.all keeps
|
||||
// running. Without a registered handler, a late rejection from the
|
||||
// bank API would surface as an unhandledRejection — Node 22 (the
|
||||
// self-hosted Docker runtime) terminates the process by default on
|
||||
// those, taking the whole server down. The cron retries the
|
||||
// backfill via initial_sync_completed_at IS NULL, so a no-op
|
||||
// catch is the right policy here.
|
||||
syncPromise.catch(() => {})
|
||||
|
||||
const TIMEOUT_MS = 60_000
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
|
||||
Reference in New Issue
Block a user