diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index 64f62748..5b6d188f 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -131,7 +131,10 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => { const toDate = new Date().toISOString().split('T')[0] // First sync: 90-day lookback (PSD2 max). Subsequent: 7-day window. - const isFirstSync = !connection.last_synced_at + // Gate on initial_sync_completed_at, not last_synced_at — manual "Sync now" + // sets last_synced_at without doing the deep backfill, and we want the cron + // to still fall back to 90 days if the inline activation backfill failed. + const isFirstSync = !connection.initial_sync_completed_at const lookbackDays = isFirstSync ? 90 : 7 if (isFirstSync) { ctx.log.info('first sync for connection — using 90-day lookback', { @@ -218,11 +221,27 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => { // Successful sync: update connection and clear any previous error state. // Write allAccounts (not accounts) so disabled accounts stay in the row. + const completedAt = new Date().toISOString() + let initialSyncFields: Record = {} + if (isFirstSync) { + // Aggregate returned booking dates across enabled accounts so the UI + // can show "we requested X but the bank returned Y to Z". + const minDates = syncResults.map(r => r.returnedMinBookingDate).filter((d): d is string => !!d) + const maxDates = syncResults.map(r => r.returnedMaxBookingDate).filter((d): d is string => !!d) + initialSyncFields = { + initial_sync_completed_at: completedAt, + initial_sync_requested_from: fromDate, + initial_sync_returned_min_date: minDates.length > 0 ? minDates.reduce((a, b) => (a < b ? a : b)) : null, + initial_sync_returned_max_date: maxDates.length > 0 ? maxDates.reduce((a, b) => (a > b ? a : b)) : null, + initial_sync_lookback_days: lookbackDays, + } + } await supabase .from('bank_connections') .update({ accounts_data: allAccounts, - last_synced_at: new Date().toISOString(), + last_synced_at: completedAt, + ...initialSyncFields, ...(connection.error_message ? { error_message: null } : {}), }) .eq('id', connection.id) diff --git a/extensions/general/enable-banking/__tests__/accounts-route.test.ts b/extensions/general/enable-banking/__tests__/accounts-route.test.ts index a37ae556..1866d5d5 100644 --- a/extensions/general/enable-banking/__tests__/accounts-route.test.ts +++ b/extensions/general/enable-banking/__tests__/accounts-route.test.ts @@ -1,8 +1,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock the sync module before importing the extension so the route handler picks up the spy. +vi.mock('../lib/sync', () => ({ + syncAccountTransactions: vi.fn(), +})) + import { enableBankingExtension } from '../index' +import { syncAccountTransactions } from '../lib/sync' import type { ExtensionContext } from '@/lib/extensions/types' import type { StoredAccount } from '../types' +const mockedSync = vi.mocked(syncAccountTransactions) + // Locate the PATCH /accounts handler once — schema doesn't change at runtime. const accountsRoute = enableBankingExtension.apiRoutes?.find( r => r.method === 'PATCH' && r.path === '/accounts' @@ -20,11 +29,22 @@ interface SupabaseStub { accounts_data: StoredAccount[] } | null connectionError?: { message: string } | null + /** Error returned for every update. Use updateErrorByCall for per-call control. */ updateError?: { message: string } | null + /** + * Per-call update errors. Indexed by 0-based call number. Lets a test + * succeed the first update (status flip) and fail the second (metadata). + * Falls back to updateError when the index isn't present. + */ + updateErrorByCall?: Array<{ message: string } | null> + /** Last update payload (may be overwritten by a follow-up metadata update). */ capturedUpdate?: Record + /** All update payloads in order — first is the status flip, second the initial-sync metadata. */ + capturedUpdates?: Record[] } function buildSupabase(stub: SupabaseStub) { + let updateCallCount = 0 return { auth: { getUser: vi.fn().mockResolvedValue({ data: { user: stub.authUser }, error: null }), @@ -37,9 +57,15 @@ function buildSupabase(stub: SupabaseStub) { error: stub.connectionError ?? null, }), update: vi.fn((payload: Record) => { + const callIndex = updateCallCount++ stub.capturedUpdate = payload + ;(stub.capturedUpdates ??= []).push(payload) + const error = + stub.updateErrorByCall && callIndex < stub.updateErrorByCall.length + ? stub.updateErrorByCall[callIndex] + : stub.updateError ?? null return { - eq: vi.fn().mockResolvedValue({ error: stub.updateError ?? null }), + eq: vi.fn().mockResolvedValue({ error }), } }), })), @@ -196,9 +222,13 @@ describe('PATCH /accounts (enable-banking)', () => { const body = await res.json() expect(body).toMatchObject({ success: true, enabled_count: 2, total_count: 3 }) - expect(stub.capturedUpdate).toBeDefined() - expect(stub.capturedUpdate?.status).toBe('active') - const written = stub.capturedUpdate?.accounts_data as StoredAccount[] + // The first update (before inline backfill) is the status flip + accounts_data. + // A second metadata update only follows if backfill succeeds; assert against + // the first explicitly so this test is robust to both paths. + const firstUpdate = stub.capturedUpdates?.[0] + expect(firstUpdate).toBeDefined() + expect(firstUpdate?.status).toBe('active') + const written = firstUpdate?.accounts_data as StoredAccount[] expect(written).toHaveLength(3) expect(written.find(a => a.uid === 'acc-1')?.enabled).toBe(true) expect(written.find(a => a.uid === 'acc-2')?.enabled).toBe(false) @@ -332,4 +362,226 @@ describe('PATCH /accounts (enable-banking)', () => { }) ) }) + + describe('inline initial backfill', () => { + it('runs inline sync on pending_selection→active and writes initial-sync metadata', async () => { + mockedSync.mockResolvedValue({ + imported: 47, + duplicates: 3, + errors: 0, + returnedMinBookingDate: '2026-02-15', + returnedMaxBookingDate: '2026-05-13', + }) + + const stub: SupabaseStub = { + authUser: { id: 'user-1' }, + connectionRow: { + id: 'conn-1', + status: 'pending_selection', + accounts_data: [ + { uid: 'acc-1', currency: 'SEK', enabled: true }, + { uid: 'acc-2', currency: 'EUR', enabled: true }, + ], + }, + } + const supabase = buildSupabase(stub) + const ctx = makeContext(supabase) + + const res = await accountsRoute.handler( + makeRequest({ + connection_id: 'conn-1', + enabled_uids: ['acc-1', 'acc-2'], + initial_lookback_days: 90, + }), + ctx + ) + + expect(res.status).toBe(200) + const body = await res.json() + // Backfill summary surfaced to the UI so the user can see what the bank returned. + expect(body.initial_sync).toMatchObject({ + imported: 94, // 2 accounts × 47 + duplicates: 6, + returned_min_date: '2026-02-15', + returned_max_date: '2026-05-13', + }) + expect(body.initial_sync.requested_from).toMatch(/^\d{4}-\d{2}-\d{2}$/) + expect(body.initial_sync_error).toBeUndefined() + + // syncAccountTransactions called once per enabled account with strategy=longest. + expect(mockedSync).toHaveBeenCalledTimes(2) + expect(mockedSync).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + 'conn-1', + expect.objectContaining({ uid: 'acc-1' }), + expect.any(String), + expect.any(String), + undefined, + { strategy: 'longest' } + ) + + // Two updates: status flip first, then initial_sync metadata. + expect(stub.capturedUpdates).toHaveLength(2) + expect(stub.capturedUpdates?.[0]?.status).toBe('active') + const meta = stub.capturedUpdates?.[1] + expect(meta?.initial_sync_completed_at).toBeDefined() + expect(meta?.initial_sync_returned_min_date).toBe('2026-02-15') + expect(meta?.initial_sync_returned_max_date).toBe('2026-05-13') + expect(meta?.initial_sync_lookback_days).toBe(90) + expect(meta?.last_synced_at).toBeDefined() + }) + + it('does NOT run inline sync when connection is already active (selection edit)', async () => { + mockedSync.mockResolvedValue({ + imported: 99, + duplicates: 0, + errors: 0, + returnedMinBookingDate: '2026-01-01', + returnedMaxBookingDate: '2026-05-13', + }) + + const stub: SupabaseStub = { + authUser: { id: 'user-1' }, + connectionRow: { + id: 'conn-1', + status: 'active', + accounts_data: [ + { uid: 'acc-1', currency: 'SEK', enabled: true }, + { uid: 'acc-2', currency: 'SEK', enabled: false }, + ], + }, + } + const supabase = buildSupabase(stub) + const ctx = makeContext(supabase) + + const res = await accountsRoute.handler( + makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-2'] }), + ctx + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.initial_sync).toBeUndefined() + expect(body.initial_sync_error).toBeUndefined() + + expect(mockedSync).not.toHaveBeenCalled() + // Only one update — the original selection edit, no metadata follow-up. + expect(stub.capturedUpdates).toHaveLength(1) + }) + + it('still flips status to active when inline sync fails, surfacing initial_sync_error', async () => { + mockedSync.mockRejectedValue(new Error('ASPSP_DOWN')) + + const stub: SupabaseStub = { + authUser: { id: 'user-1' }, + connectionRow: { + id: 'conn-1', + status: 'pending_selection', + accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true }], + }, + } + const supabase = buildSupabase(stub) + const ctx = makeContext(supabase) + + const res = await accountsRoute.handler( + makeRequest({ + connection_id: 'conn-1', + enabled_uids: ['acc-1'], + initial_lookback_days: 180, + }), + ctx + ) + + // PATCH still succeeds — the cron will retry the backfill on its next run. + expect(res.status).toBe(200) + const body = await res.json() + expect(body.success).toBe(true) + expect(body.initial_sync).toBeUndefined() + expect(body.initial_sync_error).toBe('ASPSP_DOWN') + + // Status flip happened; no metadata follow-up because sync threw. + expect(stub.capturedUpdates).toHaveLength(1) + expect(stub.capturedUpdates?.[0]?.status).toBe('active') + }) + + it('clamps initial_lookback_days to [30, 365]', async () => { + mockedSync.mockResolvedValue({ + imported: 0, + duplicates: 0, + errors: 0, + }) + + const stub: SupabaseStub = { + authUser: { id: 'user-1' }, + connectionRow: { + id: 'conn-1', + status: 'pending_selection', + accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true }], + }, + } + const supabase = buildSupabase(stub) + const ctx = makeContext(supabase) + + // 9999 days → clamped to 365 + await accountsRoute.handler( + makeRequest({ + connection_id: 'conn-1', + enabled_uids: ['acc-1'], + initial_lookback_days: 9999, + }), + ctx + ) + + expect(stub.capturedUpdates?.[1]?.initial_sync_lookback_days).toBe(365) + }) + + it('surfaces metadata_update_failed when the second update errors after a successful sync', async () => { + // Sync runs and ingests transactions, but persisting initial_sync_completed_at + // fails. The client must see the failure (not a fake success) so the UI can + // show a retry warning; the cron will gate on initial_sync_completed_at IS NULL + // and self-heal on its next run. + mockedSync.mockResolvedValue({ + imported: 12, + duplicates: 0, + errors: 0, + returnedMinBookingDate: '2026-03-01', + returnedMaxBookingDate: '2026-05-13', + }) + + const stub: SupabaseStub = { + authUser: { id: 'user-1' }, + connectionRow: { + id: 'conn-1', + status: 'pending_selection', + accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true }], + }, + // First update (status flip) succeeds; second (metadata) fails. + updateErrorByCall: [null, { message: 'connection lost' }], + } + const supabase = buildSupabase(stub) + const ctx = makeContext(supabase) + + const res = await accountsRoute.handler( + makeRequest({ + connection_id: 'conn-1', + enabled_uids: ['acc-1'], + initial_lookback_days: 90, + }), + ctx + ) + + expect(res.status).toBe(200) + const body = await res.json() + // No fake success: initial_sync must NOT be populated. + expect(body.initial_sync).toBeUndefined() + // The error code surfaces the metadata-update failure mode so the UI + // and audit log can distinguish it from an ingest-side failure. + expect(body.initial_sync_error).toMatch(/^metadata_update_failed:/) + // Status flip still happened — connection is active, cron will retry backfill. + expect(stub.capturedUpdates?.[0]?.status).toBe('active') + expect(stub.capturedUpdates).toHaveLength(2) + }) + }) }) diff --git a/extensions/general/enable-banking/components/AccountPickerDialog.tsx b/extensions/general/enable-banking/components/AccountPickerDialog.tsx index 44c85ea2..2776d45b 100644 --- a/extensions/general/enable-banking/components/AccountPickerDialog.tsx +++ b/extensions/general/enable-banking/components/AccountPickerDialog.tsx @@ -11,8 +11,17 @@ import { } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { useToast } from '@/components/ui/use-toast' import { Loader2 } from 'lucide-react' +import { createClient } from '@/lib/supabase/client' +import { useCompany } from '@/contexts/CompanyContext' import type { StoredAccount } from '../types' interface AccountPickerDialogProps { @@ -28,6 +37,12 @@ interface AccountPickerDialogProps { onSaved: () => void } +const LOOKBACK_OPTIONS = [ + { days: 90, label: 'Senaste 90 dagar (PSD2 standard, rekommenderas)' }, + { days: 180, label: 'Senaste 180 dagar' }, + { days: 365, label: 'Senaste 365 dagar' }, +] as const + export function AccountPickerDialog({ open, onOpenChange, @@ -38,8 +53,17 @@ export function AccountPickerDialog({ onSaved, }: AccountPickerDialogProps) { const { toast } = useToast() + // Memoise so the client has a stable reference across re-renders. Without this, + // listing `supabase` in the SIE-fetch effect's deps would re-fire that query on + // every checkbox tick or parent re-render. + const supabase = useMemo(() => createClient(), []) + const { company } = useCompany() + const [selected, setSelected] = useState>(new Set()) const [isSaving, setIsSaving] = useState(false) + const [lookbackDays, setLookbackDays] = useState(90) + const [sieLastDate, setSieLastDate] = useState(null) + const [showCustomLookback, setShowCustomLookback] = useState(false) useEffect(() => { if (open) { @@ -49,9 +73,44 @@ export function AccountPickerDialog({ accounts.filter(a => a.enabled !== false).map(a => a.uid) ) setSelected(initial) + setShowCustomLookback(false) } }, [open, accounts]) + // Fetch the latest SIE import end date so we can anchor the backfill to + // "day after last SIE entry" (SpeedLedger pattern). Only matters on the + // initial activation flow — selection edits don't re-run sync. + useEffect(() => { + if (!open || !isInitialSelection || !company?.id) { + setSieLastDate(null) + return + } + let cancelled = false + ;(async () => { + const { data } = await supabase + .from('sie_imports') + .select('fiscal_year_end') + .eq('company_id', company.id) + .eq('status', 'completed') + .order('fiscal_year_end', { ascending: false }) + .limit(1) + .maybeSingle() + if (cancelled) return + const fye = (data as { fiscal_year_end?: string } | null)?.fiscal_year_end || null + setSieLastDate(fye) + if (fye) { + // Anchor lookback to (today - (fye + 1 day)), clamped to [30, 365]. + const dayAfter = new Date(fye) + dayAfter.setDate(dayAfter.getDate() + 1) + const days = Math.ceil((Date.now() - dayAfter.getTime()) / (24 * 60 * 60 * 1000)) + setLookbackDays(Math.min(365, Math.max(30, days))) + } else { + setLookbackDays(90) + } + })() + return () => { cancelled = true } + }, [open, isInitialSelection, company?.id, supabase]) + const allSelected = accounts.length > 0 && selected.size === accounts.length const noneSelected = selected.size === 0 @@ -95,6 +154,7 @@ export function AccountPickerDialog({ body: JSON.stringify({ connection_id: connectionId, enabled_uids: Array.from(selected), + ...(isInitialSelection ? { initial_lookback_days: lookbackDays } : {}), }), }) @@ -104,10 +164,33 @@ export function AccountPickerDialog({ throw new Error(data.error || 'Kunde inte spara kontoval') } - toast({ - title: 'Kontoval sparat', - description: `${data.enabled_count} av ${data.total_count} konton kommer synkas.`, - }) + // Surface the actual backfill result so the user sees what the bank returned. + if (isInitialSelection && data.initial_sync) { + const { imported, returned_min_date, returned_max_date } = data.initial_sync as { + imported: number + returned_min_date: string | null + returned_max_date: string | null + } + const range = returned_min_date && returned_max_date + ? ` från ${returned_min_date} till ${returned_max_date}` + : '' + toast({ + title: 'Konton sparade', + description: `Importerade ${imported} transaktioner${range}.`, + }) + } else if (isInitialSelection && data.initial_sync_error) { + toast({ + title: 'Konton sparade — bakgrundssync misslyckades', + description: 'Vi försöker igen vid nästa körning. Bankanslutningen är aktiv.', + variant: 'destructive', + }) + } else { + toast({ + title: 'Kontoval sparat', + description: `${data.enabled_count} av ${data.total_count} konton kommer synkas.`, + }) + } + onOpenChange(false) onSaved() } catch (error) { @@ -121,6 +204,14 @@ export function AccountPickerDialog({ } } + // Day-after-SIE date for the callout + const dayAfterSie = useMemo(() => { + if (!sieLastDate) return null + const d = new Date(sieLastDate) + d.setDate(d.getDate() + 1) + return d.toISOString().split('T')[0] + }, [sieLastDate]) + return ( @@ -133,6 +224,75 @@ export function AccountPickerDialog({ + {isInitialSelection && ( +
+ {sieLastDate && dayAfterSie ? ( +
+

+ Vi hittade en SIE-import som täcker fram till{' '} + {sieLastDate}. + Vi hämtar bankhistorik från{' '} + {dayAfterSie}{' '} + så att inget överlappar din tidigare bokföring. +

+ + {showCustomLookback && ( + + )} +
+ ) : ( +
+ + +

+ PSD2-bankregler begränsar oftast historiken till 90 dagar bakåt. + Vi visar exakt vad banken returnerade efter sparat val. + För äldre data, använd SIE- eller CSV-import. +

+
+ )} +
+ )} +
{selected.size} av {accounts.length} valda @@ -207,7 +367,7 @@ export function AccountPickerDialog({ {isSaving ? ( <> - Sparar… + {isInitialSelection ? 'Sparar och hämtar transaktioner…' : 'Sparar…'} ) : ( 'Spara val' diff --git a/extensions/general/enable-banking/components/BankConnectionStatus.tsx b/extensions/general/enable-banking/components/BankConnectionStatus.tsx index d325f291..4ba1599f 100644 --- a/extensions/general/enable-banking/components/BankConnectionStatus.tsx +++ b/extensions/general/enable-banking/components/BankConnectionStatus.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' import { formatDate } from '@/lib/utils' import { getDaysUntilExpiry, isConsentExpiringSoon } from '../lib/api-client' import Link from 'next/link' @@ -241,6 +242,37 @@ export function BankConnectionStatus({
)} + {/* Initial backfill summary — shows what the bank actually returned vs what we asked for. */} + {connection.initial_sync_completed_at && connection.initial_sync_requested_from && (() => { + const requested = connection.initial_sync_requested_from + const min = connection.initial_sync_returned_min_date + const max = connection.initial_sync_returned_max_date + // Truncation = bank returned less history than requested. 7-day grace + // for off-by-one + weekend posting differences. + let truncated = false + if (min && requested) { + const requestedTime = new Date(requested).getTime() + const minTime = new Date(min).getTime() + truncated = (minTime - requestedTime) > 7 * 24 * 60 * 60 * 1000 + } + return ( +
+ + Initial historik:{' '} + + {min ? formatDate(min) : '—'} → {max ? formatDate(max) : '—'} + + {' '}(begärde {formatDate(requested)}) + + {truncated && ( + + Bankens API returnerade kortare period än begärt — använd SIE-import för äldre data + + )} +
+ ) + })()} + {/* Accounts list */} {accounts.length > 0 && (
diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts index 63d64fd7..367abe7d 100644 --- a/extensions/general/enable-banking/index.ts +++ b/extensions/general/enable-banking/index.ts @@ -256,7 +256,9 @@ export const enableBankingExtension: Extension = { }) if (!rl.ok) return rl.response! - const { connection_id, days_back: rawDaysBack = 30 } = await request.json() + // Default 90 days: most callers (Sync Now button, post-activation gap fill) + // want a deep refresh, not a 30-day blip. Cron uses 7-day incrementals separately. + const { connection_id, days_back: rawDaysBack = 90 } = await request.json() const days_back = Math.min(Math.max(1, rawDaysBack), 365) const { data: connection, error: connectionError } = await supabase @@ -453,6 +455,7 @@ export const enableBankingExtension: Extension = { const body = await request.json().catch(() => null) const connection_id = body?.connection_id const enabled_uids = body?.enabled_uids + const rawLookback = body?.initial_lookback_days if (typeof connection_id !== 'string' || !connection_id) { return NextResponse.json({ error: 'connection_id krävs' }, { status: 400 }) @@ -473,6 +476,13 @@ export const enableBankingExtension: Extension = { ) } + // initial_lookback_days only applies on the pending_selection→active transition. + // Default 90 (PSD2 standard); clamp to [30, 365]. Ignored for selection edits. + const initialLookbackDays = (() => { + const n = typeof rawLookback === 'number' && Number.isFinite(rawLookback) ? rawLookback : 90 + return Math.min(365, Math.max(30, Math.round(n))) + })() + const { data: connection, error: connectionError } = await supabase .from('bank_connections') .select('id, status, accounts_data, bank_name') @@ -568,10 +578,130 @@ export const enableBankingExtension: Extension = { }) } + // Initial backfill on activation. Run inline so the user has data the + // moment they finish account selection — no 24h cron wait. Failures + // here don't fail the PATCH; the cron will retry on its next run + // (gated on initial_sync_completed_at IS NULL). + let initialSyncSummary: { + imported: number + duplicates: number + requested_from: string + returned_min_date: string | null + returned_max_date: string | null + } | null = null + let initialSyncError: string | null = null + + if (connection.status === 'pending_selection') { + const accountsToSync = updatedAccounts.filter(a => a.enabled !== false) + const toDate = new Date().toISOString().split('T')[0] + const fromDate = new Date(Date.now() - initialLookbackDays * 24 * 60 * 60 * 1000) + .toISOString() + .split('T')[0] + + log.info('[enable-banking] Starting inline initial backfill', { + connectionId: connection.id, + accountCount: accountsToSync.length, + lookbackDays: initialLookbackDays, + fromDate, + toDate, + }) + + let timeoutHandle: ReturnType | undefined + try { + const ingestFn = ctx?.services.ingestTransactions + const syncPromise = Promise.all( + accountsToSync.map(account => syncAccountTransactions( + supabase, + companyId, + user.id, + connection.id, + account, + fromDate, + toDate, + ingestFn, + { strategy: 'longest' } + )) + ) + + const TIMEOUT_MS = 60_000 + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error('initial_sync_timeout')), TIMEOUT_MS) + }) + const results = await Promise.race([syncPromise, timeoutPromise]) + + const totalImported = results.reduce((sum, r) => sum + r.imported, 0) + const totalDuplicates = results.reduce((sum, r) => sum + r.duplicates, 0) + + // Min/max booking date across all synced accounts + const minDates = results.map(r => r.returnedMinBookingDate).filter((d): d is string => !!d) + const maxDates = results.map(r => r.returnedMaxBookingDate).filter((d): d is string => !!d) + const returnedMin = minDates.length > 0 ? minDates.reduce((a, b) => (a < b ? a : b)) : null + const returnedMax = maxDates.length > 0 ? maxDates.reduce((a, b) => (a > b ? a : b)) : null + + const completedAt = new Date().toISOString() + // Don't re-write accounts_data here — the first update already wrote it. + // Including it again races with any concurrent writer (e.g. cron firing in + // the sub-60s window) and would silently overwrite their changes. + const { error: metaUpdateError } = await supabase + .from('bank_connections') + .update({ + last_synced_at: completedAt, + initial_sync_completed_at: completedAt, + initial_sync_requested_from: fromDate, + initial_sync_returned_min_date: returnedMin, + initial_sync_returned_max_date: returnedMax, + initial_sync_lookback_days: initialLookbackDays, + }) + .eq('id', connection.id) + + if (metaUpdateError) { + // The sync itself succeeded (transactions are ingested) but we + // couldn't persist that. Falsely reporting success would tell the + // client "imported N transactions" while the DB still has + // initial_sync_completed_at = NULL, causing the cron to re-run a + // 90-day backfill next morning. Surface this as initial_sync_error + // so the UI shows a "background sync needs retry" warning, and the + // cron's gate (initial_sync_completed_at IS NULL) will self-heal. + initialSyncError = `metadata_update_failed: ${metaUpdateError.message}` + log.error('[enable-banking] Failed to persist initial_sync metadata after backfill', { + connectionId: connection.id, + error: metaUpdateError.message, + userId: user.id, + companyId, + }) + } else { + initialSyncSummary = { + imported: totalImported, + duplicates: totalDuplicates, + requested_from: fromDate, + returned_min_date: returnedMin, + returned_max_date: returnedMax, + } + + log.info('[enable-banking] Inline initial backfill complete', { + connectionId: connection.id, + ...initialSyncSummary, + }) + } + } catch (syncError) { + initialSyncError = syncError instanceof Error ? syncError.message : String(syncError) + log.error('[enable-banking] Inline initial backfill failed — cron will retry', { + connectionId: connection.id, + error: initialSyncError, + userId: user.id, + companyId, + }) + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle) + } + } + return NextResponse.json({ success: true, enabled_count: enabled_uids.length, total_count: existing.length, + ...(initialSyncSummary ? { initial_sync: initialSyncSummary } : {}), + ...(initialSyncError ? { initial_sync_error: initialSyncError } : {}), }) }, }, diff --git a/extensions/general/enable-banking/lib/__tests__/sync.test.ts b/extensions/general/enable-banking/lib/__tests__/sync.test.ts index 8e13216a..f2c63dcb 100644 --- a/extensions/general/enable-banking/lib/__tests__/sync.test.ts +++ b/extensions/general/enable-banking/lib/__tests__/sync.test.ts @@ -219,4 +219,65 @@ describe('syncAccountTransactions', () => { expect(rawTxns[0].external_id).toBe('eb_acc-uid-1_tx-500') expect(rawTxns[0].import_source).toBe('enable_banking') }) + + it('returns the min/max booking date the ASPSP returned for the activation UI', async () => { + // The min/max loop reads booking_date from the *raw* transactions (sync.ts:75-82), + // before convertTransaction runs — so the dates need to be set here. + mockGetAllTransactionsWithRaw.mockResolvedValue({ + transactions: [ + { transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2026-04-15' }, + { transaction_amount: { amount: '200', currency: 'SEK' }, booking_date: '2026-02-20' }, + { transaction_amount: { amount: '300', currency: 'SEK' }, booking_date: '2026-05-10' }, + ], + rawPages: ['{}'], + }) + + mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string }, booking_date: string }) => ({ + id: `tx-${tx.transaction_amount.amount}`, + date: tx.booking_date, + booking_date: tx.booking_date, + amount: parseFloat(tx.transaction_amount.amount), + currency: 'SEK', + description: 'Test', + })) + + mockUploadDocument.mockResolvedValue({ id: 'doc-1' }) + + const account = makeAccount() + const result = await syncAccountTransactions( + {} as never, + COMPANY_ID, + USER_ID, + CONNECTION_ID, + account, + '2026-02-13', + '2026-05-13', + mockIngest + ) + + expect(result.returnedMinBookingDate).toBe('2026-02-20') + expect(result.returnedMaxBookingDate).toBe('2026-05-10') + }) + + it('returns undefined min/max when no transactions came back', async () => { + mockGetAllTransactionsWithRaw.mockResolvedValue({ + transactions: [], + rawPages: [], + }) + + const account = makeAccount() + const result = await syncAccountTransactions( + {} as never, + COMPANY_ID, + USER_ID, + CONNECTION_ID, + account, + '2026-02-13', + '2026-05-13', + mockIngest + ) + + expect(result.returnedMinBookingDate).toBeUndefined() + expect(result.returnedMaxBookingDate).toBeUndefined() + }) }) diff --git a/extensions/general/enable-banking/lib/sync.ts b/extensions/general/enable-banking/lib/sync.ts index 443a1eab..379136be 100644 --- a/extensions/general/enable-banking/lib/sync.ts +++ b/extensions/general/enable-banking/lib/sync.ts @@ -30,6 +30,10 @@ export interface SyncResult { imported: number duplicates: number errors: number + /** Earliest booking date the ASPSP returned. Undefined when no transactions came back. */ + returnedMinBookingDate?: string + /** Latest booking date the ASPSP returned. Undefined when no transactions came back. */ + returnedMaxBookingDate?: string } /** @@ -150,5 +154,7 @@ export async function syncAccountTransactions( imported: ingestResult.imported, duplicates: ingestResult.duplicates, errors: ingestResult.errors, + returnedMinBookingDate: minBookingDate, + returnedMaxBookingDate: maxBookingDate, } } diff --git a/supabase/migrations/20260514125831_bank_connection_initial_sync_metadata.sql b/supabase/migrations/20260514125831_bank_connection_initial_sync_metadata.sql new file mode 100644 index 00000000..b841155b --- /dev/null +++ b/supabase/migrations/20260514125831_bank_connection_initial_sync_metadata.sql @@ -0,0 +1,19 @@ +-- Track initial-sync metadata per bank connection. +-- +-- Decouples "have we ever done a backfill" (initial_sync_completed_at) from +-- "when did we last incrementally sync" (last_synced_at). The cron's first-sync +-- 90-day backfill path is gated on initial_sync_completed_at IS NULL, so the +-- "Sync now" button setting last_synced_at no longer permanently loses the +-- backfill window. +-- +-- The returned-date columns power the UI's "we requested X but got Y" disclosure +-- when an ASPSP truncates history below the requested window. + +ALTER TABLE public.bank_connections + ADD COLUMN IF NOT EXISTS initial_sync_completed_at timestamptz, + ADD COLUMN IF NOT EXISTS initial_sync_requested_from date, + ADD COLUMN IF NOT EXISTS initial_sync_returned_min_date date, + ADD COLUMN IF NOT EXISTS initial_sync_returned_max_date date, + ADD COLUMN IF NOT EXISTS initial_sync_lookback_days int; + +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index fee08438..642a6a47 100644 --- a/types/index.ts +++ b/types/index.ts @@ -293,6 +293,17 @@ export interface BankConnection { last_synced_at: string | null error_message: string | null + // Initial-sync metadata. initial_sync_completed_at gates the cron's + // first-sync 90-day backfill path independently of last_synced_at, so + // a manual "Sync now" doesn't permanently lose the deep backfill window. + // The returned-date columns power the "we requested X but got Y" UI when + // an ASPSP truncates history below the requested window. + initial_sync_completed_at: string | null + initial_sync_requested_from: string | null + initial_sync_returned_min_date: string | null + initial_sync_returned_max_date: string | null + initial_sync_lookback_days: number | null + created_at: string updated_at: string }