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:
co-authored by
Claude Fable 5
parent
9ad3908ed0
commit
a47ba9fede
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user