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:
Mattsson
2026-05-11 00:27:44 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 75043632de
commit ed9bdc4c00
11 changed files with 218 additions and 17 deletions
+6 -1
View File
@@ -79,6 +79,7 @@ export default function NewInvoicePage() {
const [hasBankDetails, setHasBankDetails] = useState<boolean | null>(null)
const [showBankSetup, setShowBankSetup] = useState(false)
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
const [oreRounding, setOreRounding] = useState<boolean>(true)
const [numberPreview, setNumberPreview] = useState<string | null>(null)
const pendingCustomerRef = useRef<Customer | null>(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}
/>
</ConfirmationDialog>
)}
@@ -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)
@@ -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(
+12 -1
View File
@@ -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<string, string> = {
individual: 'Privatperson',
swedish_business: 'Svenskt företag eller organisation',
@@ -160,10 +165,16 @@ export function InvoiceReviewContent({
<span>{formatCurrency(0, currency)}</span>
</div>
)}
{rounding.applies && (
<div className="flex justify-between">
<span className="text-muted-foreground">Öresavrundning</span>
<span>{formatCurrency(rounding.roundingDelta, currency)}</span>
</div>
)}
<Separator />
<div className="flex justify-between font-bold text-xl sm:text-2xl">
<span>Totalt</span>
<span>{formatCurrency(total, currency)}</span>
<span>{formatCurrency(rounding.displayed, currency)}</span>
</div>
</div>
@@ -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 }
}
+24 -1
View File
@@ -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'
+4 -4
View File
@@ -547,16 +547,16 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
const rounding = getDisplayTotal(invoice, company)
return (
<>
<View style={styles.grandTotal}>
<Text style={styles.grandTotalLabel}>{isCreditNote ? 'Att kreditera:' : 'Att betala:'}</Text>
<Text style={styles.grandTotalValue}>{formatCurrency(rounding.displayed, invoice.currency)}</Text>
</View>
{rounding.applies && (
<View style={styles.totalRow}>
<Text style={[styles.totalLabel, { fontSize: 8 }]}>Öresavrundning:</Text>
<Text style={[styles.totalValue, { fontSize: 8 }]}>{formatCurrency(rounding.roundingDelta, 'SEK')}</Text>
</View>
)}
<View style={styles.grandTotal}>
<Text style={styles.grandTotalLabel}>{isCreditNote ? 'Att kreditera:' : 'Att betala:'}</Text>
<Text style={styles.grandTotalValue}>{formatCurrency(rounding.displayed, invoice.currency)}</Text>
</View>
</>
)
})()}