From ed9bdc4c00bdc61f3855575ac473ccd0f7056697 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 11 May 2026 00:27:44 +0200 Subject: [PATCH] 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) * 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) * revert(invoices): drop logo objectFit change Co-Authored-By: Claude Opus 4.7 (1M context) * feat(enable-banking): implement transaction fetch strategy and update related logic --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/(dashboard)/invoices/new/page.tsx | 7 ++- .../enable-banking/callback/route.ts | 5 +- .../enable-banking/sync/cron/route.ts | 10 ++- components/invoices/InvoiceReviewContent.tsx | 13 +++- extensions/general/enable-banking/index.ts | 4 ++ .../lib/__tests__/api-client.test.ts | 63 +++++++++++++++++++ .../enable-banking/lib/__tests__/sync.test.ts | 53 ++++++++++++++++ .../general/enable-banking/lib/api-client.ts | 46 ++++++++++++-- extensions/general/enable-banking/lib/sync.ts | 25 +++++++- extensions/general/enable-banking/types.ts | 1 + lib/invoices/pdf-template.tsx | 8 +-- 11 files changed, 218 insertions(+), 17 deletions(-) diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index 8d925555..b881e945 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -79,6 +79,7 @@ export default function NewInvoicePage() { const [hasBankDetails, setHasBankDetails] = useState(null) const [showBankSetup, setShowBankSetup] = useState(false) const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual') + const [oreRounding, setOreRounding] = useState(true) const [numberPreview, setNumberPreview] = useState(null) const pendingCustomerRef = useRef(null) @@ -139,7 +140,7 @@ export default function NewInvoicePage() { if (!company?.id) return const { data } = await supabase .from('company_settings') - .select('invoice_default_notes, clearing_number, account_number, bankgiro, accounting_method') + .select('invoice_default_notes, clearing_number, account_number, bankgiro, accounting_method, ore_rounding') .eq('company_id', company.id) .single() if (data?.invoice_default_notes) { @@ -152,6 +153,9 @@ export default function NewInvoicePage() { if (data?.accounting_method === 'cash' || data?.accounting_method === 'accrual') { setAccountingMethod(data.accounting_method) } + if (typeof data?.ore_rounding === 'boolean') { + setOreRounding(data.ore_rounding) + } } // Preview the next invoice number so the user can catch a mis-set @@ -896,6 +900,7 @@ export default function NewInvoicePage() { ourReference={pendingData?.our_reference} notes={pendingData?.notes} numberPreview={numberPreview} + oreRounding={oreRounding} /> )} diff --git a/app/api/extensions/enable-banking/callback/route.ts b/app/api/extensions/enable-banking/callback/route.ts index ef50e8c1..fbff9890 100644 --- a/app/api/extensions/enable-banking/callback/route.ts +++ b/app/api/extensions/enable-banking/callback/route.ts @@ -149,6 +149,10 @@ export async function GET(request: Request) { }) ) + // Do not set last_synced_at here. The session is created but no transactions + // have been fetched yet; setting it now causes the cron's first-sync 90-day + // backfill path to be skipped if the manual sync triggered by the redirect + // never lands. The first successful sync (manual or cron) will set it. const { error: updateError } = await supabase .from('bank_connections') .update({ @@ -156,7 +160,6 @@ export async function GET(request: Request) { status: 'active', accounts_data: accountsWithBalances, consent_expires: consentExpiresAt, - last_synced_at: new Date().toISOString(), oauth_state: null, // Clear to prevent replay }) .eq('id', pendingConnection.id) diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index 114475f5..afaf904b 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -156,9 +156,13 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => { .limit(1) .maybeSingle() - const syncOptions = sieOverlap - ? { skipAutoCategorization: true } - : undefined + // First sync uses strategy=longest to pull the deepest history available + // from the ASPSP. Incremental syncs skip it — the implicit default is + // faster and we already have the older data. + const syncOptions = { + ...(sieOverlap ? { skipAutoCategorization: true } : {}), + ...(isFirstSync ? { strategy: 'longest' as const } : {}), + } const syncResults = await Promise.all( accounts.map(account => syncAccountTransactions( diff --git a/components/invoices/InvoiceReviewContent.tsx b/components/invoices/InvoiceReviewContent.tsx index c14422d7..9183eb3d 100644 --- a/components/invoices/InvoiceReviewContent.tsx +++ b/components/invoices/InvoiceReviewContent.tsx @@ -3,6 +3,7 @@ import { Badge } from '@/components/ui/badge' import { Separator } from '@/components/ui/separator' import { formatCurrency } from '@/lib/utils' +import { getDisplayTotal } from '@/lib/invoices/rounding' import type { Customer, Currency } from '@/types' interface ReviewItem { @@ -28,6 +29,8 @@ interface InvoiceReviewContentProps { /** The invoice number that will be assigned on confirm. Null when unknown * (e.g. delivery notes use a different sequence) or unfetched. */ numberPreview?: string | null + /** Mirrors `company_settings.ore_rounding`. Defaults to true to match `getDisplayTotal`. */ + oreRounding?: boolean } export function InvoiceReviewContent({ @@ -43,7 +46,9 @@ export function InvoiceReviewContent({ ourReference, notes, numberPreview, + oreRounding, }: InvoiceReviewContentProps) { + const rounding = getDisplayTotal({ total, currency }, { ore_rounding: oreRounding ?? true }) const customerTypeLabel: Record = { individual: 'Privatperson', swedish_business: 'Svenskt företag eller organisation', @@ -160,10 +165,16 @@ export function InvoiceReviewContent({ {formatCurrency(0, currency)} )} + {rounding.applies && ( +
+ Öresavrundning + {formatCurrency(rounding.roundingDelta, currency)} +
+ )}
Totalt - {formatCurrency(total, currency)} + {formatCurrency(rounding.displayed, currency)}
diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts index 349c766b..d050e0eb 100644 --- a/extensions/general/enable-banking/index.ts +++ b/extensions/general/enable-banking/index.ts @@ -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) { diff --git a/extensions/general/enable-banking/lib/__tests__/api-client.test.ts b/extensions/general/enable-banking/lib/__tests__/api-client.test.ts index 0aab82d9..2b664f3b 100644 --- a/extensions/general/enable-banking/lib/__tests__/api-client.test.ts +++ b/extensions/general/enable-banking/lib/__tests__/api-client.test.ts @@ -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() + }) }) }) diff --git a/extensions/general/enable-banking/lib/__tests__/sync.test.ts b/extensions/general/enable-banking/lib/__tests__/sync.test.ts index 59ae87c7..8e13216a 100644 --- a/extensions/general/enable-banking/lib/__tests__/sync.test.ts +++ b/extensions/general/enable-banking/lib/__tests__/sync.test.ts @@ -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' } }], diff --git a/extensions/general/enable-banking/lib/api-client.ts b/extensions/general/enable-banking/lib/api-client.ts index d6e2c0be..d9f1f93b 100644 --- a/extensions/general/enable-banking/lib/api-client.ts +++ b/extensions/general/enable-banking/lib/api-client.ts @@ -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 { 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 { 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 } } diff --git a/extensions/general/enable-banking/lib/sync.ts b/extensions/general/enable-banking/lib/sync.ts index 1f91e51d..443a1eab 100644 --- a/extensions/general/enable-banking/lib/sync.ts +++ b/extensions/general/enable-banking/lib/sync.ts @@ -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)) diff --git a/extensions/general/enable-banking/types.ts b/extensions/general/enable-banking/types.ts index 4abdd2e8..f26e941f 100644 --- a/extensions/general/enable-banking/types.ts +++ b/extensions/general/enable-banking/types.ts @@ -20,6 +20,7 @@ export type { BalanceResponse, Transaction as EnableBankingTransaction, TransactionsResponse, + TransactionsFetchStrategy, Bank, BankTransaction, } from './lib/api-client' diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index 1fc93559..907ff008 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -547,16 +547,16 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN const rounding = getDisplayTotal(invoice, company) return ( <> - - {isCreditNote ? 'Att kreditera:' : 'Att betala:'} - {formatCurrency(rounding.displayed, invoice.currency)} - {rounding.applies && ( Öresavrundning: {formatCurrency(rounding.roundingDelta, 'SEK')} )} + + {isCreditNote ? 'Att kreditera:' : 'Att betala:'} + {formatCurrency(rounding.displayed, invoice.currency)} + ) })()}