* fix(enable-banking): reliable initial backfill, no more silent ~30-day windows (#443) PSD2 first-sync was a compound bug: the cron runs once daily so users got no data for up to 24h after activation; the "Sync now" button defaulted to 30 days and set last_synced_at, permanently locking the cron into 7-day incremental mode and discarding the 90-day backfill window. ASPSPs also truncate history below requested ranges, but the discrepancy was only logged. This change: - Runs the initial backfill inline when the user finishes account selection (PATCH /accounts), so data is available the moment they finish onboarding. - Tracks initial_sync_completed_at separately from last_synced_at; the cron now gates first-sync 90-day window on that, so manual syncs no longer clobber the backfill path. - Surfaces the actual returned date range to the UI ("Initial historik: X → Y (begärde Z)") with a warning when the bank truncated history. - Defaults manual /sync to 90 days (was 30) — matches user intent. - AccountPickerDialog uses SpeedLedger's SIE-anchor pattern when an SIE import covers prior periods (auto-defaults lookback to "day after last SIE entry"), with Bokio-style PSD2 disclosure on the standard path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(enable-banking): address review feedback on PR #486 Two fixes from review: 1. Memoise the browser Supabase client in AccountPickerDialog. createClient() in the component body returned a new reference every render, and `supabase` was in the SIE-fetch effect's dep array — every checkbox tick or parent re-render re-fired the SIE-imports query. 2. Drop `accounts_data` from the second supabase update inside the activation backfill. The first update already wrote it; including it here races with any concurrent writer (e.g. cron firing in the sub-60s window) and would silently overwrite. Only initial_sync_* metadata + last_synced_at need to be persisted in the second update. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(enable-banking): check metadata-update error after inline backfill The second supabase.update() inside the activation backfill block didn't check its error return. Supabase client methods don't throw on DB errors; they return { data, error }. If the metadata write failed (network blip, RLS quirk, etc.), the handler still populated initialSyncSummary and returned success — UI saw "imported N transactions" while the DB had initial_sync_completed_at = NULL, causing the cron to schedule another full 90-day backfill the next morning. Capture { error } from the metadata update. On failure, surface as initial_sync_error with a metadata_update_failed: prefix and skip the initialSyncSummary population. The cron's gate (initial_sync_completed_at IS NULL) still self-heals on the next run; this just keeps the UI honest about which path got us there. New test stub: SupabaseStub.updateErrorByCall lets a test succeed the first update and fail the second. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- 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
5eee06a56d
commit
04f902fe8f
@@ -131,7 +131,10 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
|
|||||||
|
|
||||||
const toDate = new Date().toISOString().split('T')[0]
|
const toDate = new Date().toISOString().split('T')[0]
|
||||||
// First sync: 90-day lookback (PSD2 max). Subsequent: 7-day window.
|
// 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
|
const lookbackDays = isFirstSync ? 90 : 7
|
||||||
if (isFirstSync) {
|
if (isFirstSync) {
|
||||||
ctx.log.info('first sync for connection — using 90-day lookback', {
|
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.
|
// Successful sync: update connection and clear any previous error state.
|
||||||
// Write allAccounts (not accounts) so disabled accounts stay in the row.
|
// Write allAccounts (not accounts) so disabled accounts stay in the row.
|
||||||
|
const completedAt = new Date().toISOString()
|
||||||
|
let initialSyncFields: Record<string, unknown> = {}
|
||||||
|
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
|
await supabase
|
||||||
.from('bank_connections')
|
.from('bank_connections')
|
||||||
.update({
|
.update({
|
||||||
accounts_data: allAccounts,
|
accounts_data: allAccounts,
|
||||||
last_synced_at: new Date().toISOString(),
|
last_synced_at: completedAt,
|
||||||
|
...initialSyncFields,
|
||||||
...(connection.error_message ? { error_message: null } : {}),
|
...(connection.error_message ? { error_message: null } : {}),
|
||||||
})
|
})
|
||||||
.eq('id', connection.id)
|
.eq('id', connection.id)
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
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 { enableBankingExtension } from '../index'
|
||||||
|
import { syncAccountTransactions } from '../lib/sync'
|
||||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||||
import type { StoredAccount } from '../types'
|
import type { StoredAccount } from '../types'
|
||||||
|
|
||||||
|
const mockedSync = vi.mocked(syncAccountTransactions)
|
||||||
|
|
||||||
// Locate the PATCH /accounts handler once — schema doesn't change at runtime.
|
// Locate the PATCH /accounts handler once — schema doesn't change at runtime.
|
||||||
const accountsRoute = enableBankingExtension.apiRoutes?.find(
|
const accountsRoute = enableBankingExtension.apiRoutes?.find(
|
||||||
r => r.method === 'PATCH' && r.path === '/accounts'
|
r => r.method === 'PATCH' && r.path === '/accounts'
|
||||||
@@ -20,11 +29,22 @@ interface SupabaseStub {
|
|||||||
accounts_data: StoredAccount[]
|
accounts_data: StoredAccount[]
|
||||||
} | null
|
} | null
|
||||||
connectionError?: { message: string } | null
|
connectionError?: { message: string } | null
|
||||||
|
/** Error returned for every update. Use updateErrorByCall for per-call control. */
|
||||||
updateError?: { message: string } | null
|
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<string, unknown>
|
capturedUpdate?: Record<string, unknown>
|
||||||
|
/** All update payloads in order — first is the status flip, second the initial-sync metadata. */
|
||||||
|
capturedUpdates?: Record<string, unknown>[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSupabase(stub: SupabaseStub) {
|
function buildSupabase(stub: SupabaseStub) {
|
||||||
|
let updateCallCount = 0
|
||||||
return {
|
return {
|
||||||
auth: {
|
auth: {
|
||||||
getUser: vi.fn().mockResolvedValue({ data: { user: stub.authUser }, error: null }),
|
getUser: vi.fn().mockResolvedValue({ data: { user: stub.authUser }, error: null }),
|
||||||
@@ -37,9 +57,15 @@ function buildSupabase(stub: SupabaseStub) {
|
|||||||
error: stub.connectionError ?? null,
|
error: stub.connectionError ?? null,
|
||||||
}),
|
}),
|
||||||
update: vi.fn((payload: Record<string, unknown>) => {
|
update: vi.fn((payload: Record<string, unknown>) => {
|
||||||
|
const callIndex = updateCallCount++
|
||||||
stub.capturedUpdate = payload
|
stub.capturedUpdate = payload
|
||||||
|
;(stub.capturedUpdates ??= []).push(payload)
|
||||||
|
const error =
|
||||||
|
stub.updateErrorByCall && callIndex < stub.updateErrorByCall.length
|
||||||
|
? stub.updateErrorByCall[callIndex]
|
||||||
|
: stub.updateError ?? null
|
||||||
return {
|
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()
|
const body = await res.json()
|
||||||
expect(body).toMatchObject({ success: true, enabled_count: 2, total_count: 3 })
|
expect(body).toMatchObject({ success: true, enabled_count: 2, total_count: 3 })
|
||||||
|
|
||||||
expect(stub.capturedUpdate).toBeDefined()
|
// The first update (before inline backfill) is the status flip + accounts_data.
|
||||||
expect(stub.capturedUpdate?.status).toBe('active')
|
// A second metadata update only follows if backfill succeeds; assert against
|
||||||
const written = stub.capturedUpdate?.accounts_data as StoredAccount[]
|
// 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).toHaveLength(3)
|
||||||
expect(written.find(a => a.uid === 'acc-1')?.enabled).toBe(true)
|
expect(written.find(a => a.uid === 'acc-1')?.enabled).toBe(true)
|
||||||
expect(written.find(a => a.uid === 'acc-2')?.enabled).toBe(false)
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,8 +11,17 @@ import {
|
|||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import { createClient } from '@/lib/supabase/client'
|
||||||
|
import { useCompany } from '@/contexts/CompanyContext'
|
||||||
import type { StoredAccount } from '../types'
|
import type { StoredAccount } from '../types'
|
||||||
|
|
||||||
interface AccountPickerDialogProps {
|
interface AccountPickerDialogProps {
|
||||||
@@ -28,6 +37,12 @@ interface AccountPickerDialogProps {
|
|||||||
onSaved: () => void
|
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({
|
export function AccountPickerDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -38,8 +53,17 @@ export function AccountPickerDialog({
|
|||||||
onSaved,
|
onSaved,
|
||||||
}: AccountPickerDialogProps) {
|
}: AccountPickerDialogProps) {
|
||||||
const { toast } = useToast()
|
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<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [lookbackDays, setLookbackDays] = useState<number>(90)
|
||||||
|
const [sieLastDate, setSieLastDate] = useState<string | null>(null)
|
||||||
|
const [showCustomLookback, setShowCustomLookback] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -49,9 +73,44 @@ export function AccountPickerDialog({
|
|||||||
accounts.filter(a => a.enabled !== false).map(a => a.uid)
|
accounts.filter(a => a.enabled !== false).map(a => a.uid)
|
||||||
)
|
)
|
||||||
setSelected(initial)
|
setSelected(initial)
|
||||||
|
setShowCustomLookback(false)
|
||||||
}
|
}
|
||||||
}, [open, accounts])
|
}, [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 allSelected = accounts.length > 0 && selected.size === accounts.length
|
||||||
const noneSelected = selected.size === 0
|
const noneSelected = selected.size === 0
|
||||||
|
|
||||||
@@ -95,6 +154,7 @@ export function AccountPickerDialog({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
connection_id: connectionId,
|
connection_id: connectionId,
|
||||||
enabled_uids: Array.from(selected),
|
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')
|
throw new Error(data.error || 'Kunde inte spara kontoval')
|
||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
// Surface the actual backfill result so the user sees what the bank returned.
|
||||||
title: 'Kontoval sparat',
|
if (isInitialSelection && data.initial_sync) {
|
||||||
description: `${data.enabled_count} av ${data.total_count} konton kommer synkas.`,
|
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)
|
onOpenChange(false)
|
||||||
onSaved()
|
onSaved()
|
||||||
} catch (error) {
|
} 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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-xl">
|
<DialogContent className="max-w-xl">
|
||||||
@@ -133,6 +224,75 @@ export function AccountPickerDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
{isInitialSelection && (
|
||||||
|
<div className="rounded-lg border border-border bg-muted/30 p-4 text-sm">
|
||||||
|
{sieLastDate && dayAfterSie ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>
|
||||||
|
Vi hittade en SIE-import som täcker fram till{' '}
|
||||||
|
<span className="font-medium tabular-nums">{sieLastDate}</span>.
|
||||||
|
Vi hämtar bankhistorik från{' '}
|
||||||
|
<span className="font-medium tabular-nums">{dayAfterSie}</span>{' '}
|
||||||
|
så att inget överlappar din tidigare bokföring.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCustomLookback(v => !v)}
|
||||||
|
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{showCustomLookback ? 'Använd föreslagen period' : 'Anpassa period'}
|
||||||
|
</button>
|
||||||
|
{showCustomLookback && (
|
||||||
|
<Select
|
||||||
|
value={String(lookbackDays)}
|
||||||
|
onValueChange={(v) => setLookbackDays(Number(v))}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-2 w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{LOOKBACK_OPTIONS.map(opt => (
|
||||||
|
<SelectItem key={opt.days} value={String(opt.days)}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
Hämta historik från
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={String(lookbackDays)}
|
||||||
|
onValueChange={(v) => setLookbackDays(Number(v))}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{LOOKBACK_OPTIONS.map(opt => (
|
||||||
|
<SelectItem key={opt.days} value={String(opt.days)}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
{selected.size} av {accounts.length} valda
|
{selected.size} av {accounts.length} valda
|
||||||
@@ -207,7 +367,7 @@ export function AccountPickerDialog({
|
|||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Sparar…
|
{isInitialSelection ? 'Sparar och hämtar transaktioner…' : 'Sparar…'}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Spara val'
|
'Spara val'
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { formatDate } from '@/lib/utils'
|
import { formatDate } from '@/lib/utils'
|
||||||
import { getDaysUntilExpiry, isConsentExpiringSoon } from '../lib/api-client'
|
import { getDaysUntilExpiry, isConsentExpiringSoon } from '../lib/api-client'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
@@ -241,6 +242,37 @@ export function BankConnectionStatus({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Initial historik:{' '}
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{min ? formatDate(min) : '—'} → {max ? formatDate(max) : '—'}
|
||||||
|
</span>
|
||||||
|
{' '}(begärde <span className="tabular-nums">{formatDate(requested)}</span>)
|
||||||
|
</span>
|
||||||
|
{truncated && (
|
||||||
|
<Badge variant="outline">
|
||||||
|
Bankens API returnerade kortare period än begärt — använd SIE-import för äldre data
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Accounts list */}
|
{/* Accounts list */}
|
||||||
{accounts.length > 0 && (
|
{accounts.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -256,7 +256,9 @@ export const enableBankingExtension: Extension = {
|
|||||||
})
|
})
|
||||||
if (!rl.ok) return rl.response!
|
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 days_back = Math.min(Math.max(1, rawDaysBack), 365)
|
||||||
|
|
||||||
const { data: connection, error: connectionError } = await supabase
|
const { data: connection, error: connectionError } = await supabase
|
||||||
@@ -453,6 +455,7 @@ export const enableBankingExtension: Extension = {
|
|||||||
const body = await request.json().catch(() => null)
|
const body = await request.json().catch(() => null)
|
||||||
const connection_id = body?.connection_id
|
const connection_id = body?.connection_id
|
||||||
const enabled_uids = body?.enabled_uids
|
const enabled_uids = body?.enabled_uids
|
||||||
|
const rawLookback = body?.initial_lookback_days
|
||||||
|
|
||||||
if (typeof connection_id !== 'string' || !connection_id) {
|
if (typeof connection_id !== 'string' || !connection_id) {
|
||||||
return NextResponse.json({ error: 'connection_id krävs' }, { status: 400 })
|
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
|
const { data: connection, error: connectionError } = await supabase
|
||||||
.from('bank_connections')
|
.from('bank_connections')
|
||||||
.select('id, status, accounts_data, bank_name')
|
.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<typeof setTimeout> | 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<never>((_, 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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
enabled_count: enabled_uids.length,
|
enabled_count: enabled_uids.length,
|
||||||
total_count: existing.length,
|
total_count: existing.length,
|
||||||
|
...(initialSyncSummary ? { initial_sync: initialSyncSummary } : {}),
|
||||||
|
...(initialSyncError ? { initial_sync_error: initialSyncError } : {}),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -219,4 +219,65 @@ describe('syncAccountTransactions', () => {
|
|||||||
expect(rawTxns[0].external_id).toBe('eb_acc-uid-1_tx-500')
|
expect(rawTxns[0].external_id).toBe('eb_acc-uid-1_tx-500')
|
||||||
expect(rawTxns[0].import_source).toBe('enable_banking')
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export interface SyncResult {
|
|||||||
imported: number
|
imported: number
|
||||||
duplicates: number
|
duplicates: number
|
||||||
errors: 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,
|
imported: ingestResult.imported,
|
||||||
duplicates: ingestResult.duplicates,
|
duplicates: ingestResult.duplicates,
|
||||||
errors: ingestResult.errors,
|
errors: ingestResult.errors,
|
||||||
|
returnedMinBookingDate: minBookingDate,
|
||||||
|
returnedMaxBookingDate: maxBookingDate,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -293,6 +293,17 @@ export interface BankConnection {
|
|||||||
last_synced_at: string | null
|
last_synced_at: string | null
|
||||||
error_message: 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
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user