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:
co-authored by
Claude Fable 5
parent
8d56219c31
commit
27383fd02b
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 []
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user