From cd46d936f3fae657d3679d01b3605ef1eb2f32a4 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:30:03 +0200 Subject: [PATCH] fix(bokforing): account search results in the booking dialog search field (#1877) (#1889) * fix(bokforing): account search results in the booking dialog search field (#1877) The search field in the "Bokfor transaktion" dialog only matched template metadata, so typing an account number or name (e.g. active 5460 Forbrukningsmaterial) gave zero hits and no path to booking; the real account search was only reachable via the discreet "Bokfor manuellt" link. - TemplatePicker now also searches the company's active chart of accounts (reusing lib/bookkeeping/account-search.ts) and shows hits as a "Konton" result group; picking one routes into the same manual booking flow as "Bokfor manuellt" with the account prefilled on the counter line. - New buildActiveAccountIndex helper indexes chart rows active-only, so deactivated accounts never surface as bookable results. - searchTemplates additionally prefix-matches all-digit tokens against a template's business account (debit for expense, credit for income, both legs for transfers, AB variants included); the settlement leg is deliberately excluded so "1930" does not light up every template. - Library template search prefix-matches line accounts on all-digit queries. - Empty search results show a hint pointing at "Bokfor manuellt" for accounts outside the active chart; placeholder now says "Sok mall eller konto...". New strings in both messages/sv.json and messages/en.json. Fixes #1877 Co-Authored-By: Claude Fable 5 * fix(bokforing): match only business lines when digit-searching library templates (#1877) CodeRabbit review on PR #1889: the all-digit library-template match also hit settlement and VAT lines, so searching "1930" lit up every user template with a bank settlement leg. Restrict the predicate to business lines, mirroring the static catalog's settlement-leg exclusion. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- app/(dashboard)/transactions/page.tsx | 24 ++- components/transactions/TemplatePicker.tsx | 170 ++++++++++++++---- .../transactions/TransactionBookingDialog.tsx | 12 +- .../__tests__/account-search.test.ts | 41 +++++ .../__tests__/booking-templates.test.ts | 32 ++++ lib/bookkeeping/account-search.ts | 19 ++ lib/bookkeeping/booking-templates.ts | 23 ++- messages/en.json | 3 + messages/sv.json | 3 + 9 files changed, 290 insertions(+), 37 deletions(-) diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index de522dc5..7dcdac9b 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -326,6 +326,9 @@ export default function TransactionsPage() { const [bookingDialogOpen, setBookingDialogOpen] = useState(false) const [bookingDialogTransaction, setBookingDialogTransaction] = useState(null) const [bookingDialogTemplate, setBookingDialogTemplate] = useState(null) + // Account picked from the template picker's "Konton" search results: + // prefills the counter line when the manual booking dialog opens. + const [bookingDialogAccount, setBookingDialogAccount] = useState(null) // Attach-underlag dialog (tx→doc mirror of the Documents view's matcher) const [attachDocTx, setAttachDocTx] = useState(null) @@ -3389,10 +3392,23 @@ export default function TransactionsPage() { if (templatePickerTransaction) { setBookingDialogTransaction(templatePickerTransaction) setBookingDialogTemplate(null) + setBookingDialogAccount(null) setBookingDialogOpen(true) } } + // Account picked from the template picker's "Konton" search group + // (issue #1877): same route as "Bokför manuellt", with the picked account + // prefilled on the counter line of the journal entry form. + function handlePickAccount(accountNumber: string) { + if (!templatePickerTransaction) return + setBookingDialogTransaction(templatePickerTransaction) + setBookingDialogTemplate(null) + setBookingDialogAccount(accountNumber) + setTemplatePickerOpen(false) + setBookingDialogOpen(true) + } + // Complex (multi-leg or otherwise non-convertible) library template picked // from the transaction modal: route into the manual booking dialog with // the template pre-applied against the transaction's amount. @@ -3400,6 +3416,7 @@ export default function TransactionsPage() { if (!templatePickerTransaction) return setBookingDialogTransaction(templatePickerTransaction) setBookingDialogTemplate(raw) + setBookingDialogAccount(null) setTemplatePickerOpen(false) setBookingDialogOpen(true) } @@ -3964,10 +3981,14 @@ export default function TransactionsPage() { open onOpenChange={(o) => { setBookingDialogOpen(o) - if (!o) setBookingDialogTemplate(null) + if (!o) { + setBookingDialogTemplate(null) + setBookingDialogAccount(null) + } }} transaction={bookingDialogTransaction} preselectedTemplate={bookingDialogTemplate} + preselectedAccount={bookingDialogAccount} onBooked={handleTransactionBooked} /> )} @@ -4037,6 +4058,7 @@ export default function TransactionsPage() { handleOpenTemplateReview(templatePickerTransaction, templateId) }} onPickLibraryTemplate={handlePickLibraryTemplate} + onSelectAccount={handlePickAccount} /> } diff --git a/components/transactions/TemplatePicker.tsx b/components/transactions/TemplatePicker.tsx index da642ee6..6344c137 100644 --- a/components/transactions/TemplatePicker.tsx +++ b/components/transactions/TemplatePicker.tsx @@ -13,6 +13,12 @@ import { type BookingTemplate, type TemplateGroup, } from '@/lib/bookkeeping/booking-templates' +import { + buildActiveAccountIndex, + searchAccounts, + type AccountSearchItem, + type ChartAccountLike, +} from '@/lib/bookkeeping/account-search' import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names' import { isCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates' import { convertLibraryToBookingTemplate, LIBRARY_TEMPLATE_PREFIX, isLibraryTemplateId } from '@/lib/bookkeeping/template-library' @@ -20,6 +26,10 @@ import { getAccountName } from '@/lib/bookkeeping/client-account-names' import type { BookingTemplateLibrary, EntityType } from '@/types' import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions' +// Cap on the "Konton" search-result group: enough to cover sibling accounts +// on a number-prefix query without drowning the template results. +const ACCOUNT_RESULT_LIMIT = 8 + const GROUP_ORDER: TemplateGroup[] = [ 'premises', 'vehicle', 'it_software', 'office_supplies', 'marketing', 'travel', 'representation', 'insurance', 'professional_services', @@ -199,6 +209,32 @@ function TemplateCard({ template, selected, onClick, compact }: TemplateCardProp ) } +// A chart-of-accounts hit in the search results (issue #1877): clicking it +// routes into the manual booking flow with the account prefilled, so typing +// "5460" or "Förbrukningsmaterial" always yields a path to booking even when +// no template covers the account. +function AccountResultCard({ account, onClick }: { account: AccountSearchItem; onClick: () => void }) { + const t = useTranslations('tx_template_picker') + return ( + + ) +} + interface TemplatePickerProps { direction: 'expense' | 'income' entityType?: EntityType @@ -207,6 +243,11 @@ interface TemplatePickerProps { onSelect: (template: BookingTemplate) => void onSelectCounterparty?: (templateId: string) => void onPickLibraryTemplate?: (raw: BookingTemplateLibrary) => void + // When provided, the search field also matches the company's active chart + // of accounts and shows hits as a "Konton" result group; picking one routes + // into the manual booking flow with the account prefilled. Omitting it + // keeps the picker template-only (and skips the accounts fetch). + onSelectAccount?: (accountNumber: string) => void selectedTemplateId?: string } @@ -217,12 +258,18 @@ export default function TemplatePicker({ onSelect, onSelectCounterparty, onPickLibraryTemplate, + onSelectAccount, selectedTemplateId, }: TemplatePickerProps) { const t = useTranslations('tx_template_picker') const [searchQuery, setSearchQuery] = useState('') const [showAdvanced, setShowAdvanced] = useState(false) const [libraryRaw, setLibraryRaw] = useState([]) + const [chartAccounts, setChartAccounts] = useState([]) + // Boolean gate rather than the callback itself: the parent recreates the + // handler every render, and depending on its identity would refetch the + // chart on each keystroke of the page underneath. + const accountSearchEnabled = !!onSelectAccount // Map direction to template direction filter (transfers show in both). // Direction filtering applies only to the static "Vanliga mallar" list: // user-created library templates ignore it (inferred direction is unreliable @@ -250,6 +297,29 @@ export default function TemplatePicker({ return () => { controller.abort() } }, []) + // Fetch the company's active chart so the search field can surface real + // accounts (issue #1877: an active konto like 5460 was unfindable because + // only template metadata was searched). The endpoint returns active + // accounts by default; buildActiveAccountIndex re-filters as defense in + // depth. Gated on the consumer actually routing account picks somewhere. + useEffect(() => { + if (!accountSearchEnabled) return + const controller = new AbortController() + ;(async () => { + try { + const res = await fetch('/api/bookkeeping/accounts', { signal: controller.signal }) + if (!res.ok) return + const { data } = await res.json() as { data?: ChartAccountLike[] } + if (data) setChartAccounts(data) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + } + })() + return () => { controller.abort() } + }, [accountSearchEnabled]) + + const accountIndex = useMemo(() => buildActiveAccountIndex(chartAccounts), [chartAccounts]) + // Lazy convertibility map. A template is "convertible" if it fits the // simple debit/credit pair shape the QuickReview booking path expects. // Non-convertible templates are still shown: they just route to the @@ -310,23 +380,35 @@ export default function TemplatePicker({ }) }, [relevantLibraryRaw, convertedById]) - // Search results (static + library). Library search ignores direction; the - // static catalog still respects it because it's curated content. + // Search results (static + library + chart accounts). Library search + // ignores direction; the static catalog still respects it because it's + // curated content. Accounts are direction-agnostic: the manual flow the + // pick routes into handles either side. const searchResults = useMemo< - | { library: BookingTemplateLibrary[]; staticTemplates: BookingTemplate[] } + | { library: BookingTemplateLibrary[]; staticTemplates: BookingTemplate[]; accounts: AccountSearchItem[] } | null >(() => { - if (!searchQuery.trim()) return null - const q = searchQuery.toLowerCase() + const qTrimmed = searchQuery.trim() + if (!qTrimmed) return null + const q = qTrimmed.toLowerCase() + const isDigits = /^\d+$/.test(qTrimmed) const libraryMatches = sortedLibraryRaw.filter((tt) => tt.name.toLowerCase().includes(q) || - (tt.description ?? '').toLowerCase().includes(q) + (tt.description ?? '').toLowerCase().includes(q) || + // An all-digit query also prefix-matches the accounts a user template + // books to, mirroring the static catalog's account matching: business + // lines only, so the settlement leg (typically 1930) and VAT lines do + // not light up every template. + (isDigits && tt.lines.some((l) => l.type === 'business' && l.account.startsWith(qTrimmed))) ) const staticMatches = searchTemplates(searchQuery, entityType).filter((tt) => { return tt.direction === templateDirection || tt.direction === 'transfer' }) - return { library: libraryMatches, staticTemplates: staticMatches } - }, [searchQuery, entityType, templateDirection, sortedLibraryRaw]) + const accountMatches = accountSearchEnabled + ? searchAccounts(accountIndex, searchQuery, ACCOUNT_RESULT_LIMIT) + : [] + return { library: libraryMatches, staticTemplates: staticMatches, accounts: accountMatches } + }, [searchQuery, entityType, templateDirection, sortedLibraryRaw, accountIndex, accountSearchEnabled]) // Group templates by group for display const commonGrouped = useMemo(() => groupTemplates(allCommon), [allCommon]) @@ -396,7 +478,7 @@ export default function TemplatePicker({ setSearchQuery(e.target.value)} - placeholder={t('search_placeholder')} + placeholder={accountSearchEnabled ? t('search_placeholder_with_accounts') : t('search_placeholder')} className="pl-9 h-9" /> @@ -406,31 +488,55 @@ export default function TemplatePicker({ {/* Search results */} {searchResults !== null ? ( (() => { - const totalResults = searchResults.library.length + searchResults.staticTemplates.length + const templateResults = searchResults.library.length + searchResults.staticTemplates.length + const totalResults = templateResults + searchResults.accounts.length return ( -
-

- {totalResults === 0 ? t('no_results') : t('n_results', { count: totalResults })} -

-
- {searchResults.library.map((raw) => ( - handleSelectLibraryRaw(raw)} - /> - ))} - {searchResults.staticTemplates.map((tt) => ( - handleSelect(tt)} - /> - ))} +
+
+

+ {totalResults === 0 ? t('no_results') : t('n_results', { count: totalResults })} +

+ {totalResults === 0 && accountSearchEnabled && ( +

+ {t('no_results_manual_hint')} +

+ )} +
+ {searchResults.library.map((raw) => ( + handleSelectLibraryRaw(raw)} + /> + ))} + {searchResults.staticTemplates.map((tt) => ( + handleSelect(tt)} + /> + ))} +
+ {searchResults.accounts.length > 0 && onSelectAccount && ( +
+

+ {t('accounts_group')} +

+
+ {searchResults.accounts.map((acc) => ( + onSelectAccount(acc.account_number)} + /> + ))} +
+
+ )}
) })() diff --git a/components/transactions/TransactionBookingDialog.tsx b/components/transactions/TransactionBookingDialog.tsx index e43a1f90..f696a88c 100644 --- a/components/transactions/TransactionBookingDialog.tsx +++ b/components/transactions/TransactionBookingDialog.tsx @@ -33,12 +33,17 @@ interface TransactionBookingDialogProps { matched?: boolean, ) => void preselectedTemplate?: BookingTemplateLibrary | null + /** Account number (string, e.g. '5460') to prefill on the counter line: + * set when the user picked an account from the template picker's "Konton" + * search results. Ignored when a preselectedTemplate is present. */ + preselectedAccount?: string | null } function buildInitialLines( transaction: TransactionWithInvoice, bankLineDescription: string, bankAccount: string = '1930', + counterAccount?: string | null, ): FormLine[] { const sekAmount = Math.round(Math.abs(resolveSekAmount( transaction.amount, @@ -67,7 +72,7 @@ function buildInitialLines( } const counterLine: FormLine = { - account_number: '', + account_number: counterAccount ?? '', debit_amount: isExpense ? amountStr : '', credit_amount: isExpense ? '' : amountStr, line_description: '', @@ -113,6 +118,7 @@ export default function TransactionBookingDialog({ transaction, onBooked, preselectedTemplate, + preselectedAccount, }: TransactionBookingDialogProps) { const t = useTranslations('tx_booking_dialog') const { toast } = useToast() @@ -385,12 +391,12 @@ export default function TransactionBookingDialog({
{bankAccount !== null && ( { expect(searchAccounts(idx, '6', 2)).toHaveLength(2) }) }) + +// The booking-dialog picker path (issue #1877): chart rows straight from +// /api/bookkeeping/accounts, indexed active-only, searched by number prefix +// or name. Synthetic fixture mirrors the reported case: 5460 active, plus a +// deactivated sibling that must never surface. +describe('buildActiveAccountIndex', () => { + const chart: ChartAccountLike[] = [ + { account_number: '1930', account_name: 'Företagskonto', account_class: 1, is_active: true }, + { account_number: '5460', account_name: 'Förbrukningsmaterial', account_class: 5, is_active: true }, + { account_number: '5410', account_name: 'Förbrukningsinventarier', account_class: 5, is_active: false }, + // No is_active flag at all: counts as active (server already filtered). + { account_number: '6212', account_name: 'Mobiltelefon', account_class: 6 }, + ] + const activeIdx = buildActiveAccountIndex(chart) + + it('finds an active account by number prefix (the reported "5460" case)', () => { + expect(numbers(searchAccounts(activeIdx, '5460'))).toEqual(['5460']) + expect(numbers(searchAccounts(activeIdx, '54'))).toEqual(['5460']) + }) + + it('finds an active account by name, case-insensitively with åäö', () => { + expect(numbers(searchAccounts(activeIdx, 'Förbrukningsmaterial'))).toEqual(['5460']) + expect(numbers(searchAccounts(activeIdx, 'FÖRBRUKNINGSMATERIAL'))).toEqual(['5460']) + expect(numbers(searchAccounts(activeIdx, 'forbrukningsmaterial'))).toEqual(['5460']) + }) + + it('excludes accounts explicitly marked inactive', () => { + expect(numbers(searchAccounts(activeIdx, '5410'))).toEqual([]) + expect(numbers(searchAccounts(activeIdx, 'Förbrukningsinventarier'))).toEqual([]) + }) + + it('treats rows without an is_active flag as active', () => { + expect(numbers(searchAccounts(activeIdx, 'mobiltelefon'))).toEqual(['6212']) + }) + + it('marks every indexed row as active for the result renderer', () => { + expect(searchAccounts(activeIdx, '').every((i) => i.isActive)).toBe(true) + }) +}) diff --git a/lib/bookkeeping/__tests__/booking-templates.test.ts b/lib/bookkeeping/__tests__/booking-templates.test.ts index dbed5d5c..7a4e2de7 100644 --- a/lib/bookkeeping/__tests__/booking-templates.test.ts +++ b/lib/bookkeeping/__tests__/booking-templates.test.ts @@ -184,6 +184,38 @@ describe('searchTemplates', () => { const results = searchTemplates('annonsering EU') expect(results.some((t) => t.id === 'marketing_online_ads_eu')).toBe(true) }) + + // Account-number matching (issue #1877): an all-digit token prefix-matches + // the template's business account, so typing a konto in the booking + // dialog's search field surfaces the templates that book to it. + it('finds an expense template by its debit (business) account number', () => { + const results = searchTemplates('5010') + expect(results.some((t) => t.id === 'premises_rent')).toBe(true) + }) + + it('finds an income template by its credit (business) account number', () => { + const results = searchTemplates('3001') + expect(results.some((t) => t.id === 'revenue_standard_25')).toBe(true) + }) + + it('matches both legs of a transfer template', () => { + const results = searchTemplates('1630') + expect(results.some((t) => t.id === 'financial_tax_account')).toBe(true) + }) + + it('does not match the settlement leg (1930 must not light up every template)', () => { + const results = searchTemplates('1930') + expect(results.some((t) => t.id === 'premises_rent')).toBe(false) + expect(results.some((t) => t.id === 'revenue_standard_25')).toBe(false) + // Transfers legitimately involve the bank account on a business leg. + expect(results.every((t) => t.direction === 'transfer')).toBe(true) + }) + + it('prefix-matches account numbers (partial konto narrows, text does not match accounts)', () => { + expect(searchTemplates('501').some((t) => t.id === 'premises_rent')).toBe(true) + // A non-digit token never matches via accounts. + expect(searchTemplates('501x').some((t) => t.id === 'premises_rent')).toBe(false) + }) }) // ============================================================ diff --git a/lib/bookkeeping/account-search.ts b/lib/bookkeeping/account-search.ts index ed717321..6cffd21e 100644 --- a/lib/bookkeeping/account-search.ts +++ b/lib/bookkeeping/account-search.ts @@ -28,6 +28,14 @@ export interface SearchableAccount { description?: string | null } +/** + * A chart row as served by /api/bookkeeping/accounts: a SearchableAccount + * that may also carry the chart's is_active flag. + */ +export interface ChartAccountLike extends SearchableAccount { + is_active?: boolean | null +} + /** A single result row the combobox renders. */ export interface AccountSearchItem { account_number: string @@ -91,6 +99,17 @@ export function buildAccountIndex(opts: { return entries } +/** + * Index over the company's ACTIVE chart only. Rows explicitly marked inactive + * are dropped; rows without the flag count as active (the accounts API already + * filters server-side, this keeps the index honest if a caller feeds it an + * unfiltered list). Used by the booking dialog's template picker, where + * deactivated accounts must not surface as bookable search results. + */ +export function buildActiveAccountIndex(rows: ChartAccountLike[]): AccountIndexEntry[] { + return buildAccountIndex({ active: rows.filter((r) => r.is_active !== false) }) +} + /** * Search the index. Returns ranked items (active first), capped at `limit`. * diff --git a/lib/bookkeeping/booking-templates.ts b/lib/bookkeeping/booking-templates.ts index 06455b48..61cedd24 100644 --- a/lib/bookkeeping/booking-templates.ts +++ b/lib/bookkeeping/booking-templates.ts @@ -1649,6 +1649,26 @@ export function getTemplateGroups(): TemplateGroupInfo[] { * Fuzzy search templates by name, keywords, or description. * Optionally filter by entity type. */ +/** + * Account-number matching for template search: an all-digit token prefix- + * matches the template's BUSINESS account(s), i.e. the cost/revenue side, + * not the settlement side. Matching the settlement leg too would make "1930" + * (the default bank account) light up nearly every template, which is noise, + * not search. Transfers have no business/settlement split, so both legs + * match. AB-variant accounts are included so the search works for both + * entity types. Account numbers are identifiers (strings): prefix match only. + */ +function templateAccountMatches(t: BookingTemplate, token: string): boolean { + if (!/^\d+$/.test(token)) return false + const candidates = + t.direction === 'expense' + ? [t.debit_account, t.debit_account_ab] + : t.direction === 'income' + ? [t.credit_account, t.credit_account_ab] + : [t.debit_account, t.credit_account, t.debit_account_ab, t.credit_account_ab] + return candidates.some((acc) => !!acc && acc.startsWith(token)) +} + export function searchTemplates(query: string, entityType?: EntityType): BookingTemplate[] { if (!query.trim()) return [] const q = query.toLowerCase() @@ -1666,7 +1686,8 @@ export function searchTemplates(query: string, entityType?: EntityType): Booking t.name_en.toLowerCase().includes(token) || t.description_sv.toLowerCase().includes(token) || t.keywords.some((kw) => kw.toLowerCase().includes(token)) || - t.id.includes(token) + t.id.includes(token) || + templateAccountMatches(t, token) ) }) } diff --git a/messages/en.json b/messages/en.json index 831426d1..feb32dea 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3130,8 +3130,11 @@ "vat_exempt": "VAT-free", "requires_vat_reg": "Requires VAT reg. no.", "search_placeholder": "Search template...", + "search_placeholder_with_accounts": "Search template or account...", "no_results": "No results", + "no_results_manual_hint": "Can't find the account? Use Bokför manuellt to search the full chart of accounts.", "n_results": "{count} results", + "accounts_group": "Accounts", "my_templates": "My templates", "previous_counterparties": "Previous counterparties", "suggested": "Suggested", diff --git a/messages/sv.json b/messages/sv.json index fb0fd225..64243460 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3130,8 +3130,11 @@ "vat_exempt": "Momsfri", "requires_vat_reg": "Kräver momsreg.nr", "search_placeholder": "Sök mall...", + "search_placeholder_with_accounts": "Sök mall eller konto...", "no_results": "Inga resultat", + "no_results_manual_hint": "Hittar du inte kontot? Via Bokför manuellt kan du söka i hela kontoplanen.", "n_results": "{count} resultat", + "accounts_group": "Konton", "my_templates": "Mina mallar", "previous_counterparties": "Tidigare motparter", "suggested": "Föreslagna",