diff --git a/extensions/general/enable-banking/__tests__/accounts-route.test.ts b/extensions/general/enable-banking/__tests__/accounts-route.test.ts index 1866d5d5..64063373 100644 --- a/extensions/general/enable-banking/__tests__/accounts-route.test.ts +++ b/extensions/general/enable-banking/__tests__/accounts-route.test.ts @@ -37,6 +37,8 @@ interface SupabaseStub { * Falls back to updateError when the index isn't present. */ updateErrorByCall?: Array<{ message: string } | null> + /** BAS account numbers that exist in the company's chart_of_accounts (PR 2 ledger validation). */ + chartAccountNumbers?: string[] /** Last update payload (may be overwritten by a follow-up metadata update). */ capturedUpdate?: Record /** All update payloads in order — first is the status flip, second the initial-sync metadata. */ @@ -49,26 +51,44 @@ function buildSupabase(stub: SupabaseStub) { auth: { getUser: vi.fn().mockResolvedValue({ data: { user: stub.authUser }, error: null }), }, - from: vi.fn(() => ({ - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ - data: stub.connectionRow, - error: stub.connectionError ?? null, - }), - update: vi.fn((payload: Record) => { - const callIndex = updateCallCount++ - stub.capturedUpdate = payload - ;(stub.capturedUpdates ??= []).push(payload) - const error = - stub.updateErrorByCall && callIndex < stub.updateErrorByCall.length - ? stub.updateErrorByCall[callIndex] - : stub.updateError ?? null + from: vi.fn((table: string) => { + // The chart_of_accounts query is used for ledger_account validation + // (PR 487). It chains select().eq().in() and is awaited as a thenable. + if (table === 'chart_of_accounts') { + const numbers = stub.chartAccountNumbers ?? [] return { - eq: vi.fn().mockResolvedValue({ error }), + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + in: vi.fn((_col: string, vals: string[]) => { + const data = vals + .filter(v => numbers.includes(v)) + .map(v => ({ account_number: v })) + return Promise.resolve({ data, error: null }) + }), } - }), - })), + } + return { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ + data: stub.connectionRow, + error: stub.connectionError ?? null, + }), + update: vi.fn((payload: Record) => { + const callIndex = updateCallCount++ + stub.capturedUpdate = payload + ;(stub.capturedUpdates ??= []).push(payload) + // Per-call error overrides win when set; fall back to updateError otherwise. + const error = + stub.updateErrorByCall && callIndex < stub.updateErrorByCall.length + ? stub.updateErrorByCall[callIndex] + : stub.updateError ?? null + return { + eq: vi.fn().mockResolvedValue({ error }), + } + }), + } + }), } } @@ -584,4 +604,250 @@ describe('PATCH /accounts (enable-banking)', () => { expect(stub.capturedUpdates).toHaveLength(2) }) }) + + describe('per-account ledger mapping (account_mappings)', () => { + it('persists ledger_account from account_mappings into accounts_data JSONB', async () => { + mockedSync.mockResolvedValue({ imported: 0, duplicates: 0, errors: 0 }) + + const stub: SupabaseStub = { + authUser: { id: 'user-1' }, + chartAccountNumbers: ['1930', '1932', '1933'], + connectionRow: { + id: 'conn-1', + status: 'pending_selection', + accounts_data: [ + { uid: 'acc-sek', currency: 'SEK', enabled: true }, + { uid: 'acc-eur', currency: 'EUR', enabled: true }, + { uid: 'acc-usd', currency: 'USD', enabled: true }, + ], + }, + } + const supabase = buildSupabase(stub) + const ctx = makeContext(supabase) + + const res = await accountsRoute.handler( + makeRequest({ + connection_id: 'conn-1', + enabled_uids: ['acc-sek', 'acc-eur', 'acc-usd'], + account_mappings: [ + { uid: 'acc-sek', ledger_account: '1930' }, + { uid: 'acc-eur', ledger_account: '1932' }, + { uid: 'acc-usd', ledger_account: '1933' }, + ], + }), + ctx + ) + + expect(res.status).toBe(200) + const written = stub.capturedUpdates?.[0]?.accounts_data as StoredAccount[] + expect(written.find(a => a.uid === 'acc-sek')?.ledger_account).toBe('1930') + expect(written.find(a => a.uid === 'acc-eur')?.ledger_account).toBe('1932') + expect(written.find(a => a.uid === 'acc-usd')?.ledger_account).toBe('1933') + }) + + it('rejects ledger_account not in BAS class 19 (e.g. 3001 revenue)', async () => { + // Even though 3001 might exist in the chart, routing the bank-side leg + // there would silently misroute every transaction into a revenue account. + // The class-19 restriction must be enforced at the API layer regardless of + // whether the chart contains the supplied account number. + const stub: SupabaseStub = { + authUser: { id: 'user-1' }, + chartAccountNumbers: ['1930', '3001'], + connectionRow: { + id: 'conn-1', + status: 'pending_selection', + accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true }], + }, + } + const supabase = buildSupabase(stub) + const ctx = makeContext(supabase) + + const res = await accountsRoute.handler( + makeRequest({ + connection_id: 'conn-1', + enabled_uids: ['acc-1'], + account_mappings: [{ uid: 'acc-1', ledger_account: '3001' }], + }), + ctx + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toMatch(/klass 19/) + }) + + it('rejects ledger_account that is malformed (not 4 digits)', async () => { + const stub: SupabaseStub = { + authUser: { id: 'user-1' }, + chartAccountNumbers: ['1930'], + connectionRow: { + id: 'conn-1', + status: 'pending_selection', + accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true }], + }, + } + const supabase = buildSupabase(stub) + const ctx = makeContext(supabase) + + const res = await accountsRoute.handler( + makeRequest({ + connection_id: 'conn-1', + enabled_uids: ['acc-1'], + account_mappings: [{ uid: 'acc-1', ledger_account: '19' }], + }), + ctx + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toMatch(/klass 19/) + }) + + it('rejects ledger_account that does not exist in chart_of_accounts', async () => { + const stub: SupabaseStub = { + authUser: { id: 'user-1' }, + chartAccountNumbers: ['1930'], // 1932 not in chart + connectionRow: { + id: 'conn-1', + status: 'pending_selection', + accounts_data: [{ uid: 'acc-eur', currency: 'EUR', enabled: true }], + }, + } + const supabase = buildSupabase(stub) + const ctx = makeContext(supabase) + + const res = await accountsRoute.handler( + makeRequest({ + connection_id: 'conn-1', + enabled_uids: ['acc-eur'], + account_mappings: [{ uid: 'acc-eur', ledger_account: '1932' }], + }), + ctx + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toMatch(/finns inte i kontoplanen/) + expect(body.invalid_accounts).toEqual(['1932']) + }) + + it('preserves existing ledger_account when account_mappings is omitted (selection edit)', async () => { + const stub: SupabaseStub = { + authUser: { id: 'user-1' }, + connectionRow: { + id: 'conn-1', + status: 'active', + accounts_data: [ + { uid: 'acc-1', currency: 'SEK', enabled: true, ledger_account: '1930' }, + { uid: 'acc-2', currency: 'EUR', enabled: true, ledger_account: '1932' }, + ], + }, + } + const supabase = buildSupabase(stub) + const ctx = makeContext(supabase) + + const res = await accountsRoute.handler( + // No account_mappings — pure selection edit (disable acc-2) + makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-1'] }), + ctx + ) + + expect(res.status).toBe(200) + const written = stub.capturedUpdates?.[0]?.accounts_data as StoredAccount[] + // Both ledger_account values stay intact even though acc-2 is now disabled. + expect(written.find(a => a.uid === 'acc-1')?.ledger_account).toBe('1930') + expect(written.find(a => a.uid === 'acc-2')?.ledger_account).toBe('1932') + }) + + it('clears ledger_account when account_mappings entry sets it to null', async () => { + mockedSync.mockResolvedValue({ imported: 0, duplicates: 0, errors: 0 }) + + const stub: SupabaseStub = { + authUser: { id: 'user-1' }, + chartAccountNumbers: ['1930'], + connectionRow: { + id: 'conn-1', + status: 'active', + accounts_data: [ + { uid: 'acc-1', currency: 'SEK', enabled: true, ledger_account: '1930' }, + ], + }, + } + const supabase = buildSupabase(stub) + const ctx = makeContext(supabase) + + const res = await accountsRoute.handler( + makeRequest({ + connection_id: 'conn-1', + enabled_uids: ['acc-1'], + account_mappings: [{ uid: 'acc-1', ledger_account: null }], + }), + ctx + ) + + expect(res.status).toBe(200) + const written = stub.capturedUpdates?.[0]?.accounts_data as StoredAccount[] + expect(written.find(a => a.uid === 'acc-1')?.ledger_account).toBeUndefined() + }) + + it('rejects account_mappings that is not an array', async () => { + const stub: SupabaseStub = { + authUser: { id: 'user-1' }, + connectionRow: { + id: 'conn-1', + status: 'pending_selection', + accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true }], + }, + } + const supabase = buildSupabase(stub) + const ctx = makeContext(supabase) + + const res = await accountsRoute.handler( + makeRequest({ + connection_id: 'conn-1', + enabled_uids: ['acc-1'], + account_mappings: 'not-an-array', + }), + ctx + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toMatch(/account_mappings/) + }) + + it('rejects account_mappings with UIDs not in the connection accounts_data', async () => { + // Mirrors the enabled_uids guard. Without this, a typo'd UID is silently + // dropped (the entry never lands in accounts_data) while the response is + // still 200, leaving the client to believe the mapping was applied. + const stub: SupabaseStub = { + authUser: { id: 'user-1' }, + chartAccountNumbers: ['1930', '1932'], + connectionRow: { + id: 'conn-1', + status: 'pending_selection', + accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true }], + }, + } + const supabase = buildSupabase(stub) + const ctx = makeContext(supabase) + + const res = await accountsRoute.handler( + makeRequest({ + connection_id: 'conn-1', + enabled_uids: ['acc-1'], + account_mappings: [ + { uid: 'acc-1', ledger_account: '1930' }, + { uid: 'acc-typo', ledger_account: '1932' }, // not in accounts_data + ], + }), + ctx + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error).toMatch(/account_mappings/) + expect(body.unknown_uids).toEqual(['acc-typo']) + }) + }) }) diff --git a/extensions/general/enable-banking/components/AccountPickerDialog.tsx b/extensions/general/enable-banking/components/AccountPickerDialog.tsx index 2776d45b..dc7c130b 100644 --- a/extensions/general/enable-banking/components/AccountPickerDialog.tsx +++ b/extensions/general/enable-banking/components/AccountPickerDialog.tsx @@ -37,12 +37,28 @@ interface AccountPickerDialogProps { onSaved: () => void } +interface ChartAccount { + account_number: string + account_name: string +} + const LOOKBACK_OPTIONS = [ { days: 90, label: 'Senaste 90 dagar (PSD2 standard, rekommenderas)' }, { days: 180, label: 'Senaste 180 dagar' }, { days: 365, label: 'Senaste 365 dagar' }, ] as const +// Suggested BAS account per currency. The mapping engine falls back to 1930 +// when ledger_account is unset, so the SEK case is just an explicit hint. +// Foreign-currency accounts default to the BAS-recommended numbers; if the +// company hasn't created them yet, the user must pick or seed them first. +const CURRENCY_DEFAULTS: Record = { + SEK: '1930', + EUR: '1932', + USD: '1933', + GBP: '1934', +} + export function AccountPickerDialog({ open, onOpenChange, @@ -64,16 +80,26 @@ export function AccountPickerDialog({ const [lookbackDays, setLookbackDays] = useState(90) const [sieLastDate, setSieLastDate] = useState(null) const [showCustomLookback, setShowCustomLookback] = useState(false) + const [chartAccounts, setChartAccounts] = useState([]) + const [ledgerByUid, setLedgerByUid] = useState>({}) useEffect(() => { if (open) { - // Start the dialog reflecting the current state. Accounts without an - // explicit enabled flag are treated as enabled (back-compat). const initial = new Set( accounts.filter(a => a.enabled !== false).map(a => a.uid) ) setSelected(initial) setShowCustomLookback(false) + + // Pre-populate ledger picks from existing StoredAccount values, falling + // back to currency-based suggestions for accounts the user hasn't mapped yet. + const initialLedger: Record = {} + for (const a of accounts) { + const fromStored = a.ledger_account + const fromDefault = CURRENCY_DEFAULTS[a.currency] ?? '' + initialLedger[a.uid] = fromStored ?? fromDefault + } + setLedgerByUid(initialLedger) } }, [open, accounts]) @@ -99,7 +125,6 @@ export function AccountPickerDialog({ const fye = (data as { fiscal_year_end?: string } | null)?.fiscal_year_end || null setSieLastDate(fye) if (fye) { - // Anchor lookback to (today - (fye + 1 day)), clamped to [30, 365]. const dayAfter = new Date(fye) dayAfter.setDate(dayAfter.getDate() + 1) const days = Math.ceil((Date.now() - dayAfter.getTime()) / (24 * 60 * 60 * 1000)) @@ -111,6 +136,24 @@ export function AccountPickerDialog({ return () => { cancelled = true } }, [open, isInitialSelection, company?.id, supabase]) + // Load 19xx accounts from the chart for the per-account ledger combobox. + // Class 19 = bank/cash on the BAS chart. + useEffect(() => { + if (!open || !company?.id) return + let cancelled = false + ;(async () => { + const { data } = await supabase + .from('chart_of_accounts') + .select('account_number, account_name') + .eq('company_id', company.id) + .like('account_number', '19%') + .order('account_number', { ascending: true }) + if (cancelled) return + setChartAccounts((data as ChartAccount[] | null) || []) + })() + return () => { cancelled = true } + }, [open, company?.id, supabase]) + const allSelected = accounts.length > 0 && selected.size === accounts.length const noneSelected = selected.size === 0 @@ -119,6 +162,22 @@ export function AccountPickerDialog({ [accounts] ) + // Detect cases where the user routed two enabled accounts with different + // currencies to the same BAS account — usually a mistake, but allowed. + const currencyConflicts = useMemo(() => { + const byLedger = new Map>() + for (const a of accounts) { + if (!selected.has(a.uid)) continue + const ledger = ledgerByUid[a.uid] + if (!ledger) continue + if (!byLedger.has(ledger)) byLedger.set(ledger, new Set()) + byLedger.get(ledger)!.add(a.currency) + } + return Array.from(byLedger.entries()) + .filter(([, currencies]) => currencies.size > 1) + .map(([ledger, currencies]) => ({ ledger, currencies: Array.from(currencies) })) + }, [accounts, selected, ledgerByUid]) + function toggle(uid: string) { setSelected(prev => { const next = new Set(prev) @@ -146,14 +205,34 @@ export function AccountPickerDialog({ return } + // Block save when any enabled account has no ledger picked. The currency + // defaults cover SEK/EUR/USD/GBP; other currencies require an explicit pick. + const missingLedger = accounts.filter(a => selected.has(a.uid) && !ledgerByUid[a.uid]) + if (missingLedger.length > 0) { + toast({ + title: 'Välj bokföringskonto', + description: `Saknar bokföringskonto för: ${missingLedger.map(a => a.name || a.iban || a.uid).join(', ')}`, + variant: 'destructive', + }) + return + } + setIsSaving(true) try { + // Send a mapping entry per selected account. Account_mappings doesn't + // include disabled accounts — their existing ledger_account stays untouched. + const account_mappings = Array.from(selected).map(uid => ({ + uid, + ledger_account: ledgerByUid[uid] || null, + })) + const response = await fetch('/api/extensions/ext/enable-banking/accounts', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ connection_id: connectionId, enabled_uids: Array.from(selected), + account_mappings, ...(isInitialSelection ? { initial_lookback_days: lookbackDays } : {}), }), }) @@ -164,7 +243,6 @@ export function AccountPickerDialog({ throw new Error(data.error || 'Kunde inte spara kontoval') } - // Surface the actual backfill result so the user sees what the bank returned. if (isInitialSelection && data.initial_sync) { const { imported, returned_min_date, returned_max_date } = data.initial_sync as { imported: number @@ -204,7 +282,6 @@ export function AccountPickerDialog({ } } - // Day-after-SIE date for the callout const dayAfterSie = useMemo(() => { if (!sieLastDate) return null const d = new Date(sieLastDate) @@ -214,13 +291,13 @@ export function AccountPickerDialog({ return ( - + Välj konton att synka — {bankName} {isInitialSelection - ? 'Banken har gett åtkomst till följande konton. Avmarkera de konton du inte vill synka transaktioner från. Inga transaktioner hämtas innan du sparar.' - : 'Justera vilka konton som ska synkas. Konton du avmarkerar slutar synkas från nästa körning; redan importerade transaktioner ligger kvar.'} + ? 'Banken har gett åtkomst till följande konton. Avmarkera de konton du inte vill synka transaktioner från, och välj vilket bokföringskonto varje konto ska bokföras mot. Inga transaktioner hämtas innan du sparar.' + : 'Justera vilka konton som ska synkas och vilka bokföringskonton de bokförs mot. Konton du avmarkerar slutar synkas från nästa körning; redan importerade transaktioner ligger kvar.'} @@ -318,38 +395,85 @@ export function AccountPickerDialog({ + {currencyConflicts.length > 0 && ( +
+ Varning: samma bokföringskonto används för flera valutor — + {currencyConflicts.map(c => ` ${c.ledger} (${c.currencies.join(', ')})`).join(';')}. + Det fungerar tekniskt men gör årsskifte med valutaomvärdering svårare. +
+ )} +
{sortedAccounts.map(account => { const isChecked = selected.has(account.uid) + const ledger = ledgerByUid[account.uid] || '' + const ledgerExistsInChart = chartAccounts.some(c => c.account_number === ledger) return ( -
diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts index 367abe7d..4af0e38d 100644 --- a/extensions/general/enable-banking/index.ts +++ b/extensions/general/enable-banking/index.ts @@ -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((_, reject) => { diff --git a/extensions/general/enable-banking/lib/__tests__/sync.test.ts b/extensions/general/enable-banking/lib/__tests__/sync.test.ts index f2c63dcb..1cde0d1b 100644 --- a/extensions/general/enable-banking/lib/__tests__/sync.test.ts +++ b/extensions/general/enable-banking/lib/__tests__/sync.test.ts @@ -280,4 +280,66 @@ describe('syncAccountTransactions', () => { expect(result.returnedMinBookingDate).toBeUndefined() expect(result.returnedMaxBookingDate).toBeUndefined() }) + + it('passes account.ledger_account as IngestOptions.settlementAccount when set', async () => { + mockGetAllTransactionsWithRaw.mockResolvedValue({ + transactions: [{ transaction_amount: { amount: '100', currency: 'EUR' }, booking_date: '2026-04-01' }], + rawPages: ['{}'], + }) + mockConvertTransaction.mockReturnValue({ + id: 'tx-1', + date: '2026-04-01', + booking_date: '2026-04-01', + amount: 100, + currency: 'EUR', + description: 'EUR purchase', + }) + mockUploadDocument.mockResolvedValue({ id: 'doc-1' }) + + const account = makeAccount({ uid: 'eur-acc', currency: 'EUR', ledger_account: '1932' }) + await syncAccountTransactions( + {} as never, + COMPANY_ID, + USER_ID, + CONNECTION_ID, + account, + '2026-02-13', + '2026-05-13', + mockIngest + ) + + const ingestOptions = mockIngest.mock.calls[0][4] + expect(ingestOptions).toMatchObject({ settlementAccount: '1932' }) + }) + + it('omits settlementAccount when account.ledger_account is unset (mapping engine defaults to 1930)', async () => { + mockGetAllTransactionsWithRaw.mockResolvedValue({ + transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2026-04-01' }], + rawPages: ['{}'], + }) + mockConvertTransaction.mockReturnValue({ + id: 'tx-1', + date: '2026-04-01', + booking_date: '2026-04-01', + amount: 100, + currency: 'SEK', + description: 'SEK purchase', + }) + mockUploadDocument.mockResolvedValue({ id: 'doc-1' }) + + const account = makeAccount() // No ledger_account + await syncAccountTransactions( + {} as never, + COMPANY_ID, + USER_ID, + CONNECTION_ID, + account, + '2026-02-13', + '2026-05-13', + mockIngest + ) + + const ingestOptions = mockIngest.mock.calls[0][4] + expect(ingestOptions.settlementAccount).toBeUndefined() + }) }) diff --git a/extensions/general/enable-banking/lib/sync.ts b/extensions/general/enable-banking/lib/sync.ts index 379136be..c640b1ec 100644 --- a/extensions/general/enable-banking/lib/sync.ts +++ b/extensions/general/enable-banking/lib/sync.ts @@ -116,6 +116,9 @@ export async function syncAccountTransactions( const ingestOptions: IngestOptions = {} if (syncOptions?.skipAutoCategorization) ingestOptions.skipAutoCategorization = true if (syncOptions?.rawInsertOnly) ingestOptions.rawInsertOnly = true + // Per-account ledger routing — the mapping engine consumes settlementAccount + // for the bank-side leg, falling back to '1930' when unset. + if (account.ledger_account) ingestOptions.settlementAccount = account.ledger_account const ingestResult = await ingest(supabase, companyId, userId, rawTransactions, ingestOptions) console.log('[enable-banking] Ingest result', { diff --git a/extensions/general/enable-banking/types.ts b/extensions/general/enable-banking/types.ts index c3b5e855..dcbc8aa7 100644 --- a/extensions/general/enable-banking/types.ts +++ b/extensions/general/enable-banking/types.ts @@ -11,6 +11,11 @@ export interface StoredAccount { // chosen not to sync transactions from it. Treated as true if missing // (back-compat with rows that predate the per-account toggle). enabled?: boolean + // BAS account number (e.g. '1930', '1932') the bank-side leg of every + // transaction from this account posts to. Null/undefined falls back to the + // mapping engine default (1930). Lets multicurrency setups route SEK→1930, + // EUR→1932, USD→1933, etc., so year-end FX revaluation is clean. + ledger_account?: string } // Re-export API types from the client