fix(bank): unchecked accounts yield their bokföringskonto to a checked one in the picker (#2449)

Unchecking the wrong bank account and putting the right one on 1930
answered 400 "Flera bankkonton kan inte bokföras på samma konto": the
collision pass counted every stored account as a claim, the picker
hides the ledger dropdown for unchecked rows, and disconnect plus
reconnect re-claims the same cash_accounts rows by IBAN. No route out
(support case 2026-09-09, two 400s on the route in the Vercel logs).

Checked accounts stay hard claims (duplicate and foreign-live = 400).
Unchecked accounts hold their ledger as a soft claim: kept and mirrored
with enabled=false unless a checked account wants it, then they yield
and lose the prefill. Contested rows (this connection's row for a
yielded or moved account, another connection's row for an account
unchecked there) are demoted to manual in one update before the mirror,
so upsertFromPsd2 promotes the holder in place and row ids, transaction
links and the is_primary flag on the 1930 row survive. The same pass
makes two checked accounts swapping ledgers work, which previously
tripped the unique constraint in both upserts and was swallowed.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-09 11:29:21 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 6ea92f3152
commit cb9eedd7f2
3 changed files with 356 additions and 16 deletions
@@ -78,10 +78,18 @@ interface SupabaseStub {
chartAccountNumbers?: string[]
/** Existing cash_accounts rows for the company (ledger collision validation). */
cashAccountRows?: Array<{
id?: string
external_uid: string | null
bank_connection_id: string | null
ledger_account: string
iban?: string | null
/** Mirrors the picker's checkbox in that row's connection; absent = live claim. */
enabled?: boolean
}>
/** Release-pass updates (rows demoted to manual before the mirror), in order. */
cashReleases?: Array<{ payload: Record<string, unknown>; ids: string[] }>
/** Error returned by the release-pass update. */
cashReleaseError?: { message: string } | null
/** Completed SIE import overlapping the backfill window (renewal-flood guard). */
sieImportRow?: { id: string } | null
/** company_members row for the caller; role 'viewer' disables the sweep. */
@@ -120,6 +128,13 @@ function buildSupabase(stub: SupabaseStub) {
return {
select: vi.fn().mockReturnThis(),
eq: vi.fn(() => Promise.resolve({ data: stub.cashAccountRows ?? [], error: null })),
// Release pass: update({...}).in('id', ids) awaited as a thenable.
update: vi.fn((payload: Record<string, unknown>) => ({
in: vi.fn((_col: string, ids: string[]) => {
;(stub.cashReleases ??= []).push({ payload, ids })
return Promise.resolve({ error: stub.cashReleaseError ?? null })
}),
})),
}
}
// Renewal-flood guard: SIE-overlap probe before the inline backfill.
@@ -1360,6 +1375,229 @@ describe('PATCH /accounts (enable-banking)', () => {
expect(written.find(a => a.uid === 'acc-1')?.ledger_account).toBe('1940')
})
describe('unchecked accounts hold their ledger only as a soft claim', () => {
// The reported dead end (support case 2026-09-09): the wrong bank
// account had been synced onto 1930, the user unchecked it and put the
// right one on 1930, and the save answered 400 because the unchecked
// account still counted as a claim. The picker hides the ledger
// dropdown for unchecked rows and disconnect + reconnect re-claims the
// same rows by IBAN, so no route led out of it.
const RELEASE = { bank_connection_id: null, external_uid: null }
it('lets a checked account take 1930 from an unchecked one on the same connection', async () => {
mockedSync.mockResolvedValue({ requestedFromDate: '2026-01-01', historyNarrowed: false, imported: 0, duplicates: 0, errors: 0 })
const stub: SupabaseStub = {
authUser: { id: 'user-1' },
chartAccountNumbers: ['1930'],
cashAccountRows: [
{ id: 'row-1930', external_uid: 'acc-wrong', bank_connection_id: 'conn-1', ledger_account: '1930' },
{ id: 'row-1935', external_uid: 'acc-right', bank_connection_id: 'conn-1', ledger_account: '1935' },
],
connectionRow: {
id: 'conn-1',
status: 'pending_selection',
accounts_data: [
{ uid: 'acc-wrong', currency: 'SEK', enabled: true, ledger_account: '1930' },
{ uid: 'acc-right', currency: 'SEK', enabled: true, ledger_account: '1935' },
],
},
}
const supabase = buildSupabase(stub)
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({
connection_id: 'conn-1',
enabled_uids: ['acc-right'],
account_mappings: [{ uid: 'acc-right', ledger_account: '1930' }],
}),
ctx
)
expect(res.status).toBe(200)
// The unchecked account's row handed over its slot before the mirror ran.
expect(stub.cashReleases).toEqual([{ payload: RELEASE, ids: ['row-1930'] }])
// accounts_data: the unchecked account no longer pre-fills 1930, the checked one holds it.
const written = stub.capturedUpdates?.[0]?.accounts_data as StoredAccount[]
const wrong = written.find(a => a.uid === 'acc-wrong')
expect(wrong?.enabled).toBe(false)
expect(wrong).not.toHaveProperty('ledger_account')
expect(written.find(a => a.uid === 'acc-right')?.ledger_account).toBe('1930')
// The mirror only touches the checked account; the yielded one has no row to write.
expect(mockUpsertFromPsd2).toHaveBeenCalledTimes(1)
expect(mockUpsertFromPsd2).toHaveBeenCalledWith(
expect.anything(),
'company-1',
expect.objectContaining({ external_uid: 'acc-right', ledger_account: '1930', enabled: true })
)
expect(mockAllocate).not.toHaveBeenCalled()
})
it('keeps an unchecked account on its ledger when nobody claims it, mirrored as disabled', async () => {
const stub: SupabaseStub = {
authUser: { id: 'user-1' },
cashAccountRows: [
{ id: 'row-1930', external_uid: 'acc-1', bank_connection_id: 'conn-1', ledger_account: '1930' },
{ id: 'row-1935', external_uid: 'acc-2', bank_connection_id: 'conn-1', ledger_account: '1935' },
],
connectionRow: {
id: 'conn-1',
status: 'active',
accounts_data: [
{ uid: 'acc-1', currency: 'SEK', enabled: true, ledger_account: '1930' },
{ uid: 'acc-2', currency: 'SEK', enabled: true, ledger_account: '1935' },
],
},
}
const supabase = buildSupabase(stub)
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-1'] }),
ctx
)
expect(res.status).toBe(200)
expect(stub.cashReleases).toBeUndefined()
const written = stub.capturedUpdates?.[0]?.accounts_data as StoredAccount[]
expect(written.find(a => a.uid === 'acc-2')).toMatchObject({ enabled: false, ledger_account: '1935' })
// Re-checking later lands back on 1935: the row stays, its enabled flag flips off.
expect(mockUpsertFromPsd2).toHaveBeenCalledTimes(2)
expect(mockUpsertFromPsd2).toHaveBeenCalledWith(
expect.anything(),
'company-1',
expect.objectContaining({ external_uid: 'acc-2', ledger_account: '1935', enabled: false })
)
})
it('lets two checked accounts swap ledgers in one save', async () => {
const stub: SupabaseStub = {
authUser: { id: 'user-1' },
chartAccountNumbers: ['1930', '1935'],
cashAccountRows: [
{ id: 'row-1930', external_uid: 'acc-1', bank_connection_id: 'conn-1', ledger_account: '1930' },
{ id: 'row-1935', external_uid: 'acc-2', bank_connection_id: 'conn-1', ledger_account: '1935' },
],
connectionRow: {
id: 'conn-1',
status: 'active',
accounts_data: [
{ uid: 'acc-1', currency: 'SEK', enabled: true, ledger_account: '1930' },
{ uid: 'acc-2', currency: 'SEK', enabled: true, ledger_account: '1935' },
],
},
}
const supabase = buildSupabase(stub)
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({
connection_id: 'conn-1',
enabled_uids: ['acc-1', 'acc-2'],
account_mappings: [
{ uid: 'acc-1', ledger_account: '1935' },
{ uid: 'acc-2', ledger_account: '1930' },
],
}),
ctx
)
expect(res.status).toBe(200)
// Both rows are demoted in ONE update, so neither upsert can trip the
// (company_id, ledger_account) constraint on the other's old slot.
expect(stub.cashReleases).toHaveLength(1)
expect([...stub.cashReleases![0].ids].sort()).toEqual(['row-1930', 'row-1935'])
const written = stub.capturedUpdates?.[0]?.accounts_data as StoredAccount[]
expect(written.find(a => a.uid === 'acc-1')?.ledger_account).toBe('1935')
expect(written.find(a => a.uid === 'acc-2')?.ledger_account).toBe('1930')
const mirrored = mockUpsertFromPsd2.mock.calls.map(c => {
const input = c[2] as { external_uid: string; ledger_account: string }
return [input.external_uid, input.ledger_account]
})
expect(mirrored.sort()).toEqual([
['acc-1', '1935'],
['acc-2', '1930'],
])
})
it('lets a mapping take a ledger held by an account that is unchecked in another connection', async () => {
mockedSync.mockResolvedValue({ requestedFromDate: '2026-01-01', historyNarrowed: false, imported: 0, duplicates: 0, errors: 0 })
const stub: SupabaseStub = {
authUser: { id: 'user-1' },
chartAccountNumbers: ['1935'],
cashAccountRows: [
{
id: 'row-other',
external_uid: 'other-acc',
bank_connection_id: 'conn-OTHER',
ledger_account: '1935',
enabled: false,
},
],
connectionRow: {
id: 'conn-1',
status: 'pending_selection',
accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true }],
},
}
const supabase = buildSupabase(stub)
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({
connection_id: 'conn-1',
enabled_uids: ['acc-1'],
account_mappings: [{ uid: 'acc-1', ledger_account: '1935' }],
}),
ctx
)
// Contrast with the synced-elsewhere case above, which stays a 400.
expect(res.status).toBe(200)
expect(stub.cashReleases).toEqual([{ payload: RELEASE, ids: ['row-other'] }])
expect(mockUpsertFromPsd2).toHaveBeenCalledWith(
expect.anything(),
'company-1',
expect.objectContaining({ external_uid: 'acc-1', ledger_account: '1935' })
)
})
it('returns 500 and persists nothing when the release update fails', async () => {
const stub: SupabaseStub = {
authUser: { id: 'user-1' },
chartAccountNumbers: ['1930'],
cashReleaseError: { message: 'boom' },
cashAccountRows: [
{ id: 'row-1930', external_uid: 'acc-wrong', bank_connection_id: 'conn-1', ledger_account: '1930' },
{ id: 'row-1935', external_uid: 'acc-right', bank_connection_id: 'conn-1', ledger_account: '1935' },
],
connectionRow: {
id: 'conn-1',
status: 'active',
accounts_data: [
{ uid: 'acc-wrong', currency: 'SEK', enabled: true, ledger_account: '1930' },
{ uid: 'acc-right', currency: 'SEK', enabled: true, ledger_account: '1935' },
],
},
}
const supabase = buildSupabase(stub)
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({
connection_id: 'conn-1',
enabled_uids: ['acc-right'],
account_mappings: [{ uid: 'acc-right', ledger_account: '1930' }],
}),
ctx
)
expect(res.status).toBe(500)
expect(stub.capturedUpdates).toBeUndefined()
expect(mockUpsertFromPsd2).not.toHaveBeenCalled()
})
})
it('rejects account_mappings that is not an array', async () => {
const stub: SupabaseStub = {
authUser: { id: 'user-1' },
+117 -16
View File
@@ -1288,7 +1288,7 @@ export const enableBankingExtension: Extension = {
const { data: companyCashRows } = await supabase
.from('cash_accounts')
.select('id, external_uid, bank_connection_id, ledger_account, iban')
.select('id, external_uid, bank_connection_id, ledger_account, iban, enabled')
.eq('company_id', companyId)
const cashRows = (companyCashRows ?? []) as Array<{
id: string
@@ -1296,6 +1296,7 @@ export const enableBankingExtension: Extension = {
bank_connection_id: string | null
ledger_account: string
iban: string | null
enabled: boolean | null
}>
// Rows that already represent one of THIS connection's accounts, matched
@@ -1331,6 +1332,12 @@ export const enableBankingExtension: Extension = {
// 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.
//
// Only ENABLED foreign rows count as live claims. A row whose account
// was unchecked in that connection's picker is not being synced onto
// the ledger; it keeps the slot only until someone who is syncing asks
// for it (the release pass below hands it over). This mirrors how
// session sharing already treats claims (see lib/session-sharing.ts).
const foreignConnectionIds = [
...new Set(
cashRows
@@ -1350,6 +1357,7 @@ export const enableBankingExtension: Extension = {
r.bank_connection_id !== null &&
r.bank_connection_id !== connection.id &&
!revokedConnectionIds.has(r.bank_connection_id) &&
r.enabled !== false &&
// Same IBAN as one of this connection's accounts: the same
// physical account under a stale owner, not a foreign claim.
!ownIbanRowIds.has(r.id)
@@ -1362,8 +1370,12 @@ export const enableBankingExtension: Extension = {
const duplicateLedgers = new Set<string>()
const conflictingLedgers = new Set<string>()
// First pass: accounts with an explicit or previously mirrored ledger.
// First pass: CHECKED accounts with an explicit or previously mirrored
// ledger. These are the hard claims: two of them on one ledger is a
// user error (400), and one of them on a ledger another connection is
// syncing onto is a conflict (400).
for (const a of updatedAccounts) {
if (!enabledSet.has(a.uid)) continue
const ledger = a.ledger_account ?? existingLedgerByUid.get(a.uid)
if (!ledger) continue
if (usedLedgers.has(ledger)) duplicateLedgers.add(ledger)
@@ -1374,6 +1386,39 @@ export const enableBankingExtension: Extension = {
effectiveLedgerByUid.set(a.uid, ledger)
}
// UNCHECKED accounts hold their ledger only as a soft claim: the slot
// stays theirs (so re-checking lands back on the same BAS account and
// the row's enabled flag flips off in the mirror) unless a checked
// account wants it, in which case they yield. Without this, moving
// 1930 from the wrong bank account to the right one was impossible:
// the picker hides the ledger dropdown for unchecked rows, the
// unchecked row still counted as a claim, and disconnect + reconnect
// re-claims the same rows by IBAN, so every route ended in a 400.
const yieldedUids = new Set<string>()
for (const a of updatedAccounts) {
if (enabledSet.has(a.uid)) continue
const ledger =
a.ledger_account ??
existingLedgerByUid.get(a.uid) ??
reuseRowByUid.get(a.uid)?.ledger_account
if (!ledger) {
// See neverMirroredDisabledUids above: a disabled account that has
// never held a ledger or a mirrored row gets neither allocated nor
// mirrored by this save.
neverMirroredDisabledUids.add(a.uid)
continue
}
const contested =
usedLedgers.has(ledger) ||
(foreignConnectedLedgers.has(ledger) && existingLedgerByUid.get(a.uid) !== ledger)
if (contested) {
yieldedUids.add(a.uid)
continue
}
usedLedgers.add(ledger)
effectiveLedgerByUid.set(a.uid, ledger)
}
if (duplicateLedgers.size > 0) {
return NextResponse.json(
{
@@ -1393,20 +1438,15 @@ export const enableBankingExtension: Extension = {
)
}
// Second pass: allocate a free slot for accounts with no ledger at all
// (legacy connections mirrored before allocation existed, or mappings
// explicitly cleared). Allocation failure must never block selection
// save — fall back to the pre-allocator behavior (1930) and let the
// mirror pass surface any collision per-account, as before.
// Second pass: allocate a free slot for CHECKED accounts with no ledger
// at all (legacy connections mirrored before allocation existed, or
// mappings explicitly cleared). Unchecked accounts were all settled
// above: kept, yielded, or never mirrored. Allocation failure must
// never block selection save — fall back to the pre-allocator behavior
// (1930) and let the 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
}
if (!enabledSet.has(a.uid) || effectiveLedgerByUid.has(a.uid)) continue
let allocated: string | null = null
try {
const resolved = await resolvePsd2LedgerAccount(supabase, companyId, user.id, {
@@ -1431,11 +1471,70 @@ export const enableBankingExtension: Extension = {
// accounts_data mirrors the resolved assignment so the picker
// pre-fills reality on the next open. Skipped disabled accounts keep
// no assignment: their slot is only claimed if they are ever enabled.
// Yielded accounts lose theirs: the picker must not pre-fill a ledger
// that now belongs to another account.
for (const a of updatedAccounts) {
if (neverMirroredDisabledUids.has(a.uid)) continue
if (yieldedUids.has(a.uid)) {
delete a.ledger_account
continue
}
a.ledger_account = effectiveLedgerByUid.get(a.uid)
}
// Release pass: rows that hold a ledger a checked account is about to
// take must stop being PSD2-bound first, or the mirror's upsert trips
// UNIQUE (company_id, ledger_account) and the failure is swallowed
// per-account (accounts_data ahead of cash_accounts). Demoting them to
// manual (bank_connection_id/external_uid NULL) keeps the row id, so
// transactions.cash_account_id links and the ledger's history stay
// put, and upsertFromPsd2 then promotes the manual holder in place for
// the claimant. Three kinds of holder are released: this connection's
// own row for an account that yielded (unchecked) or moved (two
// checked accounts swapping ledgers), and another connection's row for
// an account unchecked there. Manual and revoked holders are already
// promotable; a live foreign holder was rejected above.
const claimantByLedger = new Map<string, string>()
for (const a of updatedAccounts) {
if (!enabledSet.has(a.uid)) continue
const ledger = effectiveLedgerByUid.get(a.uid)
if (ledger) claimantByLedger.set(ledger, a.uid)
}
const releaseRowIds = cashRows
.filter(r => {
const claimant = claimantByLedger.get(r.ledger_account)
if (!claimant) return false
if (r.bank_connection_id === null) return false
if (revokedConnectionIds.has(r.bank_connection_id)) return false
// The claimant's own row under a stale uid (re-authorization
// changed the provider ids): promoted via reuse, not released.
if (reuseRowByUid.get(claimant)?.id === r.id) return false
if (r.bank_connection_id === connection.id) return r.external_uid !== claimant
return r.enabled === false
})
.map(r => r.id)
if (releaseRowIds.length > 0) {
const { error: releaseError } = await supabase
.from('cash_accounts')
.update({ bank_connection_id: null, external_uid: null })
.in('id', releaseRowIds)
if (releaseError) {
// Nothing persisted yet (accounts_data is written below): fail
// loudly rather than save a selection the mirror cannot honor.
log.error('[enable-banking] Failed to release ledger claims on selection save', {
errorMessage: releaseError.message,
connectionId: connection.id,
releaseRowIds,
userId: user.id,
companyId,
})
return NextResponse.json(
{ error: 'Kunde inte frigöra bokföringskontot från det tidigare bankkontot. Försök igen.' },
{ status: 500 }
)
}
}
// State machine: only transition pending_selection → active. Once
// active, the status field is omitted from the update so the same
// endpoint can be reused to change account selection without
@@ -1469,7 +1568,9 @@ export const enableBankingExtension: Extension = {
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
// Yielded accounts have no ledger any more; their old row was
// released above and is promoted by the claimant's upsert.
if (neverMirroredDisabledUids.has(a.uid) || yieldedUids.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