Files
accounted/lib/cash-accounts/service.ts
T
Jakob WennbergandClaude Fable 5 2c5e1ce317 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>
2026-07-09 21:10:37 +02:00

547 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { SupabaseClient } from '@supabase/supabase-js'
import type { CashAccount, CashAccountSource } from '@/types'
import { createLogger } from '@/lib/logger'
import { syncMappedAccounts } from '@/lib/import/account-sync'
const log = createLogger('cash-accounts')
/**
* Suggested BAS account per currency. Single source — the enable-banking
* callback and the AccountPickerDialog both key off these.
*/
export const CURRENCY_LEDGER_DEFAULTS: Record<string, string> = {
SEK: '1930',
EUR: '1932',
USD: '1933',
GBP: '1934',
}
export function defaultLedgerForCurrency(currency: string): string {
return CURRENCY_LEDGER_DEFAULTS[currency.toUpperCase()] ?? '1930'
}
/**
* Canonical read/write surface for cash_accounts.
*
* Replaces ad-hoc reads of bank_connections.accounts_data for routing decisions.
* UI panels that just display balances may still read accounts_data until the
* follow-up migration drops that column.
*
* All methods accept an authenticated SupabaseClient and rely on RLS for tenancy
* isolation. Defense-in-depth filter by company_id is applied regardless.
*/
export interface ListCashAccountsOptions {
enabledOnly?: boolean
}
export interface UpsertFromPsd2Input {
bank_connection_id: string
external_uid: string
currency: string
ledger_account: string
iban?: string | null
name?: string | null
balance?: number | null
balance_updated_at?: string | null
enabled?: boolean
}
export async function listForCompany(
supabase: SupabaseClient,
companyId: string,
opts: ListCashAccountsOptions = {},
): Promise<CashAccount[]> {
let q = supabase
.from('cash_accounts')
.select('*')
.eq('company_id', companyId)
.order('is_primary', { ascending: false })
.order('ledger_account', { ascending: true })
if (opts.enabledOnly) q = q.eq('enabled', true)
const { data, error } = await q
if (error) {
log.error('listForCompany failed', { companyId, error: error.message })
return []
}
return (data ?? []) as CashAccount[]
}
/**
* Primary cash account for a company. Filters by currency when provided. Falls
* back to the global primary (`is_primary = true`) when no currency-specific
* match exists.
*
* Used by skattekonto-booking's __PRIMARY_SEK__ sentinel and by transfer-pairing
* to identify the company's default settlement account.
*/
export async function getPrimary(
supabase: SupabaseClient,
companyId: string,
currency?: string,
): Promise<CashAccount | null> {
let q = supabase
.from('cash_accounts')
.select('*')
.eq('company_id', companyId)
.eq('is_primary', true)
.limit(1)
if (currency) q = q.eq('currency', currency.toUpperCase())
const { data, error } = await q.maybeSingle()
if (error) {
log.warn('getPrimary failed', { companyId, currency, error: error.message })
}
if (data) return data as CashAccount
if (currency) {
// Fall back to any-currency primary so a company without a SEK account still
// resolves the sentinel: rare but possible (manual cash-on-hand only).
const { data: anyPrimary } = await supabase
.from('cash_accounts')
.select('*')
.eq('company_id', companyId)
.eq('is_primary', true)
.maybeSingle()
if (anyPrimary) return anyPrimary as CashAccount
}
return null
}
export async function findByIban(
supabase: SupabaseClient,
companyId: string,
iban: string,
): Promise<CashAccount | null> {
if (!iban) return null
const { data, error } = await supabase
.from('cash_accounts')
.select('*')
.eq('company_id', companyId)
.eq('iban', iban)
.maybeSingle()
if (error) {
log.warn('findByIban failed', { companyId, iban, error: error.message })
return null
}
return (data as CashAccount | null) ?? null
}
/**
* Of the given bank_connection ids, return the subset whose connection row has
* status 'revoked'. A revoked connection no longer holds a live claim on its
* cash_accounts rows: the allocator, the picker-save collision guard, and
* upsertFromPsd2's promote-in-place path all treat those rows like manual
* holders so a reconnect can land back on its original ledger account.
*
* On lookup failure this returns an empty set (treat every connection as
* active): the conservative pre-fix behavior.
*/
export async function getRevokedConnectionIds(
supabase: SupabaseClient,
companyId: string,
connectionIds: readonly string[],
): Promise<Set<string>> {
if (connectionIds.length === 0) return new Set()
const { data, error } = await supabase
.from('bank_connections')
.select('id, status')
.eq('company_id', companyId)
.in('id', [...connectionIds])
if (error) {
log.warn('getRevokedConnectionIds lookup failed', { companyId, error: error.message })
return new Set()
}
return new Set(
((data ?? []) as Array<{ id: string; status: string }>)
.filter(c => c.status === 'revoked')
.map(c => c.id),
)
}
/**
* Find a free BAS class-19 slot for a new PSD2 cash account, respecting the
* UNIQUE (company_id, ledger_account) constraint. A bank returning N
* same-currency accounts must not map them all to the currency default —
* that's exactly the collision this prevents.
*
* Rules:
* - The currency default (1930/1932/1933/1934) is available when no
* PSD2-backed row holds it. A manual holder (the seeded 1930 row) does
* not block it — upsertFromPsd2 promotes that row in place.
* Rows held by a REVOKED connection count as manual too: disconnecting a
* bank releases its ledger claims, so reconnecting the same bank gets its
* original slot back instead of overflowing to 1939.
* - Overflow walks the free-use 1931–1959 sub-account slots, skipping the
* four currency defaults (reserved as suggestions for their currencies)
* and any slot held by ANY existing row — promoting an unrelated manual
* account (SIE-imported, kassa) would silently steal it.
* - `exclude` carries slots already assigned earlier in the caller's loop
* but not yet visible in the table.
*
* Returns null when no slot is free (or the lookup fails) — callers fall back
* to their previous behavior and surface the error.
*/
export async function findFreeLedgerAccount(
supabase: SupabaseClient,
companyId: string,
currency: string,
exclude: ReadonlySet<string> = new Set(),
): Promise<string | null> {
const preferred = defaultLedgerForCurrency(currency)
const { data: rows, error } = await supabase
.from('cash_accounts')
.select('ledger_account, bank_connection_id')
.eq('company_id', companyId)
if (error) {
log.error('findFreeLedgerAccount lookup failed', { companyId, error: error.message })
return null
}
const typedRows = (rows ?? []) as Array<{ ledger_account: string; bank_connection_id: string | null }>
const revokedConnectionIds = await getRevokedConnectionIds(
supabase,
companyId,
[...new Set(typedRows.map(r => r.bank_connection_id).filter((id): id is string => id !== null))],
)
const anyTaken = new Set<string>()
const connectedTaken = new Set<string>()
for (const row of typedRows) {
anyTaken.add(row.ledger_account)
if (row.bank_connection_id !== null && !revokedConnectionIds.has(row.bank_connection_id)) {
connectedTaken.add(row.ledger_account)
}
}
if (!exclude.has(preferred) && !connectedTaken.has(preferred)) return preferred
const reserved = new Set(Object.values(CURRENCY_LEDGER_DEFAULTS))
for (let n = 1931; n <= 1959; n++) {
const candidate = String(n)
if (reserved.has(candidate)) continue
if (exclude.has(candidate) || anyTaken.has(candidate)) continue
return candidate
}
log.warn('findFreeLedgerAccount exhausted 1931–1959', { companyId, currency })
return null
}
/**
* Allocate a ledger slot for a new PSD2 account AND make sure that account
* number exists in the company's chart of accounts — cash_accounts has no FK
* to the chart, but booking (and the AccountPicker, which only lists chart
* accounts) breaks on numbers the chart doesn't know. Sub-accounts outside
* the BAS reference (1931, …) are created with metadata derived from the
* account number; standard numbers get their BAS name.
*/
export async function allocatePsd2LedgerAccount(
supabase: SupabaseClient,
companyId: string,
userId: string,
input: { currency: string; accountName?: string | null; exclude?: ReadonlySet<string> },
): Promise<string | null> {
const ledger = await findFreeLedgerAccount(supabase, companyId, input.currency, input.exclude ?? new Set())
if (!ledger) return null
const name = input.accountName?.trim() || `Bankkonto ${input.currency.toUpperCase()}`
const sync = await syncMappedAccounts(
supabase,
companyId,
userId,
[
{
sourceAccount: ledger,
sourceName: name,
targetAccount: ledger,
targetName: name,
confidence: 1,
matchType: 'exact',
isOverride: false,
},
],
false,
)
if (sync.error) {
log.error('allocatePsd2LedgerAccount chart sync failed', {
companyId,
ledger,
error: sync.error,
})
return null
}
return ledger
}
/**
* Upsert a PSD2-sourced cash account during connection callback / sync. Keyed on
* (company_id, bank_connection_id, external_uid). When the row exists, balance
* and ledger_account are refreshed; the rest of the metadata stays put.
*
* Never sets is_primary: that's owned by the user via the AccountPicker or by
* the initial-backfill migration.
*/
export async function upsertFromPsd2(
supabase: SupabaseClient,
companyId: string,
input: UpsertFromPsd2Input,
): Promise<void> {
const payload = {
company_id: companyId,
bank_connection_id: input.bank_connection_id,
external_uid: input.external_uid,
iban: input.iban ?? null,
name: input.name ?? null,
currency: input.currency.toUpperCase(),
ledger_account: input.ledger_account,
balance: input.balance ?? null,
balance_updated_at: input.balance_updated_at ?? null,
enabled: input.enabled ?? true,
source: 'enable_banking' as CashAccountSource,
}
// create_company_with_owner and the seed_default_cash_account migration plant
// a manual (bank_connection_id IS NULL) row on the same ledger_account so
// reconciliation routes work before any PSD2 connection exists, and the
// disconnect handler demotes a revoked connection's rows to manual the same
// way. Rows still pointing at a REVOKED connection (orphans from before the
// disconnect handler released claims) no longer hold a live claim either.
// In all three cases the PSD2 sync claiming that BAS slot has to promote the
// holder row in place: a plain upsert on (company_id, bank_connection_id,
// external_uid) wouldn't match it and the INSERT path then trips the
// (company_id, ledger_account) UNIQUE constraint. Promoting (instead of
// inserting) keeps the row id stable so transactions.cash_account_id links
// and the ledger's history stay attached.
const { data: holderRow, error: holderLookupError } = await supabase
.from('cash_accounts')
.select('id, bank_connection_id')
.eq('company_id', companyId)
.eq('ledger_account', input.ledger_account)
.maybeSingle()
if (holderLookupError) {
log.error('upsertFromPsd2 holder lookup failed', {
companyId,
bankConnectionId: input.bank_connection_id,
externalUid: input.external_uid,
error: holderLookupError.message,
})
throw new Error(`cash_accounts upsert failed: ${holderLookupError.message}`)
}
const typedHolder = holderRow as { id: string; bank_connection_id: string | null } | null
let promotableRowId: string | null = null
if (typedHolder) {
if (typedHolder.bank_connection_id === null) {
promotableRowId = typedHolder.id
} else if (typedHolder.bank_connection_id !== input.bank_connection_id) {
const revoked = await getRevokedConnectionIds(supabase, companyId, [
typedHolder.bank_connection_id,
])
if (revoked.has(typedHolder.bank_connection_id)) {
promotableRowId = typedHolder.id
}
}
// Holder owned by the input connection itself (or by another ACTIVE
// connection): fall through to the plain upsert. For the former the upsert
// matches on (company_id, bank_connection_id, external_uid) and updates in
// place; for the latter the UNIQUE constraint rejects the write and the
// error surfaces to the caller (the picker-save collision guard should
// have caught it earlier).
}
if (promotableRowId) {
// Promoting the holder makes it THE row for this (bank_connection_id,
// external_uid). If this connection + uid already has a row on another
// ledger (the reconnect callback mirrored it onto an overflow slot while
// the target slot was still wrongly blocked by a revoked connection), that
// duplicate must be resolved first or the promote trips the UNIQUE
// (company_id, bank_connection_id, external_uid) constraint.
const { data: ownRow, error: ownLookupError } = await supabase
.from('cash_accounts')
.select('id, is_primary')
.eq('company_id', companyId)
.eq('bank_connection_id', input.bank_connection_id)
.eq('external_uid', input.external_uid)
.neq('id', promotableRowId)
.maybeSingle()
if (ownLookupError) {
log.error('upsertFromPsd2 duplicate lookup failed', {
companyId,
bankConnectionId: input.bank_connection_id,
externalUid: input.external_uid,
error: ownLookupError.message,
})
throw new Error(`cash_accounts upsert failed: ${ownLookupError.message}`)
}
const typedOwn = ownRow as { id: string; is_primary: boolean } | null
let transferPrimary = false
if (typedOwn) {
// With linked transactions the duplicate is demoted to a plain manual
// row (deleting it would SET NULL those transactions' cash_account_id
// links). Without any, it is a leftover mirror from the broken reconnect
// and is deleted outright so its overflow slot frees up.
const { data: linkedTx, error: linkedTxError } = await supabase
.from('transactions')
.select('id')
.eq('company_id', companyId)
.eq('cash_account_id', typedOwn.id)
.limit(1)
if (linkedTxError) {
log.error('upsertFromPsd2 duplicate transaction check failed', {
companyId,
bankConnectionId: input.bank_connection_id,
externalUid: input.external_uid,
error: linkedTxError.message,
})
throw new Error(`cash_accounts upsert failed: ${linkedTxError.message}`)
}
if ((linkedTx ?? []).length > 0) {
const { error: demoteError } = await supabase
.from('cash_accounts')
.update({ bank_connection_id: null, external_uid: null })
.eq('id', typedOwn.id)
if (demoteError) {
throw new Error(`cash_accounts upsert failed: ${demoteError.message}`)
}
} else {
const { error: deleteError } = await supabase
.from('cash_accounts')
.delete()
.eq('id', typedOwn.id)
if (deleteError) {
throw new Error(`cash_accounts upsert failed: ${deleteError.message}`)
}
}
// A primary duplicate must hand the flag to the promoted row either way:
// deleted, it would leave the __PRIMARY_SEK__ sentinel unresolvable;
// demoted, the sentinel would keep resolving to the stale manual row.
transferPrimary = typedOwn.is_primary
}
// .select() so we can detect a 0-row UPDATE: Supabase's update().eq() returns
// { error: null, data: [] } if the row was deleted between the SELECT above
// and this UPDATE (rare but theoretically possible under concurrent ops).
// If that happens, fall through to the normal upsert path instead of
// silently returning success without persisting anything.
const { data: promoted, error: promoteError } = await supabase
.from('cash_accounts')
.update(payload)
.eq('id', promotableRowId)
.select('id')
if (promoteError) {
log.error('upsertFromPsd2 promote-holder failed', {
companyId,
bankConnectionId: input.bank_connection_id,
externalUid: input.external_uid,
error: promoteError.message,
})
throw new Error(`cash_accounts upsert failed: ${promoteError.message}`)
}
if (promoted && promoted.length > 0) {
if (transferPrimary) {
try {
await setPrimary(supabase, companyId, promotableRowId)
} catch (primaryError) {
// The promote itself succeeded; losing the primary flag is
// recoverable via the AccountPicker, so log instead of unwinding.
log.error('upsertFromPsd2 primary transfer failed', {
companyId,
cashAccountId: promotableRowId,
error: primaryError instanceof Error ? primaryError.message : String(primaryError),
})
}
}
return
}
// Holder row vanished between SELECT and UPDATE: fall through to upsert.
}
const { error } = await supabase
.from('cash_accounts')
.upsert(payload, { onConflict: 'company_id,bank_connection_id,external_uid' })
if (error) {
log.error('upsertFromPsd2 failed', {
companyId,
bankConnectionId: input.bank_connection_id,
externalUid: input.external_uid,
error: error.message,
})
throw new Error(`cash_accounts upsert failed: ${error.message}`)
}
}
/**
* Toggle a cash account's enabled flag. Used by the AccountPicker when a user
* opts in or out of syncing a particular PSD2 account.
*/
export async function setEnabled(
supabase: SupabaseClient,
companyId: string,
cashAccountId: string,
enabled: boolean,
): Promise<void> {
const { error } = await supabase
.from('cash_accounts')
.update({ enabled })
.eq('company_id', companyId)
.eq('id', cashAccountId)
if (error) throw new Error(`cash_accounts setEnabled failed: ${error.message}`)
}
/**
* Remap a cash account to a different BAS ledger account. Triggers RLS + the
* (company_id, ledger_account) UNIQUE constraint: surface conflict errors so
* the UI can prompt the user to resolve.
*/
export async function setLedgerAccount(
supabase: SupabaseClient,
companyId: string,
cashAccountId: string,
ledgerAccount: string,
): Promise<void> {
const { error } = await supabase
.from('cash_accounts')
.update({ ledger_account: ledgerAccount })
.eq('company_id', companyId)
.eq('id', cashAccountId)
if (error) throw new Error(`cash_accounts setLedgerAccount failed: ${error.message}`)
}
/**
* Mark a cash account as the primary for its company. Delegates to the
* `set_cash_account_primary` RPC so the clear-old-primary and set-new-primary
* updates happen inside a single transaction. The intermediate "no primary"
* state is never visible to concurrent readers: important because
* skattekonto-booking's __PRIMARY_SEK__ resolver runs through getPrimary() and
* would otherwise see null in the gap and mis-route the counter account.
*/
export async function setPrimary(
supabase: SupabaseClient,
companyId: string,
cashAccountId: string,
): Promise<void> {
const { error } = await supabase.rpc('set_cash_account_primary', {
p_company_id: companyId,
p_cash_account_id: cashAccountId,
})
if (error) {
throw new Error(`cash_accounts setPrimary failed: ${error.message}`)
}
}