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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -165,6 +165,22 @@ const RETRY_DELAY_MS = 1000
|
||||
const MAX_PAGINATION_PAGES = 100
|
||||
const DEFAULT_PAGE_SIZE = 500
|
||||
|
||||
/**
|
||||
* Thrown by getAccountTransactions on a non-OK response. Carries the HTTP
|
||||
* status and raw body so the pagination caller (getAllTransactions) can run the
|
||||
* same first-page strategy/window fallbacks as getAllTransactionsWithRaw. The
|
||||
* message is identical to the previous plain Error for back-compat.
|
||||
*/
|
||||
class TransactionsFetchError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly body: string
|
||||
) {
|
||||
super(`Failed to get transactions (${status}): ${body}`)
|
||||
this.name = 'TransactionsFetchError'
|
||||
}
|
||||
}
|
||||
|
||||
// API Helper
|
||||
|
||||
async function authenticatedFetch(
|
||||
@@ -505,7 +521,7 @@ export async function getAccountTransactions(
|
||||
strategy,
|
||||
hasContinuationKey: !!continuationKey,
|
||||
})
|
||||
throw new Error(`Failed to get transactions (${response.status}): ${body}`)
|
||||
throw new TransactionsFetchError(response.status, body)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
@@ -523,15 +539,57 @@ export async function getAllTransactions(
|
||||
const allTransactions: Transaction[] = []
|
||||
let continuationKey: string | undefined
|
||||
let page = 0
|
||||
let activeStrategy = strategy
|
||||
// date_from is narrowed in place when the ASPSP rejects the window (below).
|
||||
let activeDateFrom = dateFrom
|
||||
|
||||
do {
|
||||
const response = await getAccountTransactions(
|
||||
accountUid,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
continuationKey,
|
||||
strategy
|
||||
)
|
||||
while (true) {
|
||||
let response: TransactionsResponse
|
||||
try {
|
||||
response = await getAccountTransactions(
|
||||
accountUid,
|
||||
activeDateFrom,
|
||||
dateTo,
|
||||
continuationKey,
|
||||
activeStrategy
|
||||
)
|
||||
} catch (err) {
|
||||
// Apply the same first-page recovery as getAllTransactionsWithRaw. Only
|
||||
// TransactionsFetchError carries the status/body needed to decide;
|
||||
// network errors and the like propagate untouched.
|
||||
if (err instanceof TransactionsFetchError) {
|
||||
const recovery = planFirstPageRecovery({
|
||||
status: err.status,
|
||||
body: err.body,
|
||||
page,
|
||||
hasContinuationKey: !!continuationKey,
|
||||
activeStrategy,
|
||||
activeDateFrom,
|
||||
dateTo,
|
||||
})
|
||||
if (recovery.type === 'drop-strategy') {
|
||||
console.warn('[enable-banking] strategy rejected by API, retrying without strategy', {
|
||||
accountUid,
|
||||
strategy: activeStrategy,
|
||||
body: err.body,
|
||||
})
|
||||
activeStrategy = undefined
|
||||
continue
|
||||
}
|
||||
if (recovery.type === 'narrow') {
|
||||
console.warn('[enable-banking] ASPSP rejected history window, retrying with narrower date_from', {
|
||||
accountUid,
|
||||
previousDateFrom: activeDateFrom,
|
||||
nextDateFrom: recovery.dateFrom,
|
||||
dateTo,
|
||||
body: err.body,
|
||||
})
|
||||
activeDateFrom = recovery.dateFrom
|
||||
continue
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
allTransactions.push(...response.transactions)
|
||||
continuationKey = response.continuation_key
|
||||
@@ -541,11 +599,95 @@ export async function getAllTransactions(
|
||||
console.warn(`[enable-banking] Pagination cap reached (${MAX_PAGINATION_PAGES} pages) for account ${accountUid}`)
|
||||
break
|
||||
}
|
||||
} while (continuationKey)
|
||||
if (!continuationKey) break
|
||||
}
|
||||
|
||||
return allTransactions
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookback windows (days before date_to) we step through when an ASPSP rejects
|
||||
* the requested transaction history. Descending so each fallback yields a
|
||||
* strictly narrower window. PSD2 obliges banks to ~90 days without fresh SCA;
|
||||
* the smaller rungs cover banks that cap below that.
|
||||
*/
|
||||
const ASPSP_HISTORY_FALLBACK_DAYS = [90, 60, 30] as const
|
||||
|
||||
/**
|
||||
* Enable Banking wraps upstream-bank failures in a generic envelope, e.g.
|
||||
* {"code":400,"message":"Error interacting with ASPSP","error":"ASPSP_ERROR"}.
|
||||
* A too-wide history window is the most common trigger — see the date-narrowing
|
||||
* fallback in getAllTransactionsWithRaw.
|
||||
*/
|
||||
function isAspspError(body: string): boolean {
|
||||
return body.includes('ASPSP_ERROR') || body.includes('interacting with ASPSP')
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the date_from we just tried, return the next strictly-narrower
|
||||
* date_from from ASPSP_HISTORY_FALLBACK_DAYS, anchored to date_to. Returns
|
||||
* undefined when no narrower window remains (or date_to is missing), which
|
||||
* ends the retry loop. The "strictly narrower" guard keeps the loop monotonic
|
||||
* and terminating even if the original window was already short.
|
||||
*/
|
||||
function nextNarrowerDateFrom(
|
||||
currentDateFrom: string | undefined,
|
||||
dateTo: string | undefined
|
||||
): string | undefined {
|
||||
if (!dateTo) return undefined
|
||||
const anchor = new Date(`${dateTo}T00:00:00Z`)
|
||||
if (!Number.isFinite(anchor.getTime())) return undefined
|
||||
|
||||
for (const days of ASPSP_HISTORY_FALLBACK_DAYS) {
|
||||
const candidate = new Date(anchor.getTime() - days * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
if (!currentDateFrom || candidate > currentDateFrom) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* First-page recovery policy shared by getAllTransactions and
|
||||
* getAllTransactionsWithRaw, so the two pagination loops can't drift. Fallbacks
|
||||
* apply only to the very first request (page 0, no continuation_key) — a
|
||||
* continuation_key is scoped to the window/strategy that produced it, so the
|
||||
* query is never rewritten mid-pagination.
|
||||
*
|
||||
* - 'drop-strategy' — an unsupported strategy enum: retry the same window.
|
||||
* - 'narrow' — the ASPSP rejected the history window: retry with a
|
||||
* narrower date_from (the bank caps history below the ask).
|
||||
* - 'give-up' — nothing left to try; the caller should rethrow.
|
||||
*/
|
||||
type FirstPageRecovery =
|
||||
| { type: 'drop-strategy' }
|
||||
| { type: 'narrow'; dateFrom: string }
|
||||
| { type: 'give-up' }
|
||||
|
||||
function planFirstPageRecovery(args: {
|
||||
status: number
|
||||
body: string
|
||||
page: number
|
||||
hasContinuationKey: boolean
|
||||
activeStrategy: TransactionsFetchStrategy | undefined
|
||||
activeDateFrom: string | undefined
|
||||
dateTo: string | undefined
|
||||
}): FirstPageRecovery {
|
||||
const { status, body, page, hasContinuationKey, activeStrategy, activeDateFrom, dateTo } = args
|
||||
if (status !== 400 || page !== 0 || hasContinuationKey) return { type: 'give-up' }
|
||||
// Drop an unsupported strategy first — preserves the full requested window.
|
||||
if (activeStrategy) return { type: 'drop-strategy' }
|
||||
// Then handle the ASPSP rejecting the window itself (e.g. Danske past ~90
|
||||
// days): step date_from toward date_to so a partial sync survives.
|
||||
if (isAspspError(body)) {
|
||||
const dateFrom = nextNarrowerDateFrom(activeDateFrom, dateTo)
|
||||
if (dateFrom) return { type: 'narrow', dateFrom }
|
||||
}
|
||||
return { type: 'give-up' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all transactions with raw JSON responses for archival.
|
||||
* Returns both parsed transactions and the raw response strings.
|
||||
@@ -553,6 +695,12 @@ export async function getAllTransactions(
|
||||
* If `strategy` is provided and the API rejects it with a 400 on the first
|
||||
* request, retry once without `strategy` so unknown enum values can't break
|
||||
* the sync. Logs a warning when the fallback fires.
|
||||
*
|
||||
* If the ASPSP then still rejects the first page with an ASPSP_ERROR (typically
|
||||
* a history window beyond the bank's PSD2 limit, e.g. Danske past ~90 days),
|
||||
* progressively narrow date_from toward date_to (90→60→30 days) so a partial
|
||||
* sync of the recent window survives instead of failing outright. Logs a
|
||||
* warning on each narrowing.
|
||||
*/
|
||||
export async function getAllTransactionsWithRaw(
|
||||
accountUid: string,
|
||||
@@ -565,10 +713,12 @@ export async function getAllTransactionsWithRaw(
|
||||
let continuationKey: string | undefined
|
||||
let page = 0
|
||||
let activeStrategy = strategy
|
||||
// date_from is narrowed in place when the ASPSP rejects the window (below).
|
||||
let activeDateFrom = dateFrom
|
||||
|
||||
while (true) {
|
||||
const params = new URLSearchParams()
|
||||
if (dateFrom) params.set('date_from', dateFrom)
|
||||
if (activeDateFrom) params.set('date_from', activeDateFrom)
|
||||
if (dateTo) params.set('date_to', dateTo)
|
||||
if (continuationKey) params.set('continuation_key', continuationKey)
|
||||
if (activeStrategy) params.set('strategy', activeStrategy)
|
||||
@@ -581,9 +731,16 @@ export async function getAllTransactionsWithRaw(
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text()
|
||||
// If the API rejects an unknown strategy on the very first request,
|
||||
// fall back to the implicit default and retry the same page.
|
||||
if (response.status === 400 && activeStrategy && page === 0 && !continuationKey) {
|
||||
const recovery = planFirstPageRecovery({
|
||||
status: response.status,
|
||||
body,
|
||||
page,
|
||||
hasContinuationKey: !!continuationKey,
|
||||
activeStrategy,
|
||||
activeDateFrom,
|
||||
dateTo,
|
||||
})
|
||||
if (recovery.type === 'drop-strategy') {
|
||||
console.warn('[enable-banking] strategy rejected by API, retrying without strategy', {
|
||||
accountUid,
|
||||
strategy: activeStrategy,
|
||||
@@ -592,12 +749,23 @@ export async function getAllTransactionsWithRaw(
|
||||
activeStrategy = undefined
|
||||
continue
|
||||
}
|
||||
if (recovery.type === 'narrow') {
|
||||
console.warn('[enable-banking] ASPSP rejected history window, retrying with narrower date_from', {
|
||||
accountUid,
|
||||
previousDateFrom: activeDateFrom,
|
||||
nextDateFrom: recovery.dateFrom,
|
||||
dateTo,
|
||||
body,
|
||||
})
|
||||
activeDateFrom = recovery.dateFrom
|
||||
continue
|
||||
}
|
||||
console.error('[enable-banking] getAllTransactionsWithRaw failed', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
body,
|
||||
accountUid,
|
||||
dateFrom,
|
||||
dateFrom: activeDateFrom,
|
||||
dateTo,
|
||||
strategy: activeStrategy,
|
||||
page,
|
||||
|
||||
Reference in New Issue
Block a user