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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-25 13:30:03 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e92365b86e
commit cd46d936f3
9 changed files with 290 additions and 37 deletions
@@ -2,7 +2,9 @@ import { describe, it, expect } from 'vitest'
import {
foldText,
buildAccountIndex,
buildActiveAccountIndex,
searchAccounts,
type ChartAccountLike,
type SearchableAccount,
} from '../account-search'
@@ -104,3 +106,42 @@ describe('searchAccounts', () => {
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)
})
})
@@ -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)
})
})
// ============================================================
+19
View File
@@ -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`.
*
+22 -1
View File
@@ -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)
)
})
}