From 27383fd02b21a285f87950e984c89cb48113fee1 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:55:33 +0200 Subject: [PATCH] fix(inbox): surface full BAS catalog in BookDirectlyDialog account picker (#1543) * fix(inbox): surface full BAS catalog in BookDirectlyDialog account picker The account combobox in the inbox book-directly flow was fed only the company's active chart, so typing a prefix like 65 showed just the two activated 65xx accounts and a search for 6540 found nothing, which reads as the account not existing. Pass the cached BAS catalogue (same pattern as JournalEntryForm) so every standard account is searchable; picking a not-yet-activated account flows through the existing ActivateAccountsDialog rail at booking. Co-Authored-By: Claude Fable 5 * fix(bookkeeping): log BAS catalog fetch failures Compliance swarm finding (SOC 2 CC7.2): loadBasCatalog swallowed fetch errors silently, leaving catalog-load failures unobservable. Log inside the client's catch, which is the only place the error actually surfaces: callers' own .catch handlers are unreachable since the shared promise already resolves to an empty list on failure. Co-Authored-By: Claude Fable 5 * test(bookkeeping): unit-cover loadBasCatalog fetch, fallback, and retry CodeRabbit finding on PR #1543: the catalog client had no focused coverage. Tests assert the success path with promise caching, empty-list fallback with logging on non-OK responses, missing data field handling, and cache clearing after a failure so the next call refetches. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../extensions/general/BookDirectlyDialog.tsx | 11 +++ .../__tests__/bas-catalog-client.test.ts | 74 +++++++++++++++++++ lib/bookkeeping/bas-catalog-client.ts | 5 +- 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 lib/bookkeeping/__tests__/bas-catalog-client.test.ts diff --git a/components/extensions/general/BookDirectlyDialog.tsx b/components/extensions/general/BookDirectlyDialog.tsx index 71f9f7d3..1151583d 100644 --- a/components/extensions/general/BookDirectlyDialog.tsx +++ b/components/extensions/general/BookDirectlyDialog.tsx @@ -17,6 +17,7 @@ import { useToast } from '@/components/ui/use-toast' import { Loader2, Plus, Trash2, AlertTriangle, Search, Check, BookmarkPlus } from 'lucide-react' import { cn, formatCurrency } from '@/lib/utils' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' +import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client' import DocumentViewerPane from '@/components/bookkeeping/DocumentViewerPane' import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker' import { TemplateForm } from '@/components/settings/TemplateForm' @@ -188,6 +189,12 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl = const [periods, setPeriods] = useState([]) const [accounts, setAccounts] = useState([]) + // Full BAS catalogue (static reference data, fetched once per session). Lets + // the account picker surface standard accounts the company hasn't activated + // yet; picking one activates it at commit via the existing + // ActivateAccountsDialog rail. Without it the picker only knows the active + // chart, which reads as "the account doesn't exist". + const [catalog, setCatalog] = useState([]) const [entryDate, setEntryDate] = useState( item.extracted_data?.invoice?.invoiceDate || new Date().toISOString().slice(0, 10) ) @@ -414,6 +421,9 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl = console.error('[book-direct] fetch reference data failed:', err) } })() + loadBasCatalog().then((data) => { + if (!cancelled) setCatalog(data) + }).catch(() => {/* search degrades to the active chart */}) return () => { cancelled = true } }, [open]) @@ -858,6 +868,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, docUrl = updateLine(idx, { account_number: v })} /> diff --git a/lib/bookkeeping/__tests__/bas-catalog-client.test.ts b/lib/bookkeeping/__tests__/bas-catalog-client.test.ts new file mode 100644 index 00000000..e20d793c --- /dev/null +++ b/lib/bookkeeping/__tests__/bas-catalog-client.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// loadBasCatalog caches its in-flight promise at module level, so every test +// re-imports a fresh module instance to start from an empty cache. +async function importFreshClient() { + vi.resetModules() + return import('@/lib/bookkeeping/bas-catalog-client') +} + +const CATALOG = [ + { account_number: '6540', account_name: 'IT-tjänster', account_class: 6, account_group: '65', description: null }, +] + +describe('loadBasCatalog', () => { + let consoleErrorSpy: ReturnType + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + consoleErrorSpy.mockRestore() + vi.unstubAllGlobals() + }) + + it('returns the catalog on a successful fetch and caches it across calls', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ data: CATALOG }), + }) + vi.stubGlobal('fetch', fetchMock) + const { loadBasCatalog } = await importFreshClient() + + await expect(loadBasCatalog()).resolves.toEqual(CATALOG) + await expect(loadBasCatalog()).resolves.toEqual(CATALOG) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('resolves to an empty list and logs on a non-OK response', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 500 }) + vi.stubGlobal('fetch', fetchMock) + const { loadBasCatalog } = await importFreshClient() + + await expect(loadBasCatalog()).resolves.toEqual([]) + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[bas-catalog] fetch failed:', + expect.any(Error), + ) + }) + + it('resolves to an empty list when the body has no data field', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + }) + vi.stubGlobal('fetch', fetchMock) + const { loadBasCatalog } = await importFreshClient() + + await expect(loadBasCatalog()).resolves.toEqual([]) + }) + + it('clears the cache after a failure so the next call retries the fetch', async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValueOnce({ ok: true, json: async () => ({ data: CATALOG }) }) + vi.stubGlobal('fetch', fetchMock) + const { loadBasCatalog } = await importFreshClient() + + await expect(loadBasCatalog()).resolves.toEqual([]) + await expect(loadBasCatalog()).resolves.toEqual(CATALOG) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/lib/bookkeeping/bas-catalog-client.ts b/lib/bookkeeping/bas-catalog-client.ts index 7762edb6..adb87af1 100644 --- a/lib/bookkeeping/bas-catalog-client.ts +++ b/lib/bookkeeping/bas-catalog-client.ts @@ -28,7 +28,10 @@ export function loadBasCatalog(): Promise { return res.json() }) .then((body) => (body?.data as CatalogAccount[]) ?? []) - .catch(() => { + .catch((err) => { + // Callers degrade gracefully (search falls back to the active chart), + // so this log is the only trace a catalog fetch failure leaves. + console.error('[bas-catalog] fetch failed:', err) cache = null // allow a retry on the next call return [] })