fix(bank): never pre-check or mirror another company's accounts in the EB callback (#2116)

* fix(bank): never pre-check or mirror another company's accounts in the EB callback

At one-session banks (SEB) the PSU's single consent can cover accounts a
sibling company books. The OAuth callback stored whatever the session
returned into the active company: all pre-enabled, mirrored into its
cash_accounts, ledgers allocated from its chart: one 'Spara val' away
from booking another aktiebolag's transactions (user report F1,
2026-09-01).

The deliberate reuse path (findReusableSessions) already guards claimed
IBANs; the callback now runs the same check via
fetchCrossCompanyAccountContext:

- accounts claimed by another of the user's companies are stored
  disabled + flagged (claimed_by_company_*), skipped by the
  cash_accounts mirror, and the picker names the claiming company
- a 'Synkas ej' deselection made on any other connection row is carried
  onto fresh rows (the recurring came-back-pre-checked complaint, C2)
- lookup failure fails closed: new accounts stored deselected
- accounts the row itself already carried keep their own state, so a
  renewal can never switch a working feed off

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197wmwP6zNaYvsGfbZuQHGA

* fix(bank): close the skeptic-found holes in the cross-company claim guard

Consolidated fixes from the three-skeptic review of PR #2116 (all three
refuted the first cut):

- Active-company standing state (enabled cash_accounts + enabled
  accounts on its live-ish connection rows) now outranks sibling claims
  company-wide, not row-wide: a bank-list renewal arrives on a FRESH row
  with no priors, and the old row-local check would have let a sibling
  claim switch a working feed off while supersede demoted its cash row.
- pending_selection rows no longer claim accounts or feed deselection
  memory: their flags are unconfirmed callback output (including this
  guard's own fail-closed writes), so an abandoned picker or a transient
  lookup error can no longer poison later connects.
- Guard-disabled accounts are never mirrored from the callback:
  upsertFromPsd2 with enabled:false for a new-to-row account could
  promote the seeded primary 1930 manual row and flip it to disabled
  under a foreign identity.
- The selection save skips ledger allocation and the cash_accounts
  mirror for disabled never-mirrored accounts, so 'no cash row, no 19xx
  slot burned' holds past the mandatory Spara val, and strips the
  claimed_by_*/deselected flags when the user deliberately enables an
  account.
- Deselection carry is no longer silent: deselected_elsewhere flag +
  picker note 'Tidigare bortvald'.
- Claim lookups paginate via fetchAllRows: the bare select's silent
  1000-row PostgREST cap failed open for exactly the multi-company
  consultants the guard exists for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197wmwP6zNaYvsGfbZuQHGA

* fix(bank): claim-guard round 2: pending_selection claims asymmetrically, paged reads ordered

Skeptic re-verification of 25a339810 found two holes:

- Excluding pending_selection rows from claims reopened the
  attach-to-picker window: an attach-created row holds deliberately
  offered enabled accounts with no cash_accounts rows until its picker
  is saved, and a full-OAuth connect in another company inside that
  window could take the same physical account. Enabled accounts on
  pending_selection rows claim again; their disabled flags still stay
  out of the deselection memory (unconfirmed callback output, including
  the guard's own fail-closed writes).
- Both fetchAllRows claim queries now order('id'): unordered .range()
  pagination can silently skip rows at page boundaries, and a skipped
  row is a missed claim, failing open at exactly the 1000+-row scale
  the pagination was added for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197wmwP6zNaYvsGfbZuQHGA

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-01 15:24:10 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent b1f7116231
commit fa69174aa0
8 changed files with 900 additions and 12 deletions
+35 -2
View File
@@ -1134,7 +1134,7 @@ export const enableBankingExtension: Extension = {
const mappingsByUid = new Map(mappings.map(m => [m.uid, m]))
const updatedAccounts: StoredAccount[] = existing.map(a => {
const mapping = mappingsByUid.get(a.uid)
return {
const next: StoredAccount = {
...a,
enabled: enabledSet.has(a.uid),
// Apply ledger_account from mapping when present. Explicit null clears it.
@@ -1144,8 +1144,29 @@ export const enableBankingExtension: Extension = {
? { ledger_account: mapping.ledger_account ?? undefined }
: {}),
}
// Enabling an account is the deliberate takeover the callback's
// guard flags exist to force: once made, the flags are stale (the
// account syncs HERE now) and would keep rendering a false
// "synkas i annat bolag" note in every later picker.
if (next.enabled) {
delete next.claimed_by_company_id
delete next.claimed_by_company_name
delete next.deselected_elsewhere
}
return next
})
// Accounts the callback guard left disabled AND unmirrored (no ledger
// anywhere) stay that way through a save that does not enable them:
// allocating a 19xx slot and upserting a cash_accounts row for a
// still-disabled claimed account would recreate exactly the state the
// guard exists to prevent (another company's IBAN and name in this
// company's chart and routing table), one screen after the callback
// avoided it. Disabled accounts that already have a ledger or a
// mirrored row keep the existing behavior: their row's enabled flag
// must still flip off.
const neverMirroredDisabledUids = new Set<string>()
// Resolve the effective mirror ledger for every account up front and
// reject collisions with a 400 — the mirror pass below writes into
// cash_accounts, whose UNIQUE (company_id, ledger_account) constraint
@@ -1268,6 +1289,13 @@ export const enableBankingExtension: Extension = {
// mirror pass surface any collision per-account, as before.
for (const a of updatedAccounts) {
if (effectiveLedgerByUid.has(a.uid)) continue
// See neverMirroredDisabledUids above: a disabled account that has
// never held a ledger or a mirrored row gets neither allocated nor
// mirrored by this save.
if (!enabledSet.has(a.uid) && !reuseRowByUid.has(a.uid)) {
neverMirroredDisabledUids.add(a.uid)
continue
}
let allocated: string | null = null
try {
const resolved = await resolvePsd2LedgerAccount(supabase, companyId, user.id, {
@@ -1290,8 +1318,10 @@ export const enableBankingExtension: Extension = {
}
// accounts_data mirrors the resolved assignment so the picker
// pre-fills reality on the next open.
// pre-fills reality on the next open. Skipped disabled accounts keep
// no assignment: their slot is only claimed if they are ever enabled.
for (const a of updatedAccounts) {
if (neverMirroredDisabledUids.has(a.uid)) continue
a.ledger_account = effectiveLedgerByUid.get(a.uid)
}
@@ -1326,6 +1356,9 @@ export const enableBankingExtension: Extension = {
// without reading the JSONB column.
{
for (const a of updatedAccounts) {
// Never-mirrored disabled accounts (callback-guard leftovers the
// user did not enable) get no cash_accounts row: see above.
if (neverMirroredDisabledUids.has(a.uid)) continue
const ledgerAccount = a.ledger_account ?? '1930'
// Only reuse the IBAN-matched row when it already sits on the
// ledger we are about to write. If the user deliberately remapped