* 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>
290 lines
10 KiB
TypeScript
290 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import Link from 'next/link'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Loader2, CheckCircle2, AlertTriangle } from 'lucide-react'
|
|
import { daysBetween } from '@/lib/company/fiscal-year'
|
|
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
|
|
}
|
|
|
|
export interface SyncProgressError {
|
|
message: string
|
|
}
|
|
|
|
export type SyncProgressState =
|
|
| { kind: 'syncing' }
|
|
| { kind: 'done'; summary: SyncProgressSummary }
|
|
| { kind: 'failed'; error: SyncProgressError }
|
|
|
|
interface BankSyncProgressDialogProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
bankName: string
|
|
accounts: StoredAccount[]
|
|
state: SyncProgressState
|
|
}
|
|
|
|
// Past this point the sync has run longer than the promised "up to a minute".
|
|
// We stop hard-locking the modal so the user isn't trapped: the request keeps
|
|
// running server-side (idempotent) and completion still resolves the state.
|
|
const GRACE_SEC = 75
|
|
|
|
function formatElapsed(sec: number): string {
|
|
if (sec < 60) return `${sec}s`
|
|
return `${Math.floor(sec / 60)}m ${String(sec % 60).padStart(2, '0')}s`
|
|
}
|
|
|
|
export function BankSyncProgressDialog({
|
|
open,
|
|
onOpenChange,
|
|
bankName,
|
|
accounts,
|
|
state,
|
|
}: BankSyncProgressDialogProps) {
|
|
const enabledAccounts = accounts.filter((a) => a.enabled !== false)
|
|
|
|
// Tick a visible elapsed counter while syncing so a slow bank doesn't look
|
|
// frozen, and so we know when to release the close-lock (GRACE_SEC). State is
|
|
// only ever set from the timer callbacks (never synchronously in the effect
|
|
// body), and elapsed is never computed from Date.now() during render, so this
|
|
// stays clear of the react-hooks purity rules.
|
|
const [elapsedSec, setElapsedSec] = useState(0)
|
|
useEffect(() => {
|
|
if (!open || state.kind !== 'syncing') return
|
|
const started = Date.now()
|
|
const tick = () => setElapsedSec(Math.max(0, Math.floor((Date.now() - started) / 1000)))
|
|
// Reset to ~0 on the next tick (async, so not a synchronous effect setState).
|
|
const reset = setTimeout(tick, 0)
|
|
const id = setInterval(tick, 1000)
|
|
return () => {
|
|
clearTimeout(reset)
|
|
clearInterval(id)
|
|
}
|
|
}, [open, state.kind])
|
|
|
|
const overGrace = state.kind === 'syncing' && elapsedSec >= GRACE_SEC
|
|
// Only hard-block the close affordances during the expected window; after the
|
|
// grace period the user may background the (still-running) sync.
|
|
const blockClose = state.kind === 'syncing' && !overGrace
|
|
|
|
// The payoff: once the first sync lands, name the work now waiting so the
|
|
// moment points onward instead of stranding the user on the settings panel.
|
|
// Same authenticated endpoint the dashboard pane refetches; best-effort.
|
|
const [workCounts, setWorkCounts] = useState<{ book: number; matches: number } | null>(null)
|
|
useEffect(() => {
|
|
if (!open || state.kind !== 'done' || state.summary.imported === 0) {
|
|
// A later sync must not inherit the previous run's numbers.
|
|
setWorkCounts(null)
|
|
return
|
|
}
|
|
let cancelled = false
|
|
fetch('/api/worklist/counts')
|
|
.then((res) => (res.ok ? res.json() : null))
|
|
.then((json: { data?: { counts?: Record<string, number> } } | null) => {
|
|
if (cancelled || !json?.data?.counts) return
|
|
setWorkCounts({
|
|
book: json.data.counts.book_transaction ?? 0,
|
|
matches: json.data.counts.suggested_match ?? 0,
|
|
})
|
|
})
|
|
.catch(() => {
|
|
// The CTA degrades to a plain link; the numbers are a nicety.
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [open, state])
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(next) => {
|
|
if (!next && blockClose) return
|
|
onOpenChange(next)
|
|
}}
|
|
>
|
|
<DialogContent
|
|
className="max-w-md"
|
|
onPointerDownOutside={(e) => {
|
|
if (blockClose) e.preventDefault()
|
|
}}
|
|
onEscapeKeyDown={(e) => {
|
|
if (blockClose) e.preventDefault()
|
|
}}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{state.kind === 'syncing' && `Hämtar transaktioner från ${bankName}`}
|
|
{state.kind === 'done' && 'Klart'}
|
|
{state.kind === 'failed' && 'Synkningen misslyckades'}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{state.kind === 'syncing' && (
|
|
overGrace ? (
|
|
<>
|
|
Det tar längre tid än vanligt. Vi fortsätter i bakgrunden: du kan
|
|
stänga rutan och komma tillbaka senare.
|
|
</>
|
|
) : (
|
|
<>
|
|
Vi hämtar transaktioner från {enabledAccounts.length}{' '}
|
|
{enabledAccounts.length === 1 ? 'konto' : 'konton'}. Detta kan ta upp till en minut. Stäng inte fönstret.
|
|
</>
|
|
)
|
|
)}
|
|
{state.kind === 'done' && (
|
|
<>
|
|
Vi hämtade {state.summary.imported}{' '}
|
|
{state.summary.imported === 1 ? 'transaktion' : 'transaktioner'}.
|
|
</>
|
|
)}
|
|
{state.kind === 'failed' && (
|
|
<>Vi försöker igen automatiskt i bakgrunden. Du kan stänga den här rutan.</>
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{state.kind === 'syncing' && (
|
|
<div className="space-y-3 py-2">
|
|
<div className="flex flex-col items-center justify-center gap-2 py-6">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
<span className="text-xs text-muted-foreground tabular-nums" aria-live="polite">
|
|
{formatElapsed(elapsedSec)}
|
|
</span>
|
|
</div>
|
|
<ul className="rounded-lg border border-border divide-y divide-border text-sm">
|
|
{enabledAccounts.map((a) => (
|
|
<li key={a.uid} className="flex items-center justify-between gap-3 px-3 py-2">
|
|
<span className="truncate">{a.name || a.iban || a.uid}</span>
|
|
<span className="text-xs text-muted-foreground">{a.currency}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{state.kind === 'done' && (
|
|
<DoneBody summary={state.summary} workCounts={workCounts} />
|
|
)}
|
|
|
|
{state.kind === 'failed' && (
|
|
<div className="rounded-lg border border-border bg-muted/30 p-3 text-sm text-muted-foreground">
|
|
{state.error.message}
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
{state.kind === 'done' && state.summary.imported > 0 ? (
|
|
<>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => onOpenChange(false)}
|
|
>
|
|
Stäng
|
|
</Button>
|
|
<Button asChild>
|
|
<Link href="/transactions">
|
|
{workCounts && workCounts.book > 0
|
|
? `Visa ${workCounts.book} att bokföra`
|
|
: 'Öppna transaktionerna'}
|
|
</Link>
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={blockClose}
|
|
>
|
|
{state.kind === 'syncing'
|
|
? (overGrace ? 'Fortsätt i bakgrunden' : 'Hämtar…')
|
|
: 'Klar'}
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
function DoneBody({
|
|
summary,
|
|
workCounts,
|
|
}: {
|
|
summary: SyncProgressSummary
|
|
workCounts: { book: number; matches: number } | null
|
|
}) {
|
|
const requestedDays = daysBetween(summary.requested_from)
|
|
const returnedDays =
|
|
summary.returned_min_date && summary.returned_max_date
|
|
? daysBetween(summary.returned_min_date, new Date(summary.returned_max_date))
|
|
: 0
|
|
const wasTruncated = requestedDays - returnedDays > 7
|
|
|
|
return (
|
|
<div className="space-y-3 py-2">
|
|
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/30 p-4">
|
|
<CheckCircle2 className="h-6 w-6 shrink-0 text-foreground" />
|
|
<div className="text-sm">
|
|
<p>
|
|
<span className="tabular-nums font-medium">{summary.imported}</span> nya transaktioner
|
|
importerade.
|
|
</p>
|
|
{summary.returned_min_date && summary.returned_max_date && (
|
|
<p className="text-xs text-muted-foreground">
|
|
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
|
|
{workCounts.matches > 0 && <> · {workCounts.matches} matchar fakturor</>}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{wasTruncated && (
|
|
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
|
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
|
<span>
|
|
Banken returnerade kortare historik än begärt. För äldre data, använd{' '}
|
|
<Link href="/import?mode=sie" className="text-foreground underline underline-offset-2">
|
|
SIE- eller bankfil-import
|
|
</Link>
|
|
.
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|