fix(transactions): resolve bank account from cash_account_id in booking dialog (#769)
* feat(transactions): expose cash_account_id in list API response Add cash_account_id to the transactions list API select so that components can resolve the bank account from the transaction instead of hardcoding. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Jonas Flodén <jonas@floden.nu> * feat(cash-accounts): extract resolveAccount to shared utility Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Jonas Flodén <jonas@floden.nu> * refactor(transactions): use shared resolveAccount in MatchVoucherDialog Replace the local resolveAccount function with the shared utility from lib/cash-accounts/resolve-account, reducing code duplication and improving maintainability. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Jonas Flodén <jonas@floden.nu> * fix(transactions): resolve bank account from cash_account_id in booking dialog Replaces the hardcoded '1930' bank leg in TransactionBookingDialog with the actual ledger_account of the transaction's cash account. Companies with multiple bank accounts (e.g. 1930 + 1940) now get the correct account pre-filled in both the blank and template-based booking flows. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Jonas Flodén <jonas@floden.nu> * fix(transactions): cancel stale cash-account fetch on dialog re-open Signed-off-by: Jonas Flodén <jonas@floden.nu> * fix(transactions): prevent form remount discarding edits during bank account fetch Hold JournalEntryForm render until the /api/cash-accounts fetch resolves by changing bankAccount state to string | null (null = pending). This prevents the form from mounting with key '…-1930', then immediately remounting with the correct account key and losing any user edits made in the sub-100ms window. Also adds r.ok guard before parsing and sets '1930' as explicit catch fallback. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Jonas Flodén <jonas@floden.nu> * fix(transactions): cancel stale cash-account fetch in MatchVoucherDialog Pass a signal object into loadCandidates and return a cleanup from the useEffect so a stale in-flight fetch (from a previous transaction) cannot call setAccountNumber/setAccountFallback/setGlLines/setSelected after the dialog re-opens for a different transaction. Also adds r.ok check before parsing /api/cash-accounts response. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Jonas Flodén <jonas@floden.nu> --------- Signed-off-by: Jonas Flodén <jonas@floden.nu>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
5b4cefe8ab
commit
1cd8863958
@@ -64,7 +64,7 @@ export async function GET(request: Request) {
|
||||
|
||||
let query = supabase
|
||||
.from('transactions')
|
||||
.select('id, date, description, amount, currency, amount_sek, exchange_rate, reference, journal_entry_id, reconciliation_method, is_ignored')
|
||||
.select('id, date, description, amount, currency, amount_sek, exchange_rate, reference, journal_entry_id, reconciliation_method, is_ignored, cash_account_id')
|
||||
.eq('company_id', companyId)
|
||||
|
||||
// unmatched and reconciled are mutually exclusive — unmatched wins if both set
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import { ArrowUpRight, ArrowDownRight, Loader2 } from 'lucide-react'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
import type { CashAccount } from '@/types'
|
||||
import { resolveAccount } from '@/lib/cash-accounts/resolve-account'
|
||||
|
||||
interface MatchVoucherDialogProps {
|
||||
open: boolean
|
||||
@@ -43,23 +44,6 @@ function shiftDate(isoDate: string, deltaDays: number): string {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** Resolve which cash account (BAS ledger number) this transaction reconciles against. */
|
||||
function resolveAccount(
|
||||
cashAccounts: CashAccount[],
|
||||
tx: TransactionWithInvoice,
|
||||
): { account: string; fallback: boolean } {
|
||||
// 1. Bound row → its own account.
|
||||
if (tx.cash_account_id) {
|
||||
const bound = cashAccounts.find((a) => a.id === tx.cash_account_id)
|
||||
if (bound) return { account: bound.ledger_account, fallback: false }
|
||||
}
|
||||
// 2. Legacy NULL → the sole enabled account of the transaction's currency.
|
||||
const sameCurrency = cashAccounts.filter((a) => a.enabled && a.currency === tx.currency)
|
||||
if (sameCurrency.length === 1) return { account: sameCurrency[0].ledger_account, fallback: false }
|
||||
// 3. Give up gracefully on 1930 (the default SEK företagskonto).
|
||||
return { account: '1930', fallback: true }
|
||||
}
|
||||
|
||||
export function MatchVoucherDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -80,7 +64,7 @@ export function MatchVoucherDialog({
|
||||
const [includeMatched, setIncludeMatched] = useState(false)
|
||||
|
||||
const loadCandidates = useCallback(
|
||||
async (tx: TransactionWithInvoice, wide: boolean, matched: boolean) => {
|
||||
async (tx: TransactionWithInvoice, wide: boolean, matched: boolean, signal: { cancelled: boolean }) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Resolve the settlement account from the company's cash accounts.
|
||||
@@ -88,16 +72,22 @@ export function MatchVoucherDialog({
|
||||
let fallback = true
|
||||
try {
|
||||
const caRes = await fetch('/api/cash-accounts')
|
||||
const caJson = await caRes.json()
|
||||
const accounts = (caJson.data ?? []) as CashAccount[]
|
||||
const resolved = resolveAccount(accounts, tx)
|
||||
account = resolved.account
|
||||
fallback = resolved.fallback
|
||||
if (caRes.ok) {
|
||||
const caJson = await caRes.json()
|
||||
if (!signal.cancelled) {
|
||||
const accounts = (caJson.data ?? []) as CashAccount[]
|
||||
const resolved = resolveAccount(accounts, tx.cash_account_id ?? null, tx.currency ?? 'SEK')
|
||||
account = resolved.account
|
||||
fallback = resolved.fallback
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Network hiccup — fall back to 1930 and let the user see the note.
|
||||
}
|
||||
setAccountNumber(account)
|
||||
setAccountFallback(fallback)
|
||||
if (!signal.cancelled) {
|
||||
setAccountNumber(account)
|
||||
setAccountFallback(fallback)
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('account_number', account)
|
||||
@@ -110,6 +100,7 @@ export function MatchVoucherDialog({
|
||||
|
||||
const res = await fetch(`/api/reconciliation/bank/unmatched-entries?${params}`)
|
||||
const json = await res.json()
|
||||
if (signal.cancelled) return
|
||||
const lines = (json.data ?? []) as UnlinkedGLLine[]
|
||||
setGlLines(lines)
|
||||
// Pre-select a strong auto-match (exact/reference/date-range) so the
|
||||
@@ -128,7 +119,7 @@ export function MatchVoucherDialog({
|
||||
: '',
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (!signal.cancelled) setLoading(false)
|
||||
}
|
||||
},
|
||||
[],
|
||||
@@ -138,7 +129,9 @@ export function MatchVoucherDialog({
|
||||
// the user toggles already-matched vouchers in/out.
|
||||
useEffect(() => {
|
||||
if (!open || !transaction) return
|
||||
void loadCandidates(transaction, wideRange, includeMatched)
|
||||
const signal = { cancelled: false }
|
||||
void loadCandidates(transaction, wideRange, includeMatched, signal)
|
||||
return () => { signal.cancelled = true }
|
||||
}, [open, transaction, wideRange, includeMatched, loadCandidates])
|
||||
|
||||
// Reset transient state when the dialog closes so the next open starts clean.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -16,8 +16,9 @@ import type { AvailableInboxDoc } from '@/components/bookkeeping/InboxDocumentPi
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
import { resolveSekAmount, buildCurrencyMetadata } from '@/lib/bookkeeping/currency-utils'
|
||||
import { applyTemplate } from '@/lib/bookkeeping/template-library'
|
||||
import type { BookingTemplateLibrary } from '@/types'
|
||||
import type { BookingTemplateLibrary, CashAccount } from '@/types'
|
||||
import type { TransactionWithInvoice } from './transaction-types'
|
||||
import { resolveAccount } from '@/lib/cash-accounts/resolve-account'
|
||||
|
||||
interface TransactionBookingDialogProps {
|
||||
open: boolean
|
||||
@@ -31,7 +32,11 @@ interface TransactionBookingDialogProps {
|
||||
preselectedTemplate?: BookingTemplateLibrary | null
|
||||
}
|
||||
|
||||
function buildInitialLines(transaction: TransactionWithInvoice, bankLineDescription: string): FormLine[] {
|
||||
function buildInitialLines(
|
||||
transaction: TransactionWithInvoice,
|
||||
bankLineDescription: string,
|
||||
bankAccount: string = '1930',
|
||||
): FormLine[] {
|
||||
const sekAmount = Math.round(Math.abs(resolveSekAmount(
|
||||
transaction.amount,
|
||||
transaction.amount_sek,
|
||||
@@ -51,7 +56,7 @@ function buildInitialLines(transaction: TransactionWithInvoice, bankLineDescript
|
||||
: {}
|
||||
|
||||
const bankLine: FormLine = {
|
||||
account_number: '1930',
|
||||
account_number: bankAccount,
|
||||
debit_amount: isExpense ? '' : amountStr,
|
||||
credit_amount: isExpense ? amountStr : '',
|
||||
line_description: bankLineDescription,
|
||||
@@ -71,6 +76,7 @@ function buildInitialLines(transaction: TransactionWithInvoice, bankLineDescript
|
||||
function buildInitialLinesFromTemplate(
|
||||
transaction: TransactionWithInvoice,
|
||||
template: BookingTemplateLibrary,
|
||||
bankAccount: string = '1930',
|
||||
): FormLine[] {
|
||||
const sekAmount = Math.round(Math.abs(resolveSekAmount(
|
||||
transaction.amount,
|
||||
@@ -80,20 +86,21 @@ function buildInitialLinesFromTemplate(
|
||||
)) * 100) / 100
|
||||
const lines = applyTemplate(template.lines, sekAmount)
|
||||
|
||||
// Match buildInitialLines's foreign-currency handling: attach original
|
||||
// currency/amount/exchange_rate metadata to the settlement (bank/cash) legs
|
||||
// so the journal entry retains the foreign-currency annotation. Without
|
||||
// this the entry is silently recorded in SEK only.
|
||||
const isForeign = !!transaction.currency && transaction.currency !== 'SEK'
|
||||
if (!isForeign) return lines
|
||||
const currencyMeta = buildCurrencyMetadata(
|
||||
transaction.currency,
|
||||
Math.abs(transaction.amount),
|
||||
transaction.exchange_rate
|
||||
)
|
||||
const currencyMeta = isForeign
|
||||
? buildCurrencyMetadata(
|
||||
transaction.currency,
|
||||
Math.abs(transaction.amount),
|
||||
transaction.exchange_rate
|
||||
)
|
||||
: {}
|
||||
|
||||
return lines.map((line, i) => {
|
||||
const raw = template.lines[i]
|
||||
return raw?.type === 'settlement' ? { ...line, ...currencyMeta } : line
|
||||
if (raw?.type === 'settlement') {
|
||||
return { ...line, ...(isForeign ? currencyMeta : {}), account_number: bankAccount }
|
||||
}
|
||||
return line
|
||||
})
|
||||
}
|
||||
|
||||
@@ -110,6 +117,32 @@ export default function TransactionBookingDialog({
|
||||
const [pickedInboxDocs, setPickedInboxDocs] = useState<AvailableInboxDoc[]>([])
|
||||
const [showUploadZone, setShowUploadZone] = useState(false)
|
||||
const [inboxPickerOpen, setInboxPickerOpen] = useState(false)
|
||||
const [bankAccount, setBankAccount] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !transaction) return
|
||||
setBankAccount(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
|
||||
const accounts = (json.data ?? []) as CashAccount[]
|
||||
const { account } = resolveAccount(
|
||||
accounts,
|
||||
transaction.cash_account_id ?? null,
|
||||
transaction.currency ?? 'SEK',
|
||||
)
|
||||
setBankAccount(account)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setBankAccount('1930')
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [open, transaction?.id])
|
||||
|
||||
if (!transaction) return null
|
||||
|
||||
@@ -308,21 +341,23 @@ export default function TransactionBookingDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<JournalEntryForm
|
||||
key={`${transaction.id}-${preselectedTemplate?.id ?? 'default'}`}
|
||||
embedded
|
||||
initialLines={
|
||||
preselectedTemplate
|
||||
? buildInitialLinesFromTemplate(transaction, preselectedTemplate)
|
||||
: buildInitialLines(transaction, t('bank_line_description'))
|
||||
}
|
||||
initialDate={transaction.date}
|
||||
initialDescription={transaction.description}
|
||||
submitUrl={`/api/transactions/${transaction.id}/book`}
|
||||
sourceType="bank_transaction"
|
||||
sourceId={transaction.id}
|
||||
onEntryCreated={(entryId) => handleBooked(transaction.id, entryId)}
|
||||
/>
|
||||
{bankAccount !== null && (
|
||||
<JournalEntryForm
|
||||
key={`${transaction.id}-${preselectedTemplate?.id ?? 'default'}-${bankAccount}`}
|
||||
embedded
|
||||
initialLines={
|
||||
preselectedTemplate
|
||||
? buildInitialLinesFromTemplate(transaction, preselectedTemplate, bankAccount)
|
||||
: buildInitialLines(transaction, t('bank_line_description'), bankAccount)
|
||||
}
|
||||
initialDate={transaction.date}
|
||||
initialDescription={transaction.description}
|
||||
submitUrl={`/api/transactions/${transaction.id}/book`}
|
||||
sourceType="bank_transaction"
|
||||
sourceId={transaction.id}
|
||||
onEntryCreated={(entryId) => handleBooked(transaction.id, entryId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<InboxDocumentPicker
|
||||
open={inboxPickerOpen}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { resolveAccount } from '@/lib/cash-accounts/resolve-account'
|
||||
import type { CashAccount } from '@/types'
|
||||
|
||||
function makeCashAccount(overrides: Partial<CashAccount> = {}): CashAccount {
|
||||
return {
|
||||
id: 'ca-1',
|
||||
company_id: 'company-1',
|
||||
bank_connection_id: null,
|
||||
external_uid: null,
|
||||
iban: null,
|
||||
bg_pg: null,
|
||||
name: null,
|
||||
currency: 'SEK',
|
||||
ledger_account: '1930',
|
||||
balance: null,
|
||||
balance_updated_at: null,
|
||||
enabled: true,
|
||||
is_primary: true,
|
||||
source: 'manual',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveAccount', () => {
|
||||
it('returns the ledger_account for the bound cash account when cash_account_id matches', () => {
|
||||
const accounts = [
|
||||
makeCashAccount({ id: 'ca-1', ledger_account: '1930' }),
|
||||
makeCashAccount({ id: 'ca-2', ledger_account: '1940', is_primary: false }),
|
||||
]
|
||||
const result = resolveAccount(accounts, 'ca-2', 'SEK')
|
||||
expect(result).toEqual({ account: '1940', fallback: false })
|
||||
})
|
||||
|
||||
it('falls back to the sole enabled same-currency account when cash_account_id is null', () => {
|
||||
const accounts = [makeCashAccount({ id: 'ca-1', ledger_account: '1920', currency: 'EUR' })]
|
||||
const result = resolveAccount(accounts, null, 'EUR')
|
||||
expect(result).toEqual({ account: '1920', fallback: false })
|
||||
})
|
||||
|
||||
it('falls back to 1930 when cash_account_id is null and multiple same-currency accounts exist', () => {
|
||||
const accounts = [
|
||||
makeCashAccount({ id: 'ca-1', ledger_account: '1930', currency: 'SEK' }),
|
||||
makeCashAccount({ id: 'ca-2', ledger_account: '1940', currency: 'SEK', is_primary: false }),
|
||||
]
|
||||
const result = resolveAccount(accounts, null, 'SEK')
|
||||
expect(result).toEqual({ account: '1930', fallback: true })
|
||||
})
|
||||
|
||||
it('falls back to 1930 when cash_account_id does not match any account', () => {
|
||||
const accounts = [makeCashAccount({ id: 'ca-1', ledger_account: '1930' })]
|
||||
const result = resolveAccount(accounts, 'ca-unknown', 'SEK')
|
||||
expect(result).toEqual({ account: '1930', fallback: true })
|
||||
})
|
||||
|
||||
it('falls back to 1930 when accounts list is empty', () => {
|
||||
const result = resolveAccount([], null, 'SEK')
|
||||
expect(result).toEqual({ account: '1930', fallback: true })
|
||||
})
|
||||
|
||||
it('ignores disabled accounts in the currency fallback path', () => {
|
||||
const accounts = [
|
||||
makeCashAccount({ id: 'ca-1', ledger_account: '1930', enabled: true }),
|
||||
makeCashAccount({ id: 'ca-2', ledger_account: '1940', enabled: false, is_primary: false }),
|
||||
]
|
||||
// Only one enabled SEK account → resolves without fallback
|
||||
const result = resolveAccount(accounts, null, 'SEK')
|
||||
expect(result).toEqual({ account: '1930', fallback: false })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user