fix(enable-banking): stop renewal history floods (gap-fill default + backfill reconciliation) (#1590)

* chore(ci): guard bedrock-sdk against automated version bumps

The 2026-07 prod outage (empty Bedrock streams breaking invoice OCR and
the assistant) came from an unreviewed @anthropic-ai/bedrock-sdk 0.32.0
bump. The package is exact-pinned to 0.29.1, but nothing stopped an
automated PR from proposing the bump again. Add a dependabot config in
security-updates-only posture (open-pull-requests-limit: 0) with an
ignore for bedrock-sdk >=0.30.0 so neither scheduled nor security
updates can reintroduce it silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(enable-banking): stop renewal history floods (gap-fill default + backfill reconciliation)

Renewing a bank connection walks the same pending_selection -> active
flow as a first connect, and a fresh consent often makes the bank
release history the first connect never delivered. Two gaps turned that
into a flood of falsely 'unhandled' rows over already-bookkept periods
(11 companies, ~600 rows in prod):

- The picker defaulted every renewal to the fiscal-year lookback. It now
  probes the connection's newest imported transaction and defaults a
  renewal to 'continue where the last fetch stopped' (7-day overlap,
  absorbed by external_id dedup), with an .attn warning when a longer
  lookback re-requests already-fetched periods.
- The inline initial backfill ran without the SIE-overlap guard that the
  manual /sync route and the cron both apply. It now suppresses
  auto-categorization on overlap and runs the same unattended-threshold
  reconciliation sweep, scoped per ledger account via
  resolveCashAccountScope instead of the pooled unscoped form
  (#1290/#1298), and surfaces the linked count as auto_matched in the
  sync summary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(enable-banking): renewal guard survives mid-open refetch; harden sweep scope and window

Skeptic pass findings on the renewal-flood guard:

- REFUTED: the settings panel's visibility refetch (near-certain in a
  BankID reconnect) hands the open picker a fresh accounts identity; the
  pre-existing reset effect then wiped the gap-fill state while the
  probe effect never re-ran, silently stranding the renewal back on the
  fiscal-year default with no warning. The probe now keys its state by
  connectionId and shares the reset's triggers via an accounts dep, so
  wipe and re-probe always pair up.
- The sweep skips accounts whose cash_accounts row did not resolve
  (found: false) instead of degrading to the pooled currency-only form
  (#1290 write shape), which could otherwise follow a same-request
  mirror-upsert failure.
- The sweep window opens at the oldest booking date the bank actually
  returned: over-returning ASPSPs ingest rows outside the requested
  window, which the sweep would otherwise never examine.
- resolveGapFillStart clamps to the backend's 365-day lookback floor so
  the radio never promises a start date the backfill cannot honor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(enable-banking): address PR #1590 review findings

- zizmor: add a 7-day cooldown to the dependabot npm entry.
- CodeRabbit: clear the event bus in the accounts-route beforeEach (repo
  test convention); surface probe query failures in AccountPickerDialog
  so a failed probe cannot read as a first connect and silently restore
  the fiscal-year default; build the sweep's ledger-account list with a
  string filter instead of a nullish fallback so the pooled scope path
  is structurally unreachable.
- Swedish compliance review: document at the sweep site that linking
  writes bank-feed metadata only, never journal tables, with the
  opening-balance link trigger and unlinkReconciliation reversibility
  spelled out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-13 16:50:24 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 9ad3908ed0
commit a47ba9fede
8 changed files with 671 additions and 13 deletions
+19
View File
@@ -0,0 +1,19 @@
# Security-updates-only posture: open-pull-requests-limit: 0 disables scheduled
# version-bump PRs while keeping Dependabot security PRs, and `ignore` applies
# to both kinds.
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 0
cooldown:
default-days: 7
ignore:
# @anthropic-ai/bedrock-sdk 0.30.0+ broke invoice OCR and the assistant in
# prod (empty Bedrock streams, 2026-07 outage; see DECISIONS.md). The
# dependency is pinned to 0.29.1 in package.json; a bump must be a
# deliberate, verified change, never an automated PR.
- dependency-name: "@anthropic-ai/bedrock-sdk"
versions: [">=0.30.0"]
+8
View File
@@ -921,5 +921,13 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-11] Self-hosted: the 30 s poll in lib/hooks/use-document-extraction.ts left alone even though #1406 names it. It is a symptom of missing credentials rather than a bug in itself: with AI configured, extraction finishes in 2-8 s and the wait disappears. The real fix is for the extraction-status endpoint to report "not configured" instead of leaving the column NULL forever, which is a separate change.
[2026-08-13] Tier 1 self-hosting packages the `document-extraction` extension in the stock image: a plain `ANTHROPIC_API_KEY` must cover documents uploaded in the app as well as emailed invoices and the assistant to satisfy issue #1406; this does not add provider abstraction or other Tier 2 work.
[2026-08-13] document-extraction's manifest no longer lists AWS_REGION under requiredEnvVars: the extension accepts either AWS static keys or ANTHROPIC_API_KEY, which the manifest schema cannot express as alternatives, and requiredEnvVars only drives a build-time warning rather than gating execution.
[2026-08-13] dependabot.yml added as security-updates-only (open-pull-requests-limit: 0) with an ignore on @anthropic-ai/bedrock-sdk >=0.30.0: enabling scheduled version bumps repo-wide just to pin one package would create review noise, and the exact 0.29.1 pin alone does not stop an automated bump PR from reintroducing the 0.32.0 empty-stream outage class; the ignore covers both paths.
[2026-08-13] Renewal-flood guard reuses runReconciliation in the PATCH /accounts backfill instead of adding an import-time matcher: the manual /sync route and the cron already run the identical SIE-overlap gate plus unattended-threshold sweep, and a third matching implementation would drift. Unlike those two sweeps, the new call resolves a per-ledger-account scope via resolveCashAccountScope (the documented #1290/#1298 call-site fix) because the backfill knows each enabled account; a scope resolution failure skips that account's sweep rather than widening to the pooled unscoped form.
[2026-08-13] Gap-fill renewal default anchors on the connection's newest imported transaction (feed coverage), not ledger coverage: the flood is a feed-level re-request problem, and ledger coverage already has its own suggestion box in the picker. The gap-fill start subtracts 7 days because external_id dedup makes overlap free and banks book some rows late. The async default only flips the radio to gap-fill if the user has not touched the group before the probe lands.
[2026-08-13] Skeptic refutation fix on the renewal guard: the picker's gap-fill probe keys its state by connectionId and lists `accounts` in its deps, so the pre-existing reset effect (re-runs on every accounts identity change, e.g. the panel's visibility refetch after a BankID app switch) can never wipe the renewal default without a matching re-probe. Backfill sweep hardening from the same pass: accounts whose cash_accounts row did not resolve (found: false) are skipped instead of degrading to the pooled currency-only form, and the sweep window opens at the oldest returned booking date when the bank over-returns history. resolveGapFillStart clamps to the backend's 365-day floor so the shown date always matches the actual backfill start.
[2026-08-13] WhatsApp decline observability (#1552) reuses whatsapp_messages with content-free rows for unknown-sender declines instead of a new table or aggregate RPC: no migration (no orphan risk), the wamid unique index gives redelivery dedupe for free (a redelivered bad-code or greeted message no longer earns a second reply), and the existing 30-day unknown-sender retention pass already deletes the rows. Write amplification from an over-quota flood is bounded by a 20-rows-per-hash-per-day trace cap, not by dropping the trail entirely. The settings panel gets a closed event enum derived server-side (lib/last-event.ts), never raw error_message text, so internal errors cannot leak to the client.
[2026-08-13] Issue #546 ships a provider-agnostic Peppol BIS Billing 3 XML export with strict Swedish preflight, not a fake send path: certified access-point delivery, SMP lookup, receipts, inbound handling, credentials, and commercial terms depend on Emil selecting and contracting a multitenant provider, and the existing email delivery state cannot truthfully model those guarantees.
@@ -27,8 +27,23 @@ vi.mock('@/lib/cash-accounts/service', () => ({
iban ? iban.replace(/\s+/g, '').toUpperCase() || null : null,
}))
// Mock the reconciliation modules so the renewal-guard sweep is deterministic
// and observable. The mocks must export every symbol index.ts imports.
const { mockRunReconciliation, mockResolveCashAccountScope } = vi.hoisted(() => ({
mockRunReconciliation: vi.fn(),
mockResolveCashAccountScope: vi.fn(),
}))
vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
runReconciliation: (...args: unknown[]) => mockRunReconciliation(...args),
DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD: 0.9,
}))
vi.mock('@/lib/reconciliation/cash-account-scope', () => ({
resolveCashAccountScope: (...args: unknown[]) => mockResolveCashAccountScope(...args),
}))
import { enableBankingExtension } from '../index'
import { syncAccountTransactions } from '../lib/sync'
import { eventBus } from '@/lib/events/bus'
import type { ExtensionContext } from '@/lib/extensions/types'
import type { StoredAccount } from '../types'
@@ -67,6 +82,10 @@ interface SupabaseStub {
bank_connection_id: string | null
ledger_account: string
}>
/** Completed SIE import overlapping the backfill window (renewal-flood guard). */
sieImportRow?: { id: string } | null
/** company_members row for the caller; role 'viewer' disables the sweep. */
membershipRow?: { role: string } | null
/** Last update payload (may be overwritten by a follow-up metadata update). */
capturedUpdate?: Record<string, unknown>
/** All update payloads in order: first is the status flip, second the initial-sync metadata. */
@@ -103,6 +122,24 @@ function buildSupabase(stub: SupabaseStub) {
eq: vi.fn(() => Promise.resolve({ data: stub.cashAccountRows ?? [], error: null })),
}
}
// Renewal-flood guard: SIE-overlap probe before the inline backfill.
if (table === 'sie_imports') {
return {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
gte: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({ data: stub.sieImportRow ?? null, error: null }),
}
}
// Role probe for the reconciliation sweep (viewers cannot write links).
if (table === 'company_members') {
return {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({ data: stub.membershipRow ?? null, error: null }),
}
}
return {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
@@ -167,7 +204,25 @@ const ALLOCATOR_DEFAULTS: Record<string, string> = {
describe('PATCH /accounts (enable-banking)', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
mockUpsertFromPsd2.mockResolvedValue(undefined)
mockRunReconciliation.mockResolvedValue({
matches: [],
applied: 0,
errors: 0,
skippedBelowThreshold: 0,
})
// Scope stand-in: echo the requested account (or the '1930' default) so
// tests can assert the sweep runs once per ledger account, correctly scoped.
mockResolveCashAccountScope.mockImplementation(
async (_supabase: unknown, _companyId: unknown, accountNumber?: string) => ({
accountNumber: accountNumber ?? '1930',
currency: 'SEK',
cashAccountId: `ca-${accountNumber ?? '1930'}`,
includeUnassigned: accountNumber === undefined,
found: true,
}),
)
// Default: no revoked connections; individual tests override to exercise
// the self-heal path.
mockGetRevokedConnectionIds.mockResolvedValue(new Set<string>())
@@ -520,6 +575,252 @@ describe('PATCH /accounts (enable-banking)', () => {
expect(meta?.last_synced_at).toBeDefined()
})
it('suppresses auto-categorization and reconciles per ledger account when the window overlaps an SIE import (renewal-flood guard)', async () => {
mockedSync.mockResolvedValue({
imported: 22,
duplicates: 0,
errors: 0,
returnedMinBookingDate: '2026-01-05',
returnedMaxBookingDate: '2026-05-06',
})
mockRunReconciliation
.mockResolvedValueOnce({ matches: [{}, {}, {}, {}], applied: 3, errors: 0, skippedBelowThreshold: 1 })
.mockResolvedValueOnce({ matches: [{}, {}], applied: 2, errors: 0, skippedBelowThreshold: 0 })
const stub: SupabaseStub = {
authUser: { id: 'user-1' },
connectionRow: {
id: 'conn-1',
status: 'pending_selection',
accounts_data: [
// ledger_account preset on the stored account: no account_mappings
// in the request, so the chart/collision validation stays out of scope.
{ uid: 'acc-1', currency: 'SEK', enabled: true, ledger_account: '1930' },
{ uid: 'acc-2', currency: 'EUR', enabled: true },
] as StoredAccount[],
},
sieImportRow: { id: 'sie-1' },
}
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()
// Auto-categorization is suppressed: booking these rows through mapping
// rules would double-book the already-imported period.
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', skipAutoCategorization: true }
)
// One scoped sweep per distinct ledger account: pooled unscoped runs can
// cross-link accounts (#1290/#1298). The EUR account had no stored
// ledger_account, but the PATCH allocator assigns one ('1932') before the
// backfill, so both sweeps run with a concrete account.
expect(mockResolveCashAccountScope).toHaveBeenCalledTimes(2)
expect(mockResolveCashAccountScope).toHaveBeenCalledWith(expect.anything(), 'company-1', '1930')
expect(mockResolveCashAccountScope).toHaveBeenCalledWith(expect.anything(), 'company-1', '1932')
expect(mockRunReconciliation).toHaveBeenCalledTimes(2)
expect(mockRunReconciliation).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({
accountNumber: '1930',
currency: 'SEK',
cashAccountId: 'ca-1930',
includeUnassigned: false,
confidenceThreshold: 0.9,
// The bank over-returned history: the sweep window opens at the
// oldest returned booking date, not the requested 90-day fromDate,
// so over-returned rows are swept too.
dateFrom: '2026-01-05',
dateTo: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
})
)
// Applied links surface to the UI so the user sees the period was
// recognized, not re-imported as work.
expect(body.initial_sync.auto_matched).toBe(5)
})
it('runs no reconciliation sweep and keeps categorization on without SIE overlap', async () => {
mockedSync.mockResolvedValue({
imported: 10,
duplicates: 0,
errors: 0,
returnedMinBookingDate: '2026-05-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 }],
},
}
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()
expect(mockedSync).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'conn-1',
expect.anything(),
expect.any(String),
expect.any(String),
undefined,
{ strategy: 'longest' }
)
expect(mockRunReconciliation).not.toHaveBeenCalled()
expect(body.initial_sync.auto_matched).toBe(0)
})
it('gives viewers rawInsertOnly and no sweep even with SIE overlap (viewers cannot write links)', async () => {
mockedSync.mockResolvedValue({
imported: 5,
duplicates: 0,
errors: 0,
returnedMinBookingDate: '2026-05-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 }],
},
sieImportRow: { id: 'sie-1' },
membershipRow: { role: 'viewer' },
}
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)
expect(mockedSync).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'conn-1',
expect.anything(),
expect.any(String),
expect.any(String),
undefined,
{ strategy: 'longest', skipAutoCategorization: true, rawInsertOnly: true }
)
expect(mockRunReconciliation).not.toHaveBeenCalled()
})
it('skips the sweep for an account whose cash_accounts row did not resolve instead of widening to the pooled form', async () => {
mockedSync.mockResolvedValue({
imported: 6,
duplicates: 0,
errors: 0,
returnedMinBookingDate: '2026-05-01',
returnedMaxBookingDate: '2026-05-13',
})
// found: false = no cash_accounts row (e.g. the mirror upsert failed
// earlier in the request). Running anyway would sweep currency-only
// across every same-currency account (#1290 write shape).
mockResolveCashAccountScope.mockResolvedValue({
accountNumber: '1930',
currency: 'SEK',
cashAccountId: undefined,
includeUnassigned: true,
found: false,
})
const stub: SupabaseStub = {
authUser: { id: 'user-1' },
connectionRow: {
id: 'conn-1',
status: 'pending_selection',
accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true, ledger_account: '1930' }] as StoredAccount[],
},
sieImportRow: { id: 'sie-1' },
}
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()
expect(mockResolveCashAccountScope).toHaveBeenCalled()
expect(mockRunReconciliation).not.toHaveBeenCalled()
expect(body.initial_sync.auto_matched).toBe(0)
})
it('keeps the backfill successful when the reconciliation sweep throws (non-critical)', async () => {
mockedSync.mockResolvedValue({
imported: 8,
duplicates: 0,
errors: 0,
returnedMinBookingDate: '2026-05-01',
returnedMaxBookingDate: '2026-05-13',
})
mockRunReconciliation.mockRejectedValue(new Error('recon exploded'))
const stub: SupabaseStub = {
authUser: { id: 'user-1' },
connectionRow: {
id: 'conn-1',
status: 'pending_selection',
accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true }],
},
sieImportRow: { id: 'sie-1' },
}
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()
expect(body.initial_sync).toMatchObject({ imported: 8, auto_matched: 0 })
expect(body.initial_sync_error).toBeUndefined()
})
it('does NOT run inline sync when connection is already active (selection edit)', async () => {
mockedSync.mockResolvedValue({
imported: 99,
@@ -1,6 +1,6 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import Link from 'next/link'
import {
Dialog,
@@ -31,6 +31,7 @@ import {
import {
resolveBookedCoverage,
resolveFiscalYearStart,
resolveGapFillStart,
} from '../lib/date-suggestions'
import type { CompanySettings } from '@/types'
import type { StoredAccount } from '../types'
@@ -57,7 +58,7 @@ interface ChartAccount {
account_name: string
}
type LookbackMode = 'fast' | 'fiscal-year' | 'custom'
type LookbackMode = 'gap-fill' | 'fast' | 'fiscal-year' | 'custom'
type CustomSubMode = 'date' | 'previous-fiscal-year'
// Suggested BAS account per currency. The mapping engine falls back to 1930
@@ -104,6 +105,15 @@ export function AccountPickerDialog({
const [lookbackMode, setLookbackMode] = useState<LookbackMode>('fiscal-year')
const [customSubMode, setCustomSubMode] = useState<CustomSubMode>('date')
const [customDate, setCustomDate] = useState<string>('')
// Newest transaction date this CONNECTION has already imported (date null on
// a first connect). A date means this is a renewal, where the default must be
// "fill the gap", not a fresh long lookback over bookkept periods. Keyed by
// connectionId so a stale value can never render into another connection's
// dialog between open and probe: the gapFill memo ignores mismatched keys.
const [latestImported, setLatestImported] = useState<{ connectionId: string; date: string | null } | null>(null)
// Ref, not state: only the async default below reads it, and putting it in
// the effect's deps would re-fire the query on the first radio click.
const lookbackTouched = useRef(false)
const [progressOpen, setProgressOpen] = useState(false)
const [progressState, setProgressState] = useState<SyncProgressState>({ kind: 'syncing' })
@@ -126,6 +136,7 @@ export function AccountPickerDialog({
setLookbackMode('fiscal-year')
setCustomSubMode('date')
setCustomDate('')
lookbackTouched.current = false
// Pre-populate ledger picks from existing StoredAccount values, falling
// back to currency-based suggestions for accounts the user hasn't mapped
@@ -213,6 +224,45 @@ export function AccountPickerDialog({
return () => { cancelled = true }
}, [open, isInitialSelection, company?.id, supabase])
// Fetch the newest transaction this connection has already imported. Any row
// means this pending_selection is a RENEWAL: the flow re-runs the initial
// backfill, and a fresh consent often makes the bank release history the
// first connect never delivered. Defaulting to the fiscal-year lookback then
// re-imports whole bookkept periods as "ohanterade" (the 2026-08 renewal
// flood), so a renewal defaults to gap-fill instead, unless the user has
// already picked a mode by the time the query lands.
useEffect(() => {
// `accounts` is deliberately a dep even though only its length is read: the
// reset effect above re-runs on every accounts identity change (the panel's
// visibility refetch produces a fresh array mid-open, e.g. returning from a
// BankID app switch) and resets the lookback default. Re-probing on the
// same trigger re-establishes the gap-fill default; without it the reset
// would silently strand a renewal back on the fiscal-year default.
if (!open || !isInitialSelection || !company?.id || accounts.length === 0) return
let cancelled = false
;(async () => {
const { data, error } = await supabase
.from('transactions')
.select('date')
.eq('company_id', company.id)
.eq('bank_connection_id', connectionId)
.order('date', { ascending: false })
.limit(1)
.maybeSingle()
if (cancelled) return
if (error) {
// A failed probe must not read as "first connect": deriving the
// fiscal-year default from an unknown state is the flood case itself.
console.warn('[enable-banking] latest-import probe failed', error.message)
return
}
const date = (data as { date?: string } | null)?.date || null
setLatestImported({ connectionId, date })
if (date && !lookbackTouched.current) setLookbackMode('gap-fill')
})()
return () => { cancelled = true }
}, [open, isInitialSelection, company?.id, connectionId, supabase, accounts])
// Load 19xx accounts from the chart for the per-account ledger combobox.
// Class 19 = bank/cash on the BAS chart.
useEffect(() => {
@@ -301,13 +351,13 @@ export function AccountPickerDialog({
return
}
// Block save when the user picked "Anpassat datum" but left the date blank.
// Without this guard, lookback.body is null and the PATCH would silently
// fall back to the backend's 120-day default, not what the user asked for.
// Block save when the chosen mode resolved to no request body: a blank
// custom date, or gap-fill whose suggestion vanished. Without this guard,
// lookback.body is null and the PATCH would silently fall back to the
// backend's 120-day default, not what the user asked for.
if (
isInitialSelection &&
lookbackMode === 'custom' &&
customSubMode === 'date' &&
((lookbackMode === 'custom' && customSubMode === 'date') || lookbackMode === 'gap-fill') &&
!lookback.body
) {
toast({
@@ -418,6 +468,13 @@ export function AccountPickerDialog({
[lastBookedDate],
)
const gapFill = useMemo(
() => (latestImported?.connectionId === connectionId
? resolveGapFillStart(latestImported.date)
: null),
[latestImported, connectionId],
)
const fiscalYearStart = useMemo(
() => resolveFiscalYearStart(currentPeriodStart, companySettings),
[currentPeriodStart, companySettings],
@@ -430,6 +487,19 @@ export function AccountPickerDialog({
// Resolve mode → concrete request payload and a "resolved from-date" for display.
const lookback = useMemo(() => {
if (lookbackMode === 'gap-fill') {
// The gap-fill radio only renders when gapFill resolved, but guard the
// body anyway: a null body falls into the same save-block as a blank
// custom date instead of silently syncing the server's 120-day default.
if (gapFill) {
return {
body: { initial_lookback_from_date: gapFill.suggestedStartDate },
fromDate: gapFill.suggestedStartDate,
days: daysBetween(gapFill.suggestedStartDate),
}
}
return { body: null as Record<string, string | number> | null, fromDate: null as string | null, days: 0 }
}
if (lookbackMode === 'fast') {
return { body: { initial_lookback_days: 90 }, fromDate: null as string | null, days: 90 }
}
@@ -442,10 +512,21 @@ export function AccountPickerDialog({
return { body: { initial_lookback_from_date: date }, fromDate: date, days: daysBetween(date) }
}
return { body: null as Record<string, string | number> | null, fromDate: null as string | null, days: 0 }
}, [lookbackMode, customSubMode, customDate, fiscalYearStart, previousFiscalYearStart])
}, [lookbackMode, customSubMode, customDate, fiscalYearStart, previousFiscalYearStart, gapFill])
const showLongRangeHelper = lookback.days > 90
// On a renewal, a lookback reaching past what the connection already
// delivered re-imports periods that may already be bookkept: with a fresh
// consent the bank often releases history the first connect never returned.
const reimportsFetchedPeriod = Boolean(
gapFill &&
lookbackMode !== 'gap-fill' &&
(lookback.fromDate
? lookback.fromDate < gapFill.latestImportedDate
: lookback.days > daysBetween(gapFill.latestImportedDate)),
)
return (
<>
<BankSyncProgressDialog
@@ -526,6 +607,7 @@ export function AccountPickerDialog({
type="button"
className="shrink-0 text-xs text-foreground underline underline-offset-2"
onClick={() => {
lookbackTouched.current = true
setLookbackMode('custom')
setCustomSubMode('date')
setCustomDate(bookedCoverage.suggestedStartDate)
@@ -538,13 +620,33 @@ export function AccountPickerDialog({
)}
<div className="space-y-2">
{gapFill && (
<label className="flex cursor-pointer items-start gap-2">
<input
type="radio"
name="lookback-mode"
value="gap-fill"
checked={lookbackMode === 'gap-fill'}
onChange={() => { lookbackTouched.current = true; setLookbackMode('gap-fill') }}
disabled={isSaving}
className="mt-1"
/>
<span>
<span className="block">Fortsätt där förra hämtningen slutade <span className="text-muted-foreground">(rekommenderas)</span></span>
<span className="text-xs text-muted-foreground tabular-nums">
från {gapFill.suggestedStartDate}; redan hämtade transaktioner hoppas över automatiskt
</span>
</span>
</label>
)}
<label className="flex cursor-pointer items-start gap-2">
<input
type="radio"
name="lookback-mode"
value="fast"
checked={lookbackMode === 'fast'}
onChange={() => setLookbackMode('fast')}
onChange={() => { lookbackTouched.current = true; setLookbackMode('fast') }}
disabled={isSaving}
className="mt-1"
/>
@@ -559,7 +661,7 @@ export function AccountPickerDialog({
name="lookback-mode"
value="fiscal-year"
checked={lookbackMode === 'fiscal-year'}
onChange={() => setLookbackMode('fiscal-year')}
onChange={() => { lookbackTouched.current = true; setLookbackMode('fiscal-year') }}
disabled={isSaving}
className="mt-1"
/>
@@ -577,7 +679,7 @@ export function AccountPickerDialog({
name="lookback-mode"
value="custom"
checked={lookbackMode === 'custom'}
onChange={() => setLookbackMode('custom')}
onChange={() => { lookbackTouched.current = true; setLookbackMode('custom') }}
disabled={isSaving}
className="mt-1"
/>
@@ -617,6 +719,14 @@ export function AccountPickerDialog({
</label>
</div>
{reimportsFetchedPeriod && gapFill && (
<p className="attn text-[12.5px]">
Du har redan hämtat transaktioner till och med {gapFill.latestImportedDate}. Ett
tidigare startdatum kan hämta mer historik från banken, och dagar som redan är
bokförda kan då dyka upp som ohanterade.
</p>
)}
{showLongRangeHelper && (
<p className="text-xs text-muted-foreground">
Din bank returnerar oftast max 90 dagar. Behöver du äldre transaktioner kan du{' '}
@@ -18,6 +18,12 @@ import type { StoredAccount } from '../types'
export interface SyncProgressSummary {
imported: number
duplicates: number
/**
* Rows the post-backfill reconciliation sweep linked to existing verifikat
* (renewal over an already-bookkept period). Optional: responses from before
* the sweep existed omit it.
*/
auto_matched?: number
requested_from: string
returned_min_date: string | null
returned_max_date: string | null
@@ -252,6 +258,11 @@ function DoneBody({
Datum: {summary.returned_min_date} → {summary.returned_max_date}
</p>
)}
{(summary.auto_matched ?? 0) > 0 && (
<p className="text-xs text-muted-foreground tabular-nums">
{summary.auto_matched} kopplades automatiskt till redan bokförda verifikat.
</p>
)}
{workCounts && workCounts.book > 0 && (
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
{workCounts.book} att bokföra
+105 -1
View File
@@ -17,6 +17,7 @@ import {
runReconciliation,
DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
} from '@/lib/reconciliation/bank-reconciliation'
import { resolveCashAccountScope } from '@/lib/reconciliation/cash-account-scope'
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
@@ -1336,6 +1337,7 @@ export const enableBankingExtension: Extension = {
let initialSyncSummary: {
imported: number
duplicates: number
auto_matched: number
requested_from: string
returned_min_date: string | null
returned_max_date: string | null
@@ -1349,12 +1351,37 @@ export const enableBankingExtension: Extension = {
.toISOString()
.split('T')[0]
// Same guard the manual /sync route and the cron apply. This path also
// runs on RENEWAL (reconnect resets status to pending_selection), and a
// fresh consent often makes the bank release history the first connect
// never delivered, straight over an already-bookkept period. Without
// the guard those rows would be auto-categorized into brand-new
// verifikat (double-booking) instead of being linked to the ones that
// already describe them.
const { data: sieOverlap } = await supabase
.from('sie_imports')
.select('id')
.eq('company_id', companyId)
.eq('status', 'completed')
.gte('fiscal_year_end', fromDate)
.limit(1)
.maybeSingle()
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
const isViewer = membership?.role === 'viewer'
log.info('[enable-banking] Starting inline initial backfill', {
connectionId: connection.id,
accountCount: accountsToSync.length,
lookbackDays: initialLookbackDays,
fromDate,
toDate,
sieOverlap: Boolean(sieOverlap),
})
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
@@ -1370,7 +1397,11 @@ export const enableBankingExtension: Extension = {
fromDate,
toDate,
ingestFn,
{ strategy: 'longest' }
{
strategy: 'longest',
...(sieOverlap ? { skipAutoCategorization: true } : {}),
...(isViewer ? { rawInsertOnly: true } : {}),
}
))
)
// If the timeout wins the race, the underlying Promise.all keeps
@@ -1397,6 +1428,78 @@ export const enableBankingExtension: Extension = {
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
// Post-backfill reconciliation sweep, mirroring the manual /sync
// route: link just-imported rows to the verifikat that already
// describe them so a re-released period does not resurface as
// hundreds of "ohanterade" transactions. Unlike the /sync and cron
// sweeps this runs once per enabled ledger account with a resolved
// cash-account scope: the pooled unscoped form can cross-link
// accounts (#1290/#1298). Both a thrown scope resolution AND an
// unresolved cash-account row (found: false) skip that account's
// sweep instead of widening to the pooled form.
//
// The window opens at the OLDEST booking date the bank actually
// returned when that is older than the requested fromDate: some
// ASPSPs over-return history, and rows outside the requested window
// would otherwise be ingested but never swept.
const sweepDateFrom = returnedMin && returnedMin < fromDate ? returnedMin : fromDate
// The sweep writes bank-feed metadata only (transactions.journal_entry_id,
// reconciliation_method, is_business): journal tables are never touched,
// so BFL immutability and period locks (which guard journal entries) are
// not in play. Links to opening-balance verifikat are blocked by the
// check_transaction_link_not_opening_balance trigger, and every link is
// reversible via unlinkReconciliation without any ledger write.
let totalAutoMatched = 0
if (sieOverlap && totalImported > 0 && !isViewer) {
// Filter, not `?? undefined`: an undefined accountNumber makes
// resolveCashAccountScope fall back to the primary account with
// includeUnassigned=true, which is the pooled form this block must
// never widen to. The allocator gives every enabled account a
// concrete ledger_account, so nothing is skipped in practice.
const ledgerAccounts = Array.from(
new Set(
accountsToSync
.map(a => a.ledger_account)
.filter((l): l is string => typeof l === 'string' && l.length > 0)
)
)
for (const ledgerAccount of ledgerAccounts) {
try {
const scope = await resolveCashAccountScope(supabase, companyId, ledgerAccount)
if (!scope.found) {
log.warn('[enable-banking] No cash_accounts row for ledger account; skipping its reconciliation sweep', {
connectionId: connection.id,
ledgerAccount,
})
continue
}
const reconResult = await runReconciliation(supabase, companyId, user.id, {
dateFrom: sweepDateFrom,
dateTo: toDate,
accountNumber: scope.accountNumber,
currency: scope.currency,
cashAccountId: scope.cashAccountId,
includeUnassigned: scope.includeUnassigned,
// Unattended run: nobody reviews a dry-run first, so never
// commit low-confidence (fuzzy / date-range) matches.
confidenceThreshold: DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD,
})
totalAutoMatched += reconResult.applied
if (reconResult.applied > 0 || reconResult.skippedBelowThreshold > 0) {
log.info('[enable-banking] Post-backfill reconciliation linked imported rows to existing verifikat', {
connectionId: connection.id,
accountNumber: scope.accountNumber,
applied: reconResult.applied,
skippedBelowThreshold: reconResult.skippedBelowThreshold,
total: reconResult.matches.length,
})
}
} catch {
// Non-critical: rows stay unmatched for manual review.
}
}
}
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
@@ -1432,6 +1535,7 @@ export const enableBankingExtension: Extension = {
initialSyncSummary = {
imported: totalImported,
duplicates: totalDuplicates,
auto_matched: totalAutoMatched,
requested_from: fromDate,
returned_min_date: returnedMin,
returned_max_date: returnedMax,
@@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest'
import { resolveBookedCoverage, resolveFiscalYearStart } from '../date-suggestions'
import {
resolveBookedCoverage,
resolveFiscalYearStart,
resolveGapFillStart,
} from '../date-suggestions'
describe('resolveBookedCoverage', () => {
it('suggests the day after the last posted verifikat date', () => {
@@ -43,6 +47,50 @@ describe('resolveBookedCoverage', () => {
})
})
describe('resolveGapFillStart', () => {
it('suggests one week of overlap before the newest imported transaction', () => {
const today = new Date('2026-08-13T12:00:00Z')
expect(resolveGapFillStart('2026-08-06', today)).toEqual({
latestImportedDate: '2026-08-06',
suggestedStartDate: '2026-07-30',
})
})
it('rolls over month and year boundaries', () => {
// Todays within the 365-day floor of each case so only the 7-day overlap acts.
expect(resolveGapFillStart('2026-01-03', new Date('2026-01-15T12:00:00Z'))?.suggestedStartDate).toBe('2025-12-27')
expect(resolveGapFillStart('2026-03-04', new Date('2026-03-10T12:00:00Z'))?.suggestedStartDate).toBe('2026-02-25')
})
it('clamps to today when bank data claims a future date (backend rejects non-past dates)', () => {
const today = new Date('2026-08-13T12:00:00Z')
expect(resolveGapFillStart('2026-09-20', today)).toEqual({
latestImportedDate: '2026-09-20',
suggestedStartDate: '2026-08-13',
})
})
it('clamps a stale renewal to the backend 365-day lookback floor so the shown date matches the actual backfill', () => {
const today = new Date('2026-08-13T12:00:00Z')
// Newest import 2025-01-10; minus 7d = 2025-01-03, older than the 365-day
// floor (2025-08-13), which the backend would silently clamp to anyway.
expect(resolveGapFillStart('2025-01-10', today)).toEqual({
latestImportedDate: '2025-01-10',
suggestedStartDate: '2025-08-13',
})
})
it('returns null when the connection has never imported anything (first connect has no gap)', () => {
expect(resolveGapFillStart(null)).toBeNull()
expect(resolveGapFillStart(undefined)).toBeNull()
expect(resolveGapFillStart('')).toBeNull()
})
it('returns null for an unparsable date', () => {
expect(resolveGapFillStart('not-a-date')).toBeNull()
})
})
describe('resolveFiscalYearStart', () => {
const calendarYearSettings = {
fiscal_year_start_month: 1,
@@ -43,6 +43,63 @@ export function resolveBookedCoverage(
}
}
export interface GapFillSuggestion {
/** Date of the newest transaction this connection has already imported. */
latestImportedDate: string
/**
* Suggested sync start: latestImportedDate minus GAP_FILL_OVERLAP_DAYS,
* clamped to today (UTC) so the backend accepts it.
*/
suggestedStartDate: string
}
/**
* Overlap requested before the newest already-imported row. The external_id
* dedup makes re-imported rows no-ops, so the overlap costs nothing, and it
* catches transactions the bank booked late around the boundary.
*/
export const GAP_FILL_OVERLAP_DAYS = 7
/**
* Turn the newest transaction a connection has already imported into a
* "continue where the last fetch stopped" suggestion for RENEWALS.
*
* A reconnect walks the same pending_selection → active flow as a first
* connect, and a fresh consent often makes the bank release history the first
* connect never delivered. Re-requesting a long lookback then floods the inbox
* with rows over already-bookkept periods (the 2026-08 renewal flood), so a
* renewal should default to fetching only the gap since the last import.
* Returns null when the connection has never imported anything: a first
* connect has no gap to fill.
*/
export function resolveGapFillStart(
latestImportedDate: string | null | undefined,
today: Date = new Date(),
): GapFillSuggestion | null {
if (!latestImportedDate) return null
const d = new Date(latestImportedDate + 'T00:00:00Z')
if (!Number.isFinite(d.getTime())) return null
d.setUTCDate(d.getUTCDate() - GAP_FILL_OVERLAP_DAYS)
let start = d.toISOString().split('T')[0]
// The backend clamps every lookback to 365 days. A renewal staler than that
// must show the date the backfill will actually start from, not promise a
// gap it cannot fill (the >90-day helper already points at SIE/file import
// for older history).
const floor = new Date(today.getTime())
floor.setUTCDate(floor.getUTCDate() - 365)
const floorUtc = floor.toISOString().split('T')[0]
if (start < floorUtc) start = floorUtc
// The backend PATCH handler rejects initial_lookback_from_date unless it is
// strictly in the past; today (UTC) is the newest value it accepts. A
// latestImportedDate in the future can only come from bad bank data: clamp
// rather than propagate it.
const todayUtc = today.toISOString().split('T')[0]
return {
latestImportedDate,
suggestedStartDate: start <= todayUtc ? start : todayUtc,
}
}
/**
* Resolve the start of the current fiscal year, preferring the actual
* fiscal_periods row that contains today over the recurring