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
@@ -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