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
@@ -837,7 +837,7 @@ export const enableBankingExtension: Extension = {
|
||||
// cash_accounts, whose UNIQUE (company_id, ledger_account) constraint
|
||||
// would otherwise fail per-account and get swallowed, leaving accounts
|
||||
// silently unmirrored.
|
||||
const { allocatePsd2LedgerAccount, upsertFromPsd2 } = await import(
|
||||
const { allocatePsd2LedgerAccount, upsertFromPsd2, getRevokedConnectionIds } = await import(
|
||||
'@/lib/cash-accounts/service'
|
||||
)
|
||||
|
||||
@@ -857,10 +857,32 @@ export const enableBankingExtension: Extension = {
|
||||
)
|
||||
// Slots held by OTHER connections' PSD2 accounts — an explicit mapping
|
||||
// onto one of those would violate the unique constraint. Manual rows
|
||||
// are not foreign: upsertFromPsd2 promotes them in place.
|
||||
// are not foreign: upsertFromPsd2 promotes them in place. Rows held by
|
||||
// a REVOKED connection are not foreign either: those are orphaned
|
||||
// leftovers (disconnect predating the claim release, or a lost demote)
|
||||
// and upsertFromPsd2 promotes them in place too. Excluding them here
|
||||
// is the self-heal path for companies whose bank was disconnected
|
||||
// before disconnect started releasing ledger claims.
|
||||
const foreignConnectionIds = [
|
||||
...new Set(
|
||||
cashRows
|
||||
.filter(r => r.bank_connection_id !== null && r.bank_connection_id !== connection.id)
|
||||
.map(r => r.bank_connection_id as string)
|
||||
),
|
||||
]
|
||||
const revokedConnectionIds = await getRevokedConnectionIds(
|
||||
supabase,
|
||||
companyId,
|
||||
foreignConnectionIds
|
||||
)
|
||||
const foreignConnectedLedgers = new Set(
|
||||
cashRows
|
||||
.filter(r => r.bank_connection_id !== null && r.bank_connection_id !== connection.id)
|
||||
.filter(
|
||||
r =>
|
||||
r.bank_connection_id !== null &&
|
||||
r.bank_connection_id !== connection.id &&
|
||||
!revokedConnectionIds.has(r.bank_connection_id)
|
||||
)
|
||||
.map(r => r.ledger_account)
|
||||
)
|
||||
|
||||
@@ -1227,6 +1249,30 @@ export const enableBankingExtension: Extension = {
|
||||
return NextResponse.json({ error: 'Failed to disconnect' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Release the connection's ledger claims by demoting its cash_accounts
|
||||
// rows to manual (bank_connection_id = null). The rows themselves stay:
|
||||
// transactions.cash_account_id and the ledger history reference them,
|
||||
// and upsertFromPsd2 promotes a manual holder in place on reconnect so
|
||||
// the same bank lands back on its original BAS account (e.g. 1930)
|
||||
// instead of overflowing to the next free slot.
|
||||
const { error: releaseError } = await supabase
|
||||
.from('cash_accounts')
|
||||
.update({ bank_connection_id: null })
|
||||
.eq('company_id', companyId)
|
||||
.eq('bank_connection_id', connection.id)
|
||||
|
||||
if (releaseError) {
|
||||
// Don't fail the disconnect: the connection is already revoked, and
|
||||
// the allocator / collision guard also skip revoked connections, so
|
||||
// the orphaned rows self-heal on the next picker save.
|
||||
log.error('[enable-banking] Failed to release cash_accounts ledger claims on disconnect', {
|
||||
errorMessage: releaseError.message,
|
||||
connectionId: connection.id,
|
||||
userId: user.id,
|
||||
companyId,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
|
||||
await emit({
|
||||
|
||||
Reference in New Issue
Block a user