fix(enable-banking): release ledger claims on disconnect so reconnect lands on the original account (#916) (#955)
Disconnecting a bank left its cash_accounts rows pointing at the revoked connection, so the BAS slot (e.g. 1930) looked taken forever: reconnecting the same bank was shunted to 1939 and the picker save was rejected with a 400 the user never saw. Four coordinated fixes: - DELETE /disconnect now demotes the connection's cash_accounts rows to manual (bank_connection_id = null) after marking the connection revoked. Rows are never deleted: transactions.cash_account_id and ledger history reference them, and upsertFromPsd2 promotes manual holders in place on reconnect. - findFreeLedgerAccount and the PATCH /accounts collision guard no longer count claims held by revoked connections (new getRevokedConnectionIds helper). This is the self-heal path for rows orphaned before this fix: no manual data repair needed. - upsertFromPsd2 promotes a holder row owned by a revoked connection in place (same as the manual seed row), keeping the row id stable so the ledger's transaction history stays attached. A duplicate row for the same connection+uid on an overflow slot (mirrored there by the callback while the slot was wrongly blocked) is merged: deleted when it has no linked transactions, demoted to manual otherwise. Either way a primary duplicate hands the flag to the promoted row, so the __PRIMARY_SEK__ sentinel never resolves to a deleted or stale manual row. The linked-transactions probe is company-scoped (defense in depth on the service-role client). - AccountPickerDialog surfaces rejected saves inline in the picker with the picks intact instead of routing them into the sync-progress modal. It also stops signaling the parent to close before the request resolves: the parent unmounts the whole component on close, which tore down the progress modal mid-flight and made every save outcome (including the 400) invisible. Tests: allocator revoked-exclusion + promote/merge unit tests in lib/cash-accounts, PATCH self-heal case in accounts-route.test.ts, and a new disconnect-route.test.ts covering claim release and its failure mode. Fixes #916 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
15e5dc1a01
commit
2c5e1ce317
@@ -86,6 +86,10 @@ export function AccountPickerDialog({
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
// Server-side save rejection (validation / ledger conflict). Shown inline in
|
||||
// the dialog: a rejected save persisted nothing and started no sync, so the
|
||||
// user must see why and be able to correct the picks.
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [sieLastDate, setSieLastDate] = useState<string | null>(null)
|
||||
const [chartAccounts, setChartAccounts] = useState<ChartAccount[]>([])
|
||||
const [chartError, setChartError] = useState(false)
|
||||
@@ -114,6 +118,7 @@ export function AccountPickerDialog({
|
||||
accounts.filter(a => a.enabled !== false).map(a => a.uid)
|
||||
)
|
||||
setSelected(initial)
|
||||
setSaveError(null)
|
||||
setLookbackMode('fiscal-year')
|
||||
setCustomSubMode('date')
|
||||
setCustomDate('')
|
||||
@@ -285,6 +290,7 @@ export function AccountPickerDialog({
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
setSaveError(null)
|
||||
|
||||
// Cap the client wait at the route's 300s budget so a hung backfill can't
|
||||
// leave the progress modal in 'syncing' forever. The save+backfill is one
|
||||
@@ -295,12 +301,15 @@ export function AccountPickerDialog({
|
||||
|
||||
// For the initial-selection path, open the progress modal up-front so the
|
||||
// user has visible feedback during the 30-60s backfill. Selection edits
|
||||
// (no backfill) keep the existing toast-only feedback.
|
||||
// (no backfill) keep the existing toast-only feedback. Do NOT signal the
|
||||
// parent to close here (issue #916): the parent unmounts this component on
|
||||
// close, which would tear down the progress modal too and swallow every
|
||||
// outcome, including a rejected save. The picker Dialog hides itself while
|
||||
// progressOpen is true and comes back if the save is rejected.
|
||||
if (isInitialSelection) {
|
||||
setSyncAttempt((n) => n + 1)
|
||||
setProgressState({ kind: 'syncing' })
|
||||
setProgressOpen(true)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -326,7 +335,17 @@ export function AccountPickerDialog({
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Kunde inte spara kontoval')
|
||||
// Rejected save (400 conflicting_accounts / duplicate_accounts / other
|
||||
// validation): nothing was persisted and no sync started. Surface the
|
||||
// server's message inside the still-open picker; the progress modal's
|
||||
// failed state would wrongly claim "we retry in the background".
|
||||
setSaveError(
|
||||
typeof data?.error === 'string' && data.error
|
||||
? data.error
|
||||
: 'Kunde inte spara kontoval'
|
||||
)
|
||||
if (isInitialSelection) setProgressOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (isInitialSelection && data.initial_sync) {
|
||||
@@ -407,16 +426,27 @@ export function AccountPickerDialog({
|
||||
open={progressOpen}
|
||||
onOpenChange={(next) => {
|
||||
setProgressOpen(next)
|
||||
// When the user closes the summary, propagate the saved/refresh
|
||||
// signal to the parent (it would have been emitted on success earlier;
|
||||
// this just guards the failure case where we still want a refresh).
|
||||
if (!next) onSaved()
|
||||
// When the user dismisses the progress modal the initial-selection
|
||||
// flow is over: propagate the saved/refresh signal and close the
|
||||
// picker. The parent unmounts this whole component on close, which is
|
||||
// exactly why the picker must stay open until this point: closing it
|
||||
// earlier would unmount the progress modal mid-flight and swallow the
|
||||
// sync summary or error. (onSaved was already emitted on success;
|
||||
// repeating it just guards the failure case where we still refresh.)
|
||||
if (!next) {
|
||||
onSaved()
|
||||
onOpenChange(false)
|
||||
}
|
||||
}}
|
||||
bankName={bankName}
|
||||
accounts={accounts.filter((a) => selected.has(a.uid))}
|
||||
state={progressState}
|
||||
/>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{/* Visually yield to the progress modal while it is up, but WITHOUT
|
||||
signaling the parent (open stays true): the parent unmounts the
|
||||
component on close, and a rejected save must return to a live picker
|
||||
with the user's picks intact. */}
|
||||
<Dialog open={open && !progressOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Välj konton att synka: {bankName}</DialogTitle>
|
||||
@@ -596,6 +626,15 @@ export function AccountPickerDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-xs text-destructive"
|
||||
>
|
||||
Kontovalet sparades inte: {saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-border divide-y divide-border">
|
||||
{sortedAccounts.map(account => {
|
||||
const isChecked = selected.has(account.uid)
|
||||
|
||||
Reference in New Issue
Block a user