diff --git a/app/api/transactions/route.ts b/app/api/transactions/route.ts index ef78605c..b0b2bffa 100644 --- a/app/api/transactions/route.ts +++ b/app/api/transactions/route.ts @@ -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 diff --git a/components/transactions/MatchVoucherDialog.tsx b/components/transactions/MatchVoucherDialog.tsx index d0d122be..fa164993 100644 --- a/components/transactions/MatchVoucherDialog.tsx +++ b/components/transactions/MatchVoucherDialog.tsx @@ -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. diff --git a/components/transactions/TransactionBookingDialog.tsx b/components/transactions/TransactionBookingDialog.tsx index 491ff498..7fa99f99 100644 --- a/components/transactions/TransactionBookingDialog.tsx +++ b/components/transactions/TransactionBookingDialog.tsx @@ -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([]) const [showUploadZone, setShowUploadZone] = useState(false) const [inboxPickerOpen, setInboxPickerOpen] = useState(false) + const [bankAccount, setBankAccount] = useState(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({ )} - handleBooked(transaction.id, entryId)} - /> + {bankAccount !== null && ( + handleBooked(transaction.id, entryId)} + /> + )} = {}): 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 }) + }) +})