fix(transactions): resolve bank account from cash_account_id in bulk-book and direct-book dialogs (#770)
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 <jonas@floden.nu>
This commit is contained in:
@@ -28,7 +28,8 @@ import {
|
|||||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||||
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||||
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
|
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 {
|
interface InboxItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -77,9 +78,12 @@ interface Props {
|
|||||||
// a transaction is selected and the document is in a foreign currency, the
|
// 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
|
// transaction's SEK amount is the canonical figure. The cost-account row
|
||||||
// stays blank — the user must pick a cost account themselves.
|
// 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(
|
function buildPrefillLines(
|
||||||
item: InboxItem,
|
item: InboxItem,
|
||||||
selectedTransactionAmount: number | null = null
|
selectedTransactionAmount: number | null = null,
|
||||||
|
bankAccount: string = '1930',
|
||||||
): FormLine[] {
|
): FormLine[] {
|
||||||
const docTotal = item.extracted_data?.totals?.total ?? null
|
const docTotal = item.extracted_data?.totals?.total ?? null
|
||||||
const docVat = item.extracted_data?.totals?.vatAmount ?? null
|
const docVat = item.extracted_data?.totals?.vatAmount ?? null
|
||||||
@@ -125,7 +129,7 @@ function buildPrefillLines(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
lines.push({
|
lines.push({
|
||||||
account_number: '1930',
|
account_number: bankAccount,
|
||||||
debit_amount: '',
|
debit_amount: '',
|
||||||
credit_amount: String(totalRounded),
|
credit_amount: String(totalRounded),
|
||||||
})
|
})
|
||||||
@@ -157,6 +161,9 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess
|
|||||||
// pending, or unsupported.
|
// pending, or unsupported.
|
||||||
const [fxRate, setFxRate] = useState<number | null>(null)
|
const [fxRate, setFxRate] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// null = fetch pending; array = loaded (may be empty on error — falls back to '1930')
|
||||||
|
const [cashAccounts, setCashAccounts] = useState<CashAccount[] | null>(null)
|
||||||
|
|
||||||
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
||||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||||
const [entryDate, setEntryDate] = useState<string>(
|
const [entryDate, setEntryDate] = useState<string>(
|
||||||
@@ -169,6 +176,9 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess
|
|||||||
return [supplier, invoiceNum].filter(Boolean).join(' · ') || 'Bokföring från inkorg'
|
return [supplier, invoiceNum].filter(Boolean).join(' · ') || 'Bokföring från inkorg'
|
||||||
})
|
})
|
||||||
const [notes, setNotes] = useState<string>('')
|
const [notes, setNotes] = useState<string>('')
|
||||||
|
// 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<FormLine[]>(() => buildPrefillLines(item))
|
const [lines, setLines] = useState<FormLine[]>(() => buildPrefillLines(item))
|
||||||
|
|
||||||
// Transaction picker — optional selection.
|
// Transaction picker — optional selection.
|
||||||
@@ -181,11 +191,14 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess
|
|||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setEntryDate(item.extracted_data?.invoice?.invoiceDate || new Date().toISOString().slice(0, 10))
|
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)
|
setSelectedTransactionId(item.matched_transaction_id)
|
||||||
const supplier = item.extracted_data?.supplier?.name?.trim() || ''
|
const supplier = item.extracted_data?.supplier?.name?.trim() || ''
|
||||||
const invoiceNum = item.extracted_data?.invoice?.invoiceNumber?.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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, targetCurrency, item.id])
|
}, [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.
|
// SEK-equivalent of the underlag total — the anchor for ranking candidates.
|
||||||
const targetSek = useMemo(() => {
|
const targetSek = useMemo(() => {
|
||||||
if (targetAmount == null) return null
|
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)
|
: resolveSekAmount(tx.amount, tx.amount_sek ?? null, tx.currency, tx.exchange_rate ?? null)
|
||||||
}, [selectedTransactionId, transactions])
|
}, [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<string | null>(() => {
|
||||||
|
if (cashAccounts === null) return null
|
||||||
|
const { account } = resolveAccount(cashAccounts, null, settlementCurrency)
|
||||||
|
return account
|
||||||
|
}, [cashAccounts, settlementCurrency])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
// Update amounts when the transaction selection changes, but preserve
|
// Update amounts when the transaction selection or resolved bank account
|
||||||
// user-entered account numbers. This handles "user typed cost account,
|
// changes, but preserve user-entered account numbers. This handles "user
|
||||||
// then picked an SEK-denominated transaction" — we want the SEK figure
|
// typed cost account, then picked an SEK-denominated transaction" — we
|
||||||
// to flow into the line amounts without forgetting their account pick.
|
// 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) => {
|
setLines((current) => {
|
||||||
const next = buildPrefillLines(item, selectedTransactionAmount)
|
const next = buildPrefillLines(item, selectedTransactionAmount, bankAccount ?? '1930')
|
||||||
return next.map((nl, i) => {
|
return next.map((nl, i) => {
|
||||||
const existing = current[i]
|
const existing = current[i]
|
||||||
if (!existing) return nl
|
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
|
// Fetch fiscal periods and accounts on first open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { createClient } from '@/lib/supabase/client'
|
import { createClient } from '@/lib/supabase/client'
|
||||||
import { useCompany } from '@/contexts/CompanyContext'
|
import { useCompany } from '@/contexts/CompanyContext'
|
||||||
|
import { resolveAccount } from '@/lib/cash-accounts/resolve-account'
|
||||||
|
import type { CashAccount } from '@/types'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -81,6 +83,8 @@ export default function BulkBookDialog({
|
|||||||
const [templates, setTemplates] = useState<BookingTemplateLibrary[]>([])
|
const [templates, setTemplates] = useState<BookingTemplateLibrary[]>([])
|
||||||
const [loadingTemplates, setLoadingTemplates] = useState(true)
|
const [loadingTemplates, setLoadingTemplates] = useState(true)
|
||||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
|
||||||
|
// null = fetch pending; array = loaded (may be empty on error — falls back to '1930')
|
||||||
|
const [cashAccounts, setCashAccounts] = useState<CashAccount[] | null>(null)
|
||||||
const [mode, setMode] = useState<Mode>('one_line_per_tx')
|
const [mode, setMode] = useState<Mode>('one_line_per_tx')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
const [manualLines, setManualLines] = useState<ManualLine[]>([])
|
const [manualLines, setManualLines] = useState<ManualLine[]>([])
|
||||||
@@ -137,6 +141,28 @@ export default function BulkBookDialog({
|
|||||||
}
|
}
|
||||||
}, [open, company, supabase])
|
}, [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.
|
// Reset state when dialog closes so the next open starts clean.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -145,31 +171,43 @@ export default function BulkBookDialog({
|
|||||||
setMode('one_line_per_tx')
|
setMode('one_line_per_tx')
|
||||||
setDescription('')
|
setDescription('')
|
||||||
setManualLines([])
|
setManualLines([])
|
||||||
|
setCashAccounts(null)
|
||||||
} else if (sharedDate) {
|
} else if (sharedDate) {
|
||||||
// Pre-fill description with a sensible default the user can edit.
|
// Pre-fill description with a sensible default the user can edit.
|
||||||
setDescription(t('default_description', { date: sharedDate }))
|
setDescription(t('default_description', { date: sharedDate }))
|
||||||
}
|
}
|
||||||
}, [open, sharedDate, t])
|
}, [open, sharedDate, t])
|
||||||
|
|
||||||
// Pre-fill the bank side from the txs (one line per tx on 1930 with
|
// Pre-fill the bank side from the txs (one line per tx with the resolved
|
||||||
// the correct Dr/Cr direction). We intentionally do NOT pre-fill a
|
// ledger account and correct Dr/Cr direction). We intentionally do NOT
|
||||||
// counterpart account: swedish-compliance flagged that a hardcoded
|
// pre-fill a counterpart account: swedish-compliance flagged that a
|
||||||
// 3001/5800 prefill nudges users into submitting verifikat without a
|
// hardcoded 3001/5800 prefill nudges users into submitting verifikat
|
||||||
// VAT line (26xx) for momsregistrerade affärshändelser. The bank
|
// without a VAT line (26xx) for momsregistrerade affärshändelser. The
|
||||||
// side is the unambiguous part the user always wants; the
|
// bank side is the unambiguous part the user always wants; the
|
||||||
// counterpart (and any VAT split) is the user's responsibility.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (tab !== 'manual') return
|
if (tab !== 'manual') return
|
||||||
if (manualLines.length > 0) return
|
if (manualLines.length > 0) return
|
||||||
if (transactions.length === 0) return
|
if (transactions.length === 0) return
|
||||||
|
if (cashAccounts === null) return
|
||||||
const isIncome = direction === 'income'
|
const isIncome = direction === 'income'
|
||||||
const bankLines: ManualLine[] = transactions.map((tx) => ({
|
const bankLines: ManualLine[] = transactions.map((tx) => {
|
||||||
id: newManualLineId(),
|
const { account } = resolveAccount(
|
||||||
account_number: '1930',
|
cashAccounts,
|
||||||
debit_amount: isIncome ? Math.abs(tx.amount).toFixed(2).replace('.', ',') : '',
|
tx.cash_account_id ?? null,
|
||||||
credit_amount: isIncome ? '' : Math.abs(tx.amount).toFixed(2).replace('.', ','),
|
tx.currency ?? 'SEK',
|
||||||
line_description: (tx.description || '').slice(0, 40).trim(),
|
)
|
||||||
}))
|
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
|
// One empty counterpart row to scaffold the next entry. Account
|
||||||
// left blank — user must choose, which avoids the no-VAT trap.
|
// left blank — user must choose, which avoids the no-VAT trap.
|
||||||
const counterpart: ManualLine = {
|
const counterpart: ManualLine = {
|
||||||
@@ -180,7 +218,7 @@ export default function BulkBookDialog({
|
|||||||
line_description: '',
|
line_description: '',
|
||||||
}
|
}
|
||||||
setManualLines([...bankLines, counterpart])
|
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
|
// Live line preview — driven by either the template/mode pair (template
|
||||||
// tab) or the user-edited manual lines (manual tab). Same downstream
|
// tab) or the user-edited manual lines (manual tab). Same downstream
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user