From a3491b6d897abbef47567b29252d3fa17c874142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Flod=C3=A9n?= Date: Thu, 25 Jun 2026 12:40:09 +0200 Subject: [PATCH] fix(transactions): resolve bank account from cash_account_id in bulk-book and direct-book dialogs (#770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #769: applies the same fetch-on-open + resolveAccount pattern to BulkBookDialog (manual tab bank-leg pre-fill) and BookDirectlyDialog (settlement line in buildPrefillLines). Both dialogs now fetch /api/cash-accounts when they open, gate the pre-fill on the fetch resolving, and resolve the correct BAS ledger account per transaction instead of always emitting '1930'. Adds lib/cash-accounts/resolve-account.ts (cherry of the utility from #769) since that PR is not yet merged into main. Signed-off-by: Jonas Flodén --- .../extensions/general/BookDirectlyDialog.tsx | 81 ++++++++++++++++--- components/transactions/BulkBookDialog.tsx | 66 +++++++++++---- lib/cash-accounts/resolve-account.ts | 31 +++++++ 3 files changed, 153 insertions(+), 25 deletions(-) create mode 100644 lib/cash-accounts/resolve-account.ts diff --git a/components/extensions/general/BookDirectlyDialog.tsx b/components/extensions/general/BookDirectlyDialog.tsx index fd370f8d..61fdcc8b 100644 --- a/components/extensions/general/BookDirectlyDialog.tsx +++ b/components/extensions/general/BookDirectlyDialog.tsx @@ -28,7 +28,8 @@ import { import { getErrorMessage } from '@/lib/errors/get-error-message' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' -import type { BASAccount, FiscalPeriod, InvoiceExtractionResult } from '@/types' +import { resolveAccount } from '@/lib/cash-accounts/resolve-account' +import type { BASAccount, CashAccount, FiscalPeriod, InvoiceExtractionResult } from '@/types' interface InboxItem { id: string @@ -77,9 +78,12 @@ interface Props { // a transaction is selected and the document is in a foreign currency, the // transaction's SEK amount is the canonical figure. The cost-account row // stays blank — the user must pick a cost account themselves. +// bankAccount defaults to '1930' but is replaced by the resolved ledger account +// once the cash-accounts fetch completes. function buildPrefillLines( item: InboxItem, - selectedTransactionAmount: number | null = null + selectedTransactionAmount: number | null = null, + bankAccount: string = '1930', ): FormLine[] { const docTotal = item.extracted_data?.totals?.total ?? null const docVat = item.extracted_data?.totals?.vatAmount ?? null @@ -125,7 +129,7 @@ function buildPrefillLines( }) } lines.push({ - account_number: '1930', + account_number: bankAccount, debit_amount: '', credit_amount: String(totalRounded), }) @@ -157,6 +161,9 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess // pending, or unsupported. const [fxRate, setFxRate] = useState(null) + // null = fetch pending; array = loaded (may be empty on error — falls back to '1930') + const [cashAccounts, setCashAccounts] = useState(null) + const [periods, setPeriods] = useState([]) const [accounts, setAccounts] = useState([]) const [entryDate, setEntryDate] = useState( @@ -169,6 +176,9 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess return [supplier, invoiceNum].filter(Boolean).join(' · ') || 'Bokföring från inkorg' }) const [notes, setNotes] = useState('') + // Start with blank lines; they are replaced once cashAccounts resolves (see + // the combined prefill effect below). This mirrors the TransactionBookingDialog + // pattern of gating JournalEntryForm on bankAccount !== null. const [lines, setLines] = useState(() => buildPrefillLines(item)) // Transaction picker — optional selection. @@ -181,11 +191,14 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess const [isSubmitting, setIsSubmitting] = useState(false) - // Reset state when a different item opens the dialog + // Reset state when a different item opens the dialog. We pass bankAccount + // here but it may still be null (fetch in flight) — in that case '1930' is + // used as a placeholder and the prefill-update effect below will overwrite + // the settlement line once the fetch resolves. useEffect(() => { if (!open) return setEntryDate(item.extracted_data?.invoice?.invoiceDate || new Date().toISOString().slice(0, 10)) - setLines(buildPrefillLines(item)) + setLines(buildPrefillLines(item, null, bankAccount ?? '1930')) setSelectedTransactionId(item.matched_transaction_id) const supplier = item.extracted_data?.supplier?.name?.trim() || '' const invoiceNum = item.extracted_data?.invoice?.invoiceNumber?.trim() || '' @@ -218,6 +231,29 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, targetCurrency, item.id]) + // Fetch cash accounts once when the dialog opens so the settlement line can + // be routed to the correct ledger account instead of the hardcoded '1930'. + useEffect(() => { + if (!open) return + setCashAccounts(null) + let cancelled = false + fetch('/api/cash-accounts') + .then((r) => { + if (!r.ok) throw new Error(`cash-accounts fetch failed: ${r.status}`) + return r.json() + }) + .then((json) => { + if (cancelled) return + setCashAccounts((json.data ?? []) as CashAccount[]) + }) + .catch(() => { + // Fall back to empty list — resolveAccount will return '1930' + if (!cancelled) setCashAccounts([]) + }) + return () => { cancelled = true } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, item.id]) + // SEK-equivalent of the underlag total — the anchor for ranking candidates. const targetSek = useMemo(() => { if (targetAmount == null) return null @@ -240,14 +276,37 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess : resolveSekAmount(tx.amount, tx.amount_sek ?? null, tx.currency, tx.exchange_rate ?? null) }, [selectedTransactionId, transactions]) + // The settlement currency to resolve against: + // - When a transaction is selected, use that transaction's currency. + // - Otherwise, use the document's currency (falls back to SEK). + const settlementCurrency = useMemo(() => { + if (selectedTransactionId) { + const tx = transactions.find((t) => t.id === selectedTransactionId) + if (tx) return (tx.currency ?? 'SEK').toUpperCase() + } + return targetCurrency + }, [selectedTransactionId, transactions, targetCurrency]) + + // Resolved bank account — null while the cash-accounts fetch is in flight. + // Derived from the cash accounts list; falls back to '1930' if the list is + // empty or no single-currency match exists. + const bankAccount = useMemo(() => { + if (cashAccounts === null) return null + const { account } = resolveAccount(cashAccounts, null, settlementCurrency) + return account + }, [cashAccounts, settlementCurrency]) + useEffect(() => { if (!open) return - // Update amounts when the transaction selection changes, but preserve - // user-entered account numbers. This handles "user typed cost account, - // then picked an SEK-denominated transaction" — we want the SEK figure - // to flow into the line amounts without forgetting their account pick. + // Update amounts when the transaction selection or resolved bank account + // changes, but preserve user-entered account numbers. This handles "user + // typed cost account, then picked an SEK-denominated transaction" — we + // want the SEK figure to flow into the line amounts without forgetting + // their account pick. bankAccount may be null while the fetch is in flight; + // pass '1930' as a safe placeholder in that case — the effect re-runs once + // the fetch resolves and bankAccount becomes non-null. setLines((current) => { - const next = buildPrefillLines(item, selectedTransactionAmount) + const next = buildPrefillLines(item, selectedTransactionAmount, bankAccount ?? '1930') return next.map((nl, i) => { const existing = current[i] if (!existing) return nl @@ -257,7 +316,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess } }) }) - }, [open, item, selectedTransactionAmount]) + }, [open, item, selectedTransactionAmount, bankAccount]) // Fetch fiscal periods and accounts on first open useEffect(() => { diff --git a/components/transactions/BulkBookDialog.tsx b/components/transactions/BulkBookDialog.tsx index 58ad8668..2e03c02e 100644 --- a/components/transactions/BulkBookDialog.tsx +++ b/components/transactions/BulkBookDialog.tsx @@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from 'react' import { useTranslations } from 'next-intl' import { createClient } from '@/lib/supabase/client' import { useCompany } from '@/contexts/CompanyContext' +import { resolveAccount } from '@/lib/cash-accounts/resolve-account' +import type { CashAccount } from '@/types' import { Dialog, DialogContent, @@ -81,6 +83,8 @@ export default function BulkBookDialog({ const [templates, setTemplates] = useState([]) const [loadingTemplates, setLoadingTemplates] = useState(true) const [selectedTemplateId, setSelectedTemplateId] = useState(null) + // null = fetch pending; array = loaded (may be empty on error — falls back to '1930') + const [cashAccounts, setCashAccounts] = useState(null) const [mode, setMode] = useState('one_line_per_tx') const [description, setDescription] = useState('') const [manualLines, setManualLines] = useState([]) @@ -137,6 +141,28 @@ export default function BulkBookDialog({ } }, [open, company, supabase]) + // Fetch cash accounts once when the dialog opens so the manual bank-leg + // pre-fill can resolve the correct ledger account per transaction. + useEffect(() => { + if (!open) return + setCashAccounts(null) + let cancelled = false + fetch('/api/cash-accounts') + .then((r) => { + if (!r.ok) throw new Error(`cash-accounts fetch failed: ${r.status}`) + return r.json() + }) + .then((json) => { + if (cancelled) return + setCashAccounts((json.data ?? []) as CashAccount[]) + }) + .catch(() => { + // Fall back to empty list — resolveAccount will return '1930' + if (!cancelled) setCashAccounts([]) + }) + return () => { cancelled = true } + }, [open]) + // Reset state when dialog closes so the next open starts clean. useEffect(() => { if (!open) { @@ -145,31 +171,43 @@ export default function BulkBookDialog({ setMode('one_line_per_tx') setDescription('') setManualLines([]) + setCashAccounts(null) } else if (sharedDate) { // Pre-fill description with a sensible default the user can edit. setDescription(t('default_description', { date: sharedDate })) } }, [open, sharedDate, t]) - // Pre-fill the bank side from the txs (one line per tx on 1930 with - // the correct Dr/Cr direction). We intentionally do NOT pre-fill a - // counterpart account: swedish-compliance flagged that a hardcoded - // 3001/5800 prefill nudges users into submitting verifikat without a - // VAT line (26xx) for momsregistrerade affärshändelser. The bank - // side is the unambiguous part the user always wants; the + // Pre-fill the bank side from the txs (one line per tx with the resolved + // ledger account and correct Dr/Cr direction). We intentionally do NOT + // pre-fill a counterpart account: swedish-compliance flagged that a + // hardcoded 3001/5800 prefill nudges users into submitting verifikat + // without a VAT line (26xx) for momsregistrerade affärshändelser. The + // bank side is the unambiguous part the user always wants; the // counterpart (and any VAT split) is the user's responsibility. + // Gate on cashAccounts !== null so lines are only built after the account + // fetch resolves — this prevents the form from briefly showing '1930' when + // the resolved account differs. useEffect(() => { if (tab !== 'manual') return if (manualLines.length > 0) return if (transactions.length === 0) return + if (cashAccounts === null) return const isIncome = direction === 'income' - const bankLines: ManualLine[] = transactions.map((tx) => ({ - id: newManualLineId(), - account_number: '1930', - debit_amount: isIncome ? Math.abs(tx.amount).toFixed(2).replace('.', ',') : '', - credit_amount: isIncome ? '' : Math.abs(tx.amount).toFixed(2).replace('.', ','), - line_description: (tx.description || '').slice(0, 40).trim(), - })) + const bankLines: ManualLine[] = transactions.map((tx) => { + const { account } = resolveAccount( + cashAccounts, + tx.cash_account_id ?? null, + tx.currency ?? 'SEK', + ) + return { + id: newManualLineId(), + account_number: account, + debit_amount: isIncome ? Math.abs(tx.amount).toFixed(2).replace('.', ',') : '', + credit_amount: isIncome ? '' : Math.abs(tx.amount).toFixed(2).replace('.', ','), + line_description: (tx.description || '').slice(0, 40).trim(), + } + }) // One empty counterpart row to scaffold the next entry. Account // left blank — user must choose, which avoids the no-VAT trap. const counterpart: ManualLine = { @@ -180,7 +218,7 @@ export default function BulkBookDialog({ line_description: '', } setManualLines([...bankLines, counterpart]) - }, [tab, manualLines.length, transactions, direction]) + }, [tab, manualLines.length, transactions, direction, cashAccounts]) // Live line preview — driven by either the template/mode pair (template // tab) or the user-edited manual lines (manual tab). Same downstream diff --git a/lib/cash-accounts/resolve-account.ts b/lib/cash-accounts/resolve-account.ts new file mode 100644 index 00000000..a71cf025 --- /dev/null +++ b/lib/cash-accounts/resolve-account.ts @@ -0,0 +1,31 @@ +import type { CashAccount } from '@/types' + +export interface ResolvedAccount { + account: string + fallback: boolean +} + +/** + * Resolve the BAS ledger account number for a bank transaction. + * + * Resolution order: + * 1. cash_account_id is set → return that cash account's ledger_account. + * 2. Exactly one enabled account for the transaction currency → return it. + * 3. Give up → return '1930' with fallback=true. + */ +export function resolveAccount( + cashAccounts: CashAccount[], + cashAccountId: string | null, + currency: string, +): ResolvedAccount { + if (cashAccountId) { + const bound = cashAccounts.find((a) => a.id === cashAccountId) + // If an explicit ID was given but not found, skip the currency fallback and + // return 1930 with fallback=true — the missing link is a data integrity signal. + if (bound) return { account: bound.ledger_account, fallback: false } + return { account: '1930', fallback: true } + } + const sameCurrency = cashAccounts.filter((a) => a.enabled && a.currency === currency) + if (sameCurrency.length === 1) return { account: sameCurrency[0].ledger_account, fallback: false } + return { account: '1930', fallback: true } +}