fix(enable-banking): reconnect supersedes the old connection and stops duplicate imports (#1728)
* fix(enable-banking): supersede the old connection on bank reconnect and stop renewal duplicates
A renewal performed via the bank list ("Anslut ny bank") created a second
bank_connections row and left the old one parked in 'expired' forever: an
eternal "Åtgärd krävs" card, a red status chip, transactions stranded on the
dead row (so the picker's gap-fill probe read the renewal as a first
connect), and re-imported history for no-IBAN accounts whose provider uids
change on re-authorization.
- New migration: additive superseded_by uuid (FK, ON DELETE SET NULL) +
superseded_at + partial index on bank_connections. Status 'revoked' is
reused for superseded rows (no CHECK change); superseded_by disambiguates
a supersede from a user disconnect. File only: not applied anywhere yet.
- New lib/supersede.ts: after the OAuth callback finalizes, park same-bank
siblings matched by IBAN overlap (an ACTIVE sibling without overlap is
never touched; no-IBAN fallback only for dead siblings when neither side
has IBANs), revoke their EB session only when countLiveSiblings says
nobody shares it, re-point their transactions in id batches, demote
leftover cash_accounts claims (the mirror then promotes them by IBAN),
carry last_synced_at + initial_sync_* onto the survivor, and emit the new
bank_connection.superseded audit event.
- /connect fresh path: 409 { code: 'EXISTING_CONNECTION',
existing_connection_id } when a non-revoked same-bank row exists, unless
the body carries force_new: true (escape hatch for a second login at the
same bank). Runs after the zombie sweep; reconnect-in-place unaffected.
- Dedup scope stability: StoredAccount.dedup_scope pins the external_id
account scope at first ingest (normalized IBAN, else the uid of that
moment), is carried across in-place reconnects and supersedes by IBAN
match, and sync.ts uses dedup_scope ?? IBAN ?? uid (stamping legacy rows
lazily). The external_id FORMAT is untouched.
- AccountPickerDialog gap-fill probe also includes superseded connection
ids so the renewal default never races the transaction re-point.
- Sync toast (BankSyncNowButton) now also reports skipped duplicates
(sv+en strings) so a correctly deduped renewal does not look broken.
Tests: supersede unit tests, /connect 409 + force_new, callback supersede
wiring + dedup-scope carry, sync external_id stability across uid changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(enable-banking): scope the connect 409 to dead siblings and harden supersede ordering
- POST /connect only 409s when the same-bank sibling is expired/error/
pending_selection: an active row (a second legitimate login at the same
bank) never blocks a fresh connect; force_new bypass kept. The 409 text
now names the bank and points at Fornya samtycke.
- supersede parks the sibling row BEFORE revoking its EB session, and skips
the revoke entirely (logged) when the park update fails, so a failed park
can no longer leave a live-looking row with a dead session.
- callback keeps a survivor account's explicit dedup_scope instead of
letting a carried sibling scope clobber it; carried scopes only apply
when the survivor's scope was derived (IBAN/uid fallback).
- sync-now toast joins its two sentences with '. ' so the imported and
skipped-duplicates messages no longer run together.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
1ded1af8fe
commit
3de5dee553
@@ -263,11 +263,29 @@ export function AccountPickerDialog({
|
||||
if (!open || !isInitialSelection || !company?.id || accounts.length === 0) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
// Include rows this connection superseded: a renewal that arrived via a
|
||||
// fresh connect owns no transactions until the callback's supersede has
|
||||
// re-pointed them, and the gap-fill default must not depend on winning
|
||||
// that race. A failed lookup (e.g. column not deployed yet) falls back
|
||||
// to probing this connection alone.
|
||||
let probeConnectionIds: string[] = [connectionId]
|
||||
const { data: supersededRows, error: supersededError } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id')
|
||||
.eq('company_id', company.id)
|
||||
.eq('superseded_by', connectionId)
|
||||
if (cancelled) return
|
||||
if (!supersededError && supersededRows) {
|
||||
probeConnectionIds = [
|
||||
connectionId,
|
||||
...(supersededRows as Array<{ id: string }>).map((r) => r.id),
|
||||
]
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from('transactions')
|
||||
.select('date')
|
||||
.eq('company_id', company.id)
|
||||
.eq('bank_connection_id', connectionId)
|
||||
.in('bank_connection_id', probeConnectionIds)
|
||||
.order('date', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
Reference in New Issue
Block a user