Bug/transaction import (#435)
* fix(invoices): drop UTKAST banner on numbered invoices and preserve logo aspect ratio Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * revert(invoices): restore UTKAST banner for drafts; keep logo aspect ratio fix Numbered drafts intentionally surface UTKAST until manually marked sent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * revert(invoices): drop logo objectFit change Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(enable-banking): implement transaction fetch strategy and update related logic --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
75043632de
commit
ed9bdc4c00
@@ -282,9 +282,13 @@ export const enableBankingExtension: Extension = {
|
||||
.maybeSingle()
|
||||
const isViewer = membership?.role === 'viewer'
|
||||
|
||||
// Use strategy=longest when the caller asks for >= 30 days of history
|
||||
// (initial sync, manual backfill). Short windows get the implicit
|
||||
// default since there's no older data to surface.
|
||||
const syncOptions = {
|
||||
...(sieOverlap ? { skipAutoCategorization: true } : {}),
|
||||
...(isViewer ? { rawInsertOnly: true } : {}),
|
||||
...(days_back >= 30 ? { strategy: 'longest' as const } : {}),
|
||||
}
|
||||
|
||||
if (sieOverlap) {
|
||||
|
||||
@@ -155,6 +155,69 @@ describe('api-client', () => {
|
||||
expect(JSON.parse(result.rawPages[0])).toEqual(page1)
|
||||
expect(JSON.parse(result.rawPages[1])).toEqual(page2)
|
||||
})
|
||||
|
||||
it('appends strategy=longest to the request URL when supplied', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ transactions: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
|
||||
await getAllTransactionsWithRaw('acc-1', '2024-01-01', '2024-12-31', 'longest')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
const requestedUrl = fetchSpy.mock.calls[0][0] as string
|
||||
expect(requestedUrl).toContain('strategy=longest')
|
||||
expect(requestedUrl).toContain('date_from=2024-01-01')
|
||||
expect(requestedUrl).toContain('date_to=2024-12-31')
|
||||
})
|
||||
|
||||
it('omits the strategy param when not supplied', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ transactions: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
|
||||
await getAllTransactionsWithRaw('acc-1', '2024-01-01', '2024-12-31')
|
||||
|
||||
const requestedUrl = fetchSpy.mock.calls[0][0] as string
|
||||
expect(requestedUrl).not.toContain('strategy=')
|
||||
})
|
||||
|
||||
it('falls back to no-strategy on 400 and retries the same page', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(
|
||||
new Response('Invalid strategy', { status: 400 })
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ transactions: [{ transaction_amount: { amount: '50', currency: 'SEK' } }] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
|
||||
const result = await getAllTransactionsWithRaw('acc-1', '2024-01-01', '2024-12-31', 'longest')
|
||||
|
||||
expect(result.transactions).toHaveLength(1)
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||
|
||||
const firstUrl = fetchSpy.mock.calls[0][0] as string
|
||||
const secondUrl = fetchSpy.mock.calls[1][0] as string
|
||||
expect(firstUrl).toContain('strategy=longest')
|
||||
expect(secondUrl).not.toContain('strategy=')
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[enable-banking] strategy rejected by API, retrying without strategy',
|
||||
expect.objectContaining({ strategy: 'longest' })
|
||||
)
|
||||
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -129,6 +129,59 @@ describe('syncAccountTransactions', () => {
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('forwards strategy from sync options to getAllTransactionsWithRaw', async () => {
|
||||
mockGetAllTransactionsWithRaw.mockResolvedValue({
|
||||
transactions: [],
|
||||
rawPages: ['{}'],
|
||||
})
|
||||
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
||||
|
||||
await syncAccountTransactions(
|
||||
{} as never,
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
CONNECTION_ID,
|
||||
makeAccount(),
|
||||
'2024-01-01',
|
||||
'2024-12-31',
|
||||
mockIngest,
|
||||
{ strategy: 'longest' }
|
||||
)
|
||||
|
||||
expect(mockGetAllTransactionsWithRaw).toHaveBeenCalledWith(
|
||||
'acc-uid-1',
|
||||
'2024-01-01',
|
||||
'2024-12-31',
|
||||
'longest'
|
||||
)
|
||||
})
|
||||
|
||||
it('omits strategy when sync options do not include it', async () => {
|
||||
mockGetAllTransactionsWithRaw.mockResolvedValue({
|
||||
transactions: [],
|
||||
rawPages: ['{}'],
|
||||
})
|
||||
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
||||
|
||||
await syncAccountTransactions(
|
||||
{} as never,
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
CONNECTION_ID,
|
||||
makeAccount(),
|
||||
'2024-01-01',
|
||||
'2024-12-31',
|
||||
mockIngest
|
||||
)
|
||||
|
||||
expect(mockGetAllTransactionsWithRaw).toHaveBeenCalledWith(
|
||||
'acc-uid-1',
|
||||
'2024-01-01',
|
||||
'2024-12-31',
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('passes raw transactions to ingest function', async () => {
|
||||
mockGetAllTransactionsWithRaw.mockResolvedValue({
|
||||
transactions: [{ transaction_amount: { amount: '500', currency: 'SEK' } }],
|
||||
|
||||
@@ -120,6 +120,15 @@ export interface TransactionsResponse {
|
||||
continuation_key?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Strategy for how Enable Banking fetches transactions from the upstream ASPSP.
|
||||
* - 'default' — fast path, may return only the most recent window even if date_from is older
|
||||
* - 'longest' — fetch the longest available history (up to PSD2 90-day max), slower
|
||||
*
|
||||
* When omitted, Enable Banking applies its default strategy.
|
||||
*/
|
||||
export type TransactionsFetchStrategy = 'default' | 'longest'
|
||||
|
||||
// Legacy types for backward compatibility
|
||||
export interface Bank {
|
||||
id: string
|
||||
@@ -462,12 +471,14 @@ export async function getAccountTransactions(
|
||||
accountUid: string,
|
||||
dateFrom?: string,
|
||||
dateTo?: string,
|
||||
continuationKey?: string
|
||||
continuationKey?: string,
|
||||
strategy?: TransactionsFetchStrategy
|
||||
): Promise<TransactionsResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (dateFrom) params.set('date_from', dateFrom)
|
||||
if (dateTo) params.set('date_to', dateTo)
|
||||
if (continuationKey) params.set('continuation_key', continuationKey)
|
||||
if (strategy) params.set('strategy', strategy)
|
||||
params.set('limit', String(DEFAULT_PAGE_SIZE))
|
||||
|
||||
const queryString = params.toString()
|
||||
@@ -484,6 +495,7 @@ export async function getAccountTransactions(
|
||||
accountUid,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
strategy,
|
||||
hasContinuationKey: !!continuationKey,
|
||||
})
|
||||
throw new Error(`Failed to get transactions (${response.status}): ${body}`)
|
||||
@@ -498,7 +510,8 @@ export async function getAccountTransactions(
|
||||
export async function getAllTransactions(
|
||||
accountUid: string,
|
||||
dateFrom?: string,
|
||||
dateTo?: string
|
||||
dateTo?: string,
|
||||
strategy?: TransactionsFetchStrategy
|
||||
): Promise<Transaction[]> {
|
||||
const allTransactions: Transaction[] = []
|
||||
let continuationKey: string | undefined
|
||||
@@ -509,7 +522,8 @@ export async function getAllTransactions(
|
||||
accountUid,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
continuationKey
|
||||
continuationKey,
|
||||
strategy
|
||||
)
|
||||
|
||||
allTransactions.push(...response.transactions)
|
||||
@@ -528,22 +542,29 @@ export async function getAllTransactions(
|
||||
/**
|
||||
* Get all transactions with raw JSON responses for archival.
|
||||
* Returns both parsed transactions and the raw response strings.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export async function getAllTransactionsWithRaw(
|
||||
accountUid: string,
|
||||
dateFrom?: string,
|
||||
dateTo?: string
|
||||
dateTo?: string,
|
||||
strategy?: TransactionsFetchStrategy
|
||||
): Promise<{ transactions: Transaction[]; rawPages: string[] }> {
|
||||
const allTransactions: Transaction[] = []
|
||||
const rawPages: string[] = []
|
||||
let continuationKey: string | undefined
|
||||
let page = 0
|
||||
let activeStrategy = strategy
|
||||
|
||||
do {
|
||||
while (true) {
|
||||
const params = new URLSearchParams()
|
||||
if (dateFrom) params.set('date_from', dateFrom)
|
||||
if (dateTo) params.set('date_to', dateTo)
|
||||
if (continuationKey) params.set('continuation_key', continuationKey)
|
||||
if (activeStrategy) params.set('strategy', activeStrategy)
|
||||
params.set('limit', String(DEFAULT_PAGE_SIZE))
|
||||
|
||||
const queryString = params.toString()
|
||||
@@ -553,6 +574,17 @@ 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) {
|
||||
console.warn('[enable-banking] strategy rejected by API, retrying without strategy', {
|
||||
accountUid,
|
||||
strategy: activeStrategy,
|
||||
body,
|
||||
})
|
||||
activeStrategy = undefined
|
||||
continue
|
||||
}
|
||||
console.error('[enable-banking] getAllTransactionsWithRaw failed', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
@@ -560,6 +592,7 @@ export async function getAllTransactionsWithRaw(
|
||||
accountUid,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
strategy: activeStrategy,
|
||||
page,
|
||||
hasContinuationKey: !!continuationKey,
|
||||
})
|
||||
@@ -578,7 +611,8 @@ export async function getAllTransactionsWithRaw(
|
||||
console.warn(`[enable-banking] Pagination cap reached (${MAX_PAGINATION_PAGES} pages) for account ${accountUid}`)
|
||||
break
|
||||
}
|
||||
} while (continuationKey)
|
||||
if (!continuationKey) break
|
||||
}
|
||||
|
||||
return { transactions: allTransactions, rawPages }
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getAllTransactionsWithRaw, convertTransaction, getAccountBalance } from
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { ingestTransactions as defaultIngest } from '@/lib/transactions/ingest'
|
||||
import type { RawTransaction, IngestResult, IngestOptions } from '@/types'
|
||||
import type { StoredAccount } from '../types'
|
||||
import type { StoredAccount, TransactionsFetchStrategy } from '../types'
|
||||
|
||||
/** Ingest function signature — matches lib/transactions/ingest */
|
||||
export type IngestFn = (
|
||||
@@ -19,6 +19,11 @@ export interface SyncOptions {
|
||||
skipAutoCategorization?: boolean
|
||||
/** Only INSERT + dedup, no matching/categorization (viewer imports) */
|
||||
rawInsertOnly?: boolean
|
||||
/**
|
||||
* Fetch strategy passed to Enable Banking. 'longest' instructs the upstream
|
||||
* to fetch the deepest available history (slower); omit for incremental syncs.
|
||||
*/
|
||||
strategy?: TransactionsFetchStrategy
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
@@ -55,19 +60,37 @@ export async function syncAccountTransactions(
|
||||
accountIban: account.iban,
|
||||
fromDate,
|
||||
toDate,
|
||||
strategy: syncOptions?.strategy,
|
||||
})
|
||||
|
||||
const { transactions, rawPages } = await getAllTransactionsWithRaw(
|
||||
account.uid,
|
||||
fromDate,
|
||||
toDate,
|
||||
syncOptions?.strategy,
|
||||
)
|
||||
|
||||
// Log the actual date range returned so we can compare against the requested
|
||||
// window. Helps diagnose when an ASPSP truncates history below what we asked for.
|
||||
let minBookingDate: string | undefined
|
||||
let maxBookingDate: string | undefined
|
||||
for (const tx of transactions) {
|
||||
const d = tx.booking_date || tx.value_date
|
||||
if (!d) continue
|
||||
if (!minBookingDate || d < minBookingDate) minBookingDate = d
|
||||
if (!maxBookingDate || d > maxBookingDate) maxBookingDate = d
|
||||
}
|
||||
|
||||
console.log('[enable-banking] Fetched transactions from API', {
|
||||
connectionId,
|
||||
accountUid: account.uid,
|
||||
transactionCount: transactions.length,
|
||||
rawPageCount: rawPages.length,
|
||||
requestedFromDate: fromDate,
|
||||
requestedToDate: toDate,
|
||||
returnedMinBookingDate: minBookingDate,
|
||||
returnedMaxBookingDate: maxBookingDate,
|
||||
strategy: syncOptions?.strategy,
|
||||
})
|
||||
|
||||
const bankTransactions = transactions.map(tx => convertTransaction(tx, account.currency))
|
||||
|
||||
@@ -20,6 +20,7 @@ export type {
|
||||
BalanceResponse,
|
||||
Transaction as EnableBankingTransaction,
|
||||
TransactionsResponse,
|
||||
TransactionsFetchStrategy,
|
||||
Bank,
|
||||
BankTransaction,
|
||||
} from './lib/api-client'
|
||||
|
||||
Reference in New Issue
Block a user