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:
Jakob Wennberg
2026-05-14 16:59:29 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 04f902fe8f
commit 722b22972d
6 changed files with 605 additions and 53 deletions
+96 -4
View File
@@ -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) => {