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>
547 lines
19 KiB
TypeScript
547 lines
19 KiB
TypeScript
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}`)
|
||
}
|
||
}
|