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
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
Reference in New Issue
Block a user