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
@@ -899,6 +899,27 @@ export function AccountPickerDialog({
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
</p>
)}
{/* The callback found this IBAN already booked by another
of the user's companies (one consent can cover several
companies' accounts at e.g. SEB). Unchecked by default;
naming the claimant is what stops a reflexive
select-all from booking it here too. */}
{account.claimed_by_company_id && (
<p className="text-xs text-muted-foreground">
Synkas redan i{' '}
<span data-ph-mask="">
{account.claimed_by_company_name || 'ett annat bolag'}
</span>
</p>
)}
{/* Carried deselection: the user said "Synkas ej" to this
IBAN on another connection. An unexplained unchecked
box reads as a glitch; a silent one hides a sync gap. */}
{!account.claimed_by_company_id && account.deselected_elsewhere && (
<p className="text-xs text-muted-foreground">
Tidigare bortvald: markera för att synka i detta bolag
</p>
)}
</div>
{account.balance !== undefined && (
<p className="text-sm font-medium tabular-nums shrink-0">
+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
@@ -0,0 +1,306 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/cash-accounts/service', () => ({
normalizeIban: (iban?: string | null) => {
if (!iban) return null
const normalized = iban.replace(/\s+/g, '').toUpperCase()
return normalized || null
},
}))
import type { SupabaseClient } from '@supabase/supabase-js'
import {
fetchCrossCompanyAccountContext,
unclaimedAccountsFor,
} from '../session-sharing'
import type { StoredAccount } from '../../types'
interface TableResult {
data?: unknown
error?: { message: string } | null
}
type MockChain = Record<string, ReturnType<typeof vi.fn>> & {
then: (resolve: (v: unknown) => void) => void
}
/**
* Chainable mock resolving per table: every filter method returns the chain,
* awaiting it yields the preset result for that table. Chains are recorded so
* tests can assert which filters were applied (the mock does not filter).
*/
function makeSupabase(results: Record<string, TableResult>): {
supabase: SupabaseClient
chainsByTable: Map<string, MockChain[]>
} {
const chainsByTable = new Map<string, MockChain[]>()
const supabase = {
from: (table: string) => {
const result = results[table] ?? { data: [], error: null }
const chain = {} as MockChain
for (const m of ['select', 'eq', 'neq', 'in', 'not', 'is', 'order', 'limit', 'range']) {
chain[m] = vi.fn().mockReturnValue(chain)
}
chain.then = (resolve: (v: unknown) => void) =>
resolve({ data: result.data ?? null, error: result.error ?? null })
const bucket = chainsByTable.get(table)
if (bucket) bucket.push(chain)
else chainsByTable.set(table, [chain])
return chain
},
} as unknown as SupabaseClient
return { supabase, chainsByTable }
}
describe('fetchCrossCompanyAccountContext', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('claims enabled accounts of sibling companies, remembers deselections, resolves names', async () => {
const { supabase } = makeSupabase({
company_members: {
data: [{ company_id: 'company-1' }, { company_id: 'company-2' }],
},
bank_connections: {
data: [
{
id: 'conn-sibling',
company_id: 'company-2',
accounts_data: [
{ uid: 'a1', iban: 'SE11', currency: 'SEK', enabled: true },
{ uid: 'a2', iban: 'SE22', currency: 'SEK', enabled: false },
],
},
{
id: 'conn-own-other-row',
company_id: 'company-1',
// The active company's own account never becomes a claim; its
// enabled accounts are its standing set and its deselections are
// remembered.
accounts_data: [
{ uid: 'a3', iban: 'SE33', currency: 'SEK', enabled: true },
{ uid: 'a4', iban: 'SE44', currency: 'SEK', enabled: false },
],
},
],
},
cash_accounts: { data: [] },
companies: { data: [{ id: 'company-2', name: 'Sibling AB' }] },
})
const context = await fetchCrossCompanyAccountContext(
supabase,
'user-1',
'company-1',
'conn-active',
)
expect(context).not.toBeNull()
expect(context!.claims.get('SE11')).toEqual({
companyId: 'company-2',
companyName: 'Sibling AB',
})
expect(context!.claims.has('SE33')).toBe(false)
expect(context!.activeCompanyIbans).toEqual(new Set(['SE33']))
expect(context!.deselectedIbans).toEqual(new Set(['SE22', 'SE44']))
})
it("lets the active company's own standing state outrank a sibling claim", async () => {
// The bank-list renewal case: the active company's old row (about to be
// superseded) and its cash_accounts row still book the IBAN. A sibling
// claim on the same IBAN must not win, or a renewal arriving on a fresh
// row would switch a working feed off.
const { supabase } = makeSupabase({
company_members: {
data: [{ company_id: 'company-1' }, { company_id: 'company-2' }],
},
bank_connections: {
data: [
{
id: 'conn-sibling',
company_id: 'company-2',
accounts_data: [{ uid: 'a1', iban: 'SE11', currency: 'SEK', enabled: true }],
},
],
},
cash_accounts: {
data: [{ company_id: 'company-1', iban: 'SE11' }],
},
companies: { data: [] },
})
const context = await fetchCrossCompanyAccountContext(
supabase,
'user-1',
'company-1',
'conn-active',
)
expect(context!.claims.has('SE11')).toBe(false)
expect(context!.activeCompanyIbans.has('SE11')).toBe(true)
expect(context!.deselectedIbans.has('SE11')).toBe(false)
})
it('claims IBANs held by sibling companies via cash_accounts too', async () => {
const { supabase } = makeSupabase({
company_members: {
data: [{ company_id: 'company-1' }, { company_id: 'company-2' }],
},
bank_connections: { data: [] },
cash_accounts: { data: [{ company_id: 'company-2', iban: 'SE55' }] },
companies: { data: [] },
})
const context = await fetchCrossCompanyAccountContext(
supabase,
'user-1',
'company-1',
'conn-active',
)
expect(context!.claims.get('SE55')).toEqual({ companyId: 'company-2', companyName: null })
})
it('lets a claim outrank a remembered deselection for the same IBAN', async () => {
const { supabase } = makeSupabase({
company_members: {
data: [{ company_id: 'company-1' }, { company_id: 'company-2' }],
},
bank_connections: {
data: [
{
id: 'conn-a',
company_id: 'company-2',
accounts_data: [{ uid: 'a1', iban: 'SE11', currency: 'SEK', enabled: true }],
},
{
id: 'conn-b',
company_id: 'company-1',
accounts_data: [{ uid: 'a2', iban: 'SE11', currency: 'SEK', enabled: false }],
},
],
},
cash_accounts: { data: [] },
companies: { data: [] },
})
const context = await fetchCrossCompanyAccountContext(
supabase,
'user-1',
'company-1',
'conn-active',
)
expect(context!.claims.has('SE11')).toBe(true)
expect(context!.deselectedIbans.has('SE11')).toBe(false)
})
it('treats pending_selection rows asymmetrically: enabled accounts claim, disabled flags are ignored', async () => {
// An attach-created row is pending_selection with deliberately-offered
// enabled accounts and NO cash rows until its picker is saved: those must
// claim, or a second company can take the same physical account inside
// that window. Its disabled flags are unconfirmed callback output
// (including the guard's own fail-closed writes) and must NOT feed the
// deselection memory.
const { supabase, chainsByTable } = makeSupabase({
company_members: {
data: [{ company_id: 'company-1' }, { company_id: 'company-2' }],
},
bank_connections: {
data: [
{
id: 'conn-attached',
company_id: 'company-2',
status: 'pending_selection',
accounts_data: [
{ uid: 'a1', iban: 'SE11', currency: 'SEK', enabled: true },
{ uid: 'a2', iban: 'SE22', currency: 'SEK', enabled: false },
],
},
],
},
cash_accounts: { data: [] },
companies: { data: [] },
})
const context = await fetchCrossCompanyAccountContext(
supabase,
'user-1',
'company-1',
'conn-active',
)
expect(context!.claims.has('SE11')).toBe(true)
expect(context!.deselectedIbans.has('SE22')).toBe(false)
const connectionChain = chainsByTable.get('bank_connections')![0]
expect(connectionChain.in).toHaveBeenCalledWith('status', [
'active',
'pending_selection',
'expired',
'error',
])
// Paged reads must order on a unique column or rows can be silently
// skipped at page boundaries (a skipped row is a missed claim).
expect(connectionChain.order).toHaveBeenCalledWith('id')
expect(chainsByTable.get('cash_accounts')![0].order).toHaveBeenCalledWith('id')
})
it('reads cash_accounts for ALL member companies (active included)', async () => {
const { supabase, chainsByTable } = makeSupabase({
company_members: {
data: [{ company_id: 'company-1' }, { company_id: 'company-2' }],
},
bank_connections: { data: [] },
cash_accounts: { data: [] },
companies: { data: [] },
})
await fetchCrossCompanyAccountContext(supabase, 'user-1', 'company-1', 'conn-active')
const cashChain = chainsByTable.get('cash_accounts')![0]
expect(cashChain.in).toHaveBeenCalledWith('company_id', ['company-1', 'company-2'])
})
it('returns null when a lookup fails, so the caller can fail closed', async () => {
const { supabase } = makeSupabase({
bank_connections: { data: null, error: { message: 'boom' } },
})
const context = await fetchCrossCompanyAccountContext(
supabase,
'user-1',
'company-1',
'conn-active',
)
expect(context).toBeNull()
})
})
describe('unclaimedAccountsFor', () => {
it('strips stale claimed_by and deselected flags from offered accounts', () => {
const accounts: StoredAccount[] = [
{
uid: 'a1',
iban: 'SE11',
currency: 'SEK',
enabled: false,
ledger_account: '1938',
claimed_by_company_id: 'company-9',
claimed_by_company_name: 'Stale AB',
deselected_elsewhere: true,
},
]
const offered = unclaimedAccountsFor(accounts, new Set())
expect(offered).toHaveLength(1)
expect(offered[0].enabled).toBe(true)
expect(offered[0].ledger_account).toBeUndefined()
expect(offered[0].claimed_by_company_id).toBeUndefined()
expect(offered[0].claimed_by_company_name).toBeUndefined()
expect(offered[0].deselected_elsewhere).toBeUndefined()
})
})
@@ -1,5 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { normalizeIban } from '@/lib/cash-accounts/service'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { createLogger } from '@/lib/logger'
import type { StoredAccount } from '../types'
@@ -72,8 +73,17 @@ export function unclaimedAccountsFor(
seen.add(iban)
// The source company's enable/disable choice is its own; company B starts
// with everything on and unchecks in the picker. Drop the source's ledger
// mapping too: that number belongs to the other company's chart.
const { ledger_account: _ledger, ...rest } = account
// mapping too: that number belongs to the other company's chart. The
// claimed_by_* flags are likewise the source row's history: everything
// offered here is unclaimed by definition, so a stale flag must not
// render a "synkas i annat bolag" note on a genuinely free account.
const {
ledger_account: _ledger,
claimed_by_company_id: _claimedId,
claimed_by_company_name: _claimedName,
deselected_elsewhere: _deselected,
...rest
} = account
out.push({ ...rest, enabled: true })
}
return out
@@ -271,6 +281,208 @@ async function fetchCompanyNames(
)
}
/** The company whose books an IBAN is already synced into. */
export interface ForeignIbanClaim {
companyId: string
companyName: string | null
}
export interface CrossCompanyAccountContext {
/**
* Normalized IBAN → the OTHER company of this user that already books it
* (enabled cash_accounts row, or an enabled account on a live-ish
* connection). First claimant wins; which sibling is named is cosmetic.
* Never contains an IBAN the ACTIVE company itself already books: the
* active company's standing state outranks a sibling's claim, whatever row
* the callback happens to be finalizing (a bank-list renewal arrives on a
* FRESH row with no priors, and must not switch a working feed off).
*/
claims: Map<string, ForeignIbanClaim>
/**
* Normalized IBANs the user has deselected ("Synkas ej") on other
* connection rows, in any company, so a fresh connection row does not
* resurrect an account the user already opted out of (the recurring
* came-back-pre-checked complaint). Only rows whose selection the user has
* actually SAVED contribute ('active'/'expired'/'error'):
* a 'pending_selection' row's flags are unconfirmed callback output,
* including this guard's own fail-closed writes, and treating them as user
* intent would make one abandoned picker (or one transient lookup error)
* poison every later connect. Cleared of anything the active company
* books or a sibling claims (both outrank a remembered deselection).
*/
deselectedIbans: Set<string>
/**
* Normalized IBANs the ACTIVE company already books: enabled cash_accounts
* rows, plus enabled accounts on its own live-ish connection rows. Exposed
* for the callers/tests; claims and deselectedIbans are already cleaned
* against it.
*/
activeCompanyIbans: Set<string>
}
/**
* What the user's OTHER companies already do with each IBAN, for the OAuth
* callback's cross-company guard. At one-session banks (SEB) the PSU's single
* consent can cover accounts that belong in a sibling company's books, and the
* callback stores whatever the session returns: without this lookup those
* accounts land pre-enabled in the wrong company.
*
* Runs on the callback's SERVICE-ROLE client, so nothing is RLS-scoped:
* every query below filters on the user's own rows explicitly
* (bank_connections.user_id, cash_accounts.company_id via company_members).
*
* Returns null when any lookup failed. The caller must fail closed (treat
* every unrecognized account as not-safe-to-pre-check): an empty result is
* indistinguishable from "nothing is claimed", which is the one answer this
* guard must never invent.
*/
export async function fetchCrossCompanyAccountContext(
serviceSupabase: SupabaseClient,
userId: string,
activeCompanyId: string,
excludeConnectionId: string,
): Promise<CrossCompanyAccountContext | null> {
const claims = new Map<string, ForeignIbanClaim>()
const deselectedIbans = new Set<string>()
const activeCompanyIbans = new Set<string>()
// Statuses whose accounts count: a revoked row's accounts are released
// territory; 'expired'/'error' still count (a sibling whose feed
// momentarily died at a one-session bank has NOT given its accounts up).
// 'pending_selection' rows count ASYMMETRICALLY: their enabled accounts
// still claim, because an attach-created row holds deliberately-offered
// accounts with no cash_accounts rows until its picker is saved, and
// ignoring that window lets a second company take the same physical
// account (the exact failure fetchIbanCarriers documents). Their DISABLED
// flags are unconfirmed callback output though — including this guard's
// own fail-closed writes — so they never feed the deselection memory:
// one abandoned picker or transient lookup error must not poison every
// later connect.
let connectionRows: Array<{
id: string
company_id: string
status: string
accounts_data: StoredAccount[] | null
}>
try {
connectionRows = await fetchAllRows(range =>
serviceSupabase
.from('bank_connections')
.select('id, company_id, status, accounts_data')
.eq('user_id', userId)
.neq('id', excludeConnectionId)
.in('status', ['active', 'pending_selection', 'expired', 'error'])
// Unique-column order: fetchAllRows pages with .range(), and an
// unordered paged read can silently SKIP rows at page boundaries —
// a skipped row here is a missed claim, which fails open.
.order('id')
.range(range.from, range.to),
)
} catch (connectionError) {
log.error('cross-company claim lookup failed (bank_connections)', {
activeCompanyId,
error: connectionError instanceof Error ? connectionError.message : String(connectionError),
})
return null
}
for (const row of connectionRows) {
for (const account of row.accounts_data ?? []) {
const iban = normalizeIban(account.iban)
if (!iban) continue
if (account.enabled === false) {
if (row.status !== 'pending_selection') deselectedIbans.add(iban)
} else if (row.company_id === activeCompanyId) {
activeCompanyIbans.add(iban)
} else if (!claims.has(iban)) {
claims.set(iban, { companyId: row.company_id, companyName: null })
}
}
}
// cash_accounts catches standing state the connection rows no longer carry
// (older connects, CSV-era rows promoted onto a feed, a row a supersede is
// about to demote). ALL of the user's companies are read: sibling rows
// become claims, the active company's rows become its own standing set.
// Scoped via memberships, since the service client sees everything.
const { data: membershipRows, error: membershipError } = await serviceSupabase
.from('company_members')
.select('company_id')
.eq('user_id', userId)
if (membershipError) {
log.error('cross-company claim lookup failed (company_members)', {
activeCompanyId,
error: membershipError.message,
})
return null
}
const memberCompanyIds = [
...new Set(
((membershipRows ?? []) as Array<{ company_id: string }>).map(r => r.company_id),
),
]
if (memberCompanyIds.length > 0) {
let cashRows: Array<{ company_id: string; iban: string | null }>
try {
// fetchAllRows, not a bare select: PostgREST silently caps at 1000
// rows, and a silently truncated claim set fails OPEN for exactly the
// multi-company consultants this guard exists for.
cashRows = await fetchAllRows(range =>
serviceSupabase
.from('cash_accounts')
.select('company_id, iban')
.in('company_id', memberCompanyIds)
.eq('enabled', true)
.not('iban', 'is', null)
// Unique-column order: see the bank_connections page above.
.order('id')
.range(range.from, range.to),
)
} catch (cashError) {
log.error('cross-company claim lookup failed (cash_accounts)', {
activeCompanyId,
error: cashError instanceof Error ? cashError.message : String(cashError),
})
return null
}
for (const row of cashRows) {
const iban = normalizeIban(row.iban)
if (!iban) continue
if (row.company_id === activeCompanyId) {
activeCompanyIbans.add(iban)
} else if (!claims.has(iban)) {
claims.set(iban, { companyId: row.company_id, companyName: null })
}
}
}
// Precedence, strongest first: the active company's own standing state
// (a renewal must never switch a working feed off, whichever row it
// arrives on), then a sibling's claim, then a remembered deselection (the
// picker note "synkas i X" is strictly more information than a bare
// unchecked box).
for (const iban of activeCompanyIbans) {
claims.delete(iban)
deselectedIbans.delete(iban)
}
for (const iban of claims.keys()) deselectedIbans.delete(iban)
if (claims.size > 0) {
const names = await fetchCompanyNames(serviceSupabase, [
...new Set([...claims.values()].map(c => c.companyId)),
])
for (const claim of claims.values()) {
claim.companyName = names.get(claim.companyId) ?? null
}
}
return { claims, deselectedIbans, activeCompanyIbans }
}
/**
* How many OTHER connections still depend on this session.
*
@@ -23,6 +23,20 @@ export interface StoredAccount {
// the whole history. lib/sync.ts falls back to IBAN-then-uid when unset
// (rows that predate this field) and stamps it on the next sync.
dedup_scope?: string
// Set by the OAuth callback when the account's IBAN is already booked by
// ANOTHER of the user's companies. At one-session banks (SEB) the PSU's
// single consent can cover accounts that belong in a sibling company's
// books; such accounts are stored disabled and never mirrored into this
// company's cash_accounts, and the picker renders the claim so the user
// sees why the account is unchecked. Enabling one is a deliberate act.
claimed_by_company_id?: string
claimed_by_company_name?: string
// Set by the OAuth callback when the account arrived deselected because the
// user chose "Synkas ej" for the same IBAN on another connection row (any
// company). Rendered as a note in the picker so the unchecked box is never
// silent; cleared by the selection save when the user re-enables the
// account.
deselected_elsewhere?: boolean
}
// Re-export API types from the client