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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-13 00:55:33 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 8d56219c31
commit 27383fd02b
3 changed files with 89 additions and 1 deletions
@@ -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<FiscalPeriod[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
// 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<CatalogAccount[]>([])
const [entryDate, setEntryDate] = useState<string>(
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 =
<AccountCombobox
value={line.account_number}
accounts={accounts}
catalog={catalog}
onChange={(v) => updateLine(idx, { account_number: v })}
/>
</td>
@@ -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<typeof vi.spyOn>
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)
})
})
+4 -1
View File
@@ -28,7 +28,10 @@ export function loadBasCatalog(): Promise<CatalogAccount[]> {
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 []
})