Bug/document linking (#688)
* feat: enhance supplier invoice payment process and settings handling - Implemented linking of invoice documents to journal entries for cash payments in the supplier invoice payment process. - Refactored settings fetching logic to improve loading states and error handling across various settings components. - Introduced a new SettingsLoadError component to handle cases where settings fetch fails or returns no data. - Updated useSettings hook to manage loading and error states more effectively, allowing for retries on failure. - Enhanced tests for supplier invoice creation to ensure document IDs are persisted correctly for cash method payments. * feat(salary): enable monthly salary edits in draft runs and handle zero-total declarations
This commit is contained in:
@@ -220,6 +220,185 @@ describe('api-client', () => {
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
// Danske Bank rejects a history window beyond its ~90-day PSD2 limit with a
|
||||
// blanket ASPSP_ERROR rather than clamping. The window must be narrowed.
|
||||
const ASPSP_ERROR_BODY =
|
||||
'{"code":400,"message":"Error interacting with ASPSP","detail":"Unknown error","error":"ASPSP_ERROR"}'
|
||||
|
||||
it('narrows date_from when the ASPSP rejects the history window', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
fetchSpy
|
||||
// strategy=longest, full 120-day window → ASPSP_ERROR
|
||||
.mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 }))
|
||||
// strategy dropped, still full window → ASPSP_ERROR (window is the problem)
|
||||
.mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 }))
|
||||
// narrowed to 90 days before date_to → success
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ transactions: [{ transaction_amount: { amount: '42', currency: 'SEK' } }] }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
)
|
||||
|
||||
const result = await getAllTransactionsWithRaw('acc-1', '2026-02-07', '2026-06-07', 'longest')
|
||||
|
||||
expect(result.transactions).toHaveLength(1)
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3)
|
||||
|
||||
const urls = fetchSpy.mock.calls.map((c: unknown[]) => c[0] as string)
|
||||
expect(urls[0]).toContain('date_from=2026-02-07')
|
||||
expect(urls[0]).toContain('strategy=longest')
|
||||
expect(urls[1]).toContain('date_from=2026-02-07')
|
||||
expect(urls[1]).not.toContain('strategy=')
|
||||
// 90 days before 2026-06-07
|
||||
expect(urls[2]).toContain('date_from=2026-03-09')
|
||||
expect(urls[2]).toContain('date_to=2026-06-07')
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[enable-banking] ASPSP rejected history window, retrying with narrower date_from',
|
||||
expect.objectContaining({ previousDateFrom: '2026-02-07', nextDateFrom: '2026-03-09' })
|
||||
)
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('steps through successive narrower windows until one succeeds', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // full window
|
||||
.mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // 90 days
|
||||
.mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // 60 days
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ transactions: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
) // 30 days → success
|
||||
|
||||
await getAllTransactionsWithRaw('acc-1', '2026-02-07', '2026-06-07')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(4)
|
||||
const urls = fetchSpy.mock.calls.map((c: unknown[]) => c[0] as string)
|
||||
expect(urls[0]).toContain('date_from=2026-02-07')
|
||||
expect(urls[1]).toContain('date_from=2026-03-09') // 90 days before date_to
|
||||
expect(urls[2]).toContain('date_from=2026-04-08') // 60 days
|
||||
expect(urls[3]).toContain('date_from=2026-05-08') // 30 days
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not narrow the window on a non-ASPSP 400', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response('{"error":"INVALID_REQUEST"}', { status: 400 }))
|
||||
|
||||
await expect(
|
||||
getAllTransactionsWithRaw('acc-1', '2026-02-07', '2026-06-07')
|
||||
).rejects.toThrow('Failed to get transactions (400)')
|
||||
|
||||
// No strategy to drop + not an ASPSP error → fail fast, no retries.
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws once every narrower window is exhausted', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
// Fresh Response per call — a body can only be read once.
|
||||
fetchSpy.mockImplementation(() => Promise.resolve(new Response(ASPSP_ERROR_BODY, { status: 400 })))
|
||||
|
||||
await expect(
|
||||
getAllTransactionsWithRaw('acc-1', '2026-02-07', '2026-06-07')
|
||||
).rejects.toThrow('Failed to get transactions (400)')
|
||||
|
||||
// full window + 90 + 60 + 30 = 4 attempts, then give up
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(4)
|
||||
|
||||
warnSpy.mockRestore()
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// getAllTransactions — same first-page fallbacks via the paginated path
|
||||
// -------------------------------------------------------------------------
|
||||
describe('getAllTransactions fallbacks', () => {
|
||||
const ASPSP_ERROR_BODY =
|
||||
'{"code":400,"message":"Error interacting with ASPSP","detail":"Unknown error","error":"ASPSP_ERROR"}'
|
||||
|
||||
it('narrows the window when the ASPSP rejects the history range', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // full window
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ transactions: [{ transaction_amount: { amount: '10', currency: 'SEK' } }] }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
) // narrowed to 90 days → success
|
||||
|
||||
const result = await getAllTransactions('acc-1', '2026-02-07', '2026-06-07')
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||
const urls = fetchSpy.mock.calls.map((c: unknown[]) => c[0] as string)
|
||||
expect(urls[0]).toContain('date_from=2026-02-07')
|
||||
expect(urls[1]).toContain('date_from=2026-03-09') // 90 days before date_to
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('drops the strategy then narrows the window (Danske flow)', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // strategy=longest
|
||||
.mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // no strategy, full window
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ transactions: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
) // narrowed to 90 days → success
|
||||
|
||||
await getAllTransactions('acc-1', '2026-02-07', '2026-06-07', 'longest')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3)
|
||||
const urls = fetchSpy.mock.calls.map((c: unknown[]) => c[0] as string)
|
||||
expect(urls[0]).toContain('strategy=longest')
|
||||
expect(urls[1]).not.toContain('strategy=')
|
||||
expect(urls[1]).toContain('date_from=2026-02-07')
|
||||
expect(urls[2]).toContain('date_from=2026-03-09')
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('does not rewrite the query mid-pagination', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
transactions: [{ transaction_amount: { amount: '5', currency: 'SEK' } }],
|
||||
continuation_key: 'page2',
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
) // page 1 ok, hands back a continuation_key
|
||||
.mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // page 2 fails
|
||||
|
||||
// A continuation_key is scoped to its window, so page 2 must not narrow —
|
||||
// it fails fast instead.
|
||||
await expect(
|
||||
getAllTransactions('acc-1', '2026-02-07', '2026-06-07')
|
||||
).rejects.toThrow('Failed to get transactions (400)')
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user