Files
accounted/lib/cash-accounts/__tests__/service.test.ts
T
MattssonandClaude Fable 5 cd40127f0e feat(bank): expose bank-reported balance (booked + available) in UI, reconciliation, MCP and v1 API (#2118)
* feat(bank): expose bank-reported balance (booked + available) in UI, reconciliation, MCP and v1 API

The PSD2 sync has fetched the bank's reported balance for years but the
data was stranded (F7): the Bank-page source picker read a cash_accounts
column no sync ever updated (frozen at connect time), reconciliation
hard-coded external_balance to null for bank accounts, and neither MCP
nor the v1 API exposed any balance at all, so the only path to a current
bank balance was logging into the bank.

- getAccountBalance now returns booked + available from the same
  quota-limited BALANCES response (previously all but one type discarded)
- every sync (manual + cron) mirrors balance, available_balance and
  balance_updated_at into cash_accounts, fixing the stale picker
- new cash_accounts.available_balance column (additive migration)
- reconciliation bank kind: external_balance = bank-reported balance,
  plus bank_reported_* fields and fetch timestamp in the bank block;
  difference math stays movement-based and untouched
- reconciliation view shows "Saldo enligt banken ... hamtat {date}"
- MCP gnubok_list_cash_accounts returns the three balance fields; the
  cash_today prompt now reports the bank's figure instead of teaching
  agents to answer with the bookkept 19xx balance
- new GET /api/v1/companies/{companyId}/cash-accounts endpoint

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

* fix(bank): keep external_balance null for bank sign-offs; never fabricate a zero balance; guard the mirror against stale writers

Post-review fixes from the skeptic pass + CodeRabbit on PR #2118:

- external_balance stays null for the bank reconciliation kind: sign-off
  persists it into account_reconciliations and bokslutsbilagor computes
  closing - external from that row, so a today-balance stored on a
  balansdag sign-off printed a phantom warning-red differens in the
  year-end appendix. The bank-reported figure lives only in the
  timestamped bank_reported_* pair in the bank block, and only when its
  fetch timestamp exists (a balance of unknown age is suppressed).
- AccountOverview no longer falls back to today's date when the balance
  timestamp is missing; the line is omitted instead.
- getAccountBalance returns null on an empty BALANCES response instead
  of fabricating amount 0 with a fresh timestamp; sync keeps the
  previous stored value.
- updateBalancesFromSync only writes over an older-or-missing
  balance_updated_at, so an older sync run finishing later cannot move
  the mirrored balance backwards.
- The inline initial backfill (picker save) now mirrors fetched
  balances into cash_accounts too (accounts_data is deliberately not
  re-written there).
- cash_today MCP prompt mentions the gnubok_call_tool bridge for hosts
  that only see the default catalog.

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

* fix(bank): express the stale-writer guard as two literal predicates for the schema guard

The .or() with a template literal pushed the no-phantom-columns
unresolvable-expression count over its ceiling. Same semantics, two
updates: one for rows with an older timestamp, one for rows with none.

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

* fix(bank): rank interimBooked (ITBD) as a booked balance type before the generic fallback

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 16:16:29 +02:00

1317 lines
50 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 { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
// syncMappedAccounts is exercised by its own suite — here it only needs to be
// observable so allocatePsd2LedgerAccount's chart-ensure call can be asserted.
const { mockSyncMappedAccounts } = vi.hoisted(() => ({
mockSyncMappedAccounts: vi.fn(),
}))
vi.mock('@/lib/import/account-sync', () => ({
syncMappedAccounts: (...args: unknown[]) => mockSyncMappedAccounts(...args),
}))
import {
findFreeLedgerAccount,
allocatePsd2LedgerAccount,
resolvePsd2LedgerAccount,
normalizeIban,
defaultLedgerForCurrency,
getRevokedConnectionIds,
upsertFromPsd2,
updateBalancesFromSync,
ensureManualCashAccount,
} from '../service'
type CashRow = {
ledger_account: string
bank_connection_id: string | null
id?: string
iban?: string | null
}
type ConnRow = { id: string; status: string }
interface MakeSupabaseOpts {
error?: { message: string } | null
/** bank_connections rows for the status lookup. Missing ids = not revoked. */
connections?: ConnRow[]
connectionsError?: { message: string } | null
/** 19xx account numbers already present in the company's chart. */
chart?: string[]
chartError?: { message: string } | null
}
/**
* Thenable query stub: PostgREST chains terminate on await, not on a fixed
* method, so the same object has to answer .eq()/.not()/.like() and still
* resolve when awaited. Without this a chain that ends in .not() (the IBAN
* lookup) cannot share a mock with one that ends in .eq().
*/
function chainable(result: { data: unknown; error: unknown }) {
const chain: Record<string, unknown> = {}
for (const method of ['select', 'eq', 'neq', 'not', 'is', 'like', 'in', 'order', 'limit']) {
chain[method] = vi.fn(() => chain)
}
chain.then = (onFulfilled: (value: unknown) => unknown) =>
Promise.resolve(result).then(onFulfilled)
chain.maybeSingle = vi.fn(() => Promise.resolve(result))
return chain
}
function makeSupabase(rows: CashRow[], opts: MakeSupabaseOpts = {}) {
return {
from: vi.fn((table: string) => {
if (table === 'bank_connections') {
return {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
in: vi.fn((_col: string, ids: string[]) =>
Promise.resolve(
opts.connectionsError
? { data: null, error: opts.connectionsError }
: {
data: (opts.connections ?? []).filter(c => ids.includes(c.id)),
error: null,
},
),
),
}
}
if (table === 'chart_of_accounts') {
return chainable(
opts.chartError
? { data: null, error: opts.chartError }
: { data: (opts.chart ?? []).map(n => ({ account_number: n })), error: null },
)
}
return chainable({
data: opts.error ? null : rows,
error: opts.error ?? null,
})
}),
} as unknown as SupabaseClient
}
beforeEach(() => {
vi.clearAllMocks()
mockSyncMappedAccounts.mockResolvedValue({
created: 1,
renamed: 0,
renamedAccounts: [],
renameFailed: 0,
error: null,
})
})
describe('defaultLedgerForCurrency', () => {
it('maps the four known currencies and falls back to 1930', () => {
expect(defaultLedgerForCurrency('SEK')).toBe('1930')
expect(defaultLedgerForCurrency('eur')).toBe('1932')
expect(defaultLedgerForCurrency('USD')).toBe('1933')
expect(defaultLedgerForCurrency('GBP')).toBe('1934')
expect(defaultLedgerForCurrency('NOK')).toBe('1930')
})
})
describe('getRevokedConnectionIds', () => {
it('returns only the ids whose connection is revoked', async () => {
const supabase = makeSupabase([], {
connections: [
{ id: 'conn-a', status: 'revoked' },
{ id: 'conn-b', status: 'active' },
],
})
const revoked = await getRevokedConnectionIds(supabase, 'c1', ['conn-a', 'conn-b'])
expect(revoked).toEqual(new Set(['conn-a']))
})
it('returns an empty set without querying when no ids are given', async () => {
const supabase = makeSupabase([])
const revoked = await getRevokedConnectionIds(supabase, 'c1', [])
expect(revoked.size).toBe(0)
expect((supabase as unknown as { from: ReturnType<typeof vi.fn> }).from).not.toHaveBeenCalled()
})
it('treats every connection as active when the lookup fails (conservative)', async () => {
const supabase = makeSupabase([], { connectionsError: { message: 'boom' } })
const revoked = await getRevokedConnectionIds(supabase, 'c1', ['conn-a'])
expect(revoked.size).toBe(0)
})
})
describe('findFreeLedgerAccount', () => {
it('returns the currency default when nothing holds it', async () => {
const supabase = makeSupabase([])
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1930')
expect(await findFreeLedgerAccount(supabase, 'c1', 'EUR')).toBe('1932')
})
it('returns the default when only a MANUAL row holds it (seed promotion)', async () => {
// The seeded 1930 row has no bank connection — upsertFromPsd2 promotes it
// in place, so the slot counts as free.
const supabase = makeSupabase([{ ledger_account: '1930', bank_connection_id: null }])
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1930')
})
it('returns the default when it is held only by a REVOKED connection (issue #916)', async () => {
// Disconnecting a bank releases its ledger claims. Rows orphaned before
// that fix still point at the revoked connection; they must count as
// manual holders so a reconnect lands back on 1930, not 1939.
const supabase = makeSupabase(
[{ ledger_account: '1930', bank_connection_id: 'conn-revoked' }],
{ connections: [{ id: 'conn-revoked', status: 'revoked' }] },
)
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1930')
})
it('overflows to 1931 when a CONNECTED row holds the default', async () => {
const supabase = makeSupabase([{ ledger_account: '1930', bank_connection_id: 'conn-1' }], {
connections: [{ id: 'conn-1', status: 'active' }],
})
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1931')
})
it('still overflows when the revoked-status lookup fails (conservative)', async () => {
const supabase = makeSupabase(
[{ ledger_account: '1930', bank_connection_id: 'conn-revoked' }],
{ connectionsError: { message: 'boom' } },
)
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1931')
})
it('keeps revoked-held rows blocking OVERFLOW slots (like manual rows)', async () => {
// The revoked-held row on 1931 keeps its history on that slot; handing the
// slot to a different account would steal it via promote-in-place.
const supabase = makeSupabase(
[
{ ledger_account: '1930', bank_connection_id: 'conn-active' },
{ ledger_account: '1931', bank_connection_id: 'conn-revoked' },
],
{
connections: [
{ id: 'conn-active', status: 'active' },
{ id: 'conn-revoked', status: 'revoked' },
],
},
)
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1935')
})
it('never hands out another currency default as an overflow slot', async () => {
const supabase = makeSupabase([
{ ledger_account: '1930', bank_connection_id: 'conn-1' },
{ ledger_account: '1931', bank_connection_id: 'conn-1' },
])
// 1932/1933/1934 are reserved for EUR/USD/GBP — next free is 1935.
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1935')
})
it('does not steal a manual account on an overflow slot', async () => {
const supabase = makeSupabase([
{ ledger_account: '1930', bank_connection_id: 'conn-1' },
// Manual (e.g. SIE-imported) account on 1931 — promoting it would
// silently repoint an unrelated account.
{ ledger_account: '1931', bank_connection_id: null },
])
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1935')
})
it('honors the exclude set for slots assigned earlier in the caller loop', async () => {
const supabase = makeSupabase([])
const exclude = new Set(['1930', '1931'])
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK', exclude)).toBe('1935')
})
it('returns null when every slot in 1931–1959 is taken', async () => {
const rows: CashRow[] = [{ ledger_account: '1930', bank_connection_id: 'conn-1' }]
for (let n = 1931; n <= 1959; n++) {
rows.push({ ledger_account: String(n), bank_connection_id: 'conn-1' })
}
const supabase = makeSupabase(rows)
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBeNull()
})
it('returns null when the lookup fails', async () => {
const supabase = makeSupabase([], { error: { message: 'boom' } })
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBeNull()
})
})
describe('allocatePsd2LedgerAccount', () => {
it('allocates a slot and ensures it exists in the chart of accounts', async () => {
const supabase = makeSupabase([{ ledger_account: '1930', bank_connection_id: 'conn-1' }])
const ledger = await allocatePsd2LedgerAccount(supabase, 'c1', 'u1', {
currency: 'SEK',
accountName: 'Sparkonto',
})
expect(ledger).toBe('1931')
expect(mockSyncMappedAccounts).toHaveBeenCalledTimes(1)
const [, companyId, userId, mappings] = mockSyncMappedAccounts.mock.calls[0]
expect(companyId).toBe('c1')
expect(userId).toBe('u1')
// The chart account gets a BAS-style name, never the bank-reported account
// name: ASPSPs report the account HOLDER (the company) as the name, and
// every failed reconnect used to persist another 19xx chart account named
// after the company (issue #1643 problem 3).
expect(mappings).toEqual([
expect.objectContaining({
sourceAccount: '1931',
targetAccount: '1931',
sourceName: 'Bankkonto SEK',
}),
])
})
it('uses the BAS reference name when the allocated slot is a standard account', async () => {
const supabase = makeSupabase([{ ledger_account: '1930', bank_connection_id: 'conn-1' }])
// Every free-use slot below 1940 is already assigned earlier in the
// caller's loop, so the allocator lands on 1940 Övriga bankkonton.
const exclude = new Set(['1931', '1935', '1936', '1937', '1938', '1939'])
const ledger = await allocatePsd2LedgerAccount(supabase, 'c1', 'u1', {
currency: 'SEK',
accountName: 'Arcim AB',
exclude,
})
expect(ledger).toBe('1940')
const [, , , mappings] = mockSyncMappedAccounts.mock.calls[0]
expect(mappings).toEqual([
expect.objectContaining({
sourceAccount: '1940',
targetAccount: '1940',
sourceName: 'Övriga bankkonton',
targetName: 'Övriga bankkonton',
}),
])
})
it('names the chart account after the currency regardless of accountName', async () => {
const supabase = makeSupabase([])
await allocatePsd2LedgerAccount(supabase, 'c1', 'u1', { currency: 'EUR' })
const [, , , mappings] = mockSyncMappedAccounts.mock.calls[0]
expect(mappings[0].sourceName).toBe('Bankkonto EUR')
})
it('returns null when the chart sync fails — a slot that cannot be booked against is useless', async () => {
mockSyncMappedAccounts.mockResolvedValue({
created: 0,
renamed: 0,
renamedAccounts: [],
renameFailed: 0,
error: 'chart unavailable',
})
const supabase = makeSupabase([])
expect(
await allocatePsd2LedgerAccount(supabase, 'c1', 'u1', { currency: 'SEK' }),
).toBeNull()
})
it('returns null when no slot is free', async () => {
const rows: CashRow[] = [{ ledger_account: '1930', bank_connection_id: 'conn-1' }]
for (let n = 1931; n <= 1959; n++) {
rows.push({ ledger_account: String(n), bank_connection_id: 'conn-1' })
}
const supabase = makeSupabase(rows)
expect(
await allocatePsd2LedgerAccount(supabase, 'c1', 'u1', { currency: 'SEK' }),
).toBeNull()
expect(mockSyncMappedAccounts).not.toHaveBeenCalled()
})
})
describe('findFreeLedgerAccount: chart awareness', () => {
it('skips an overflow slot that already names a bank account in the chart', async () => {
// A chart imported from SIE carries the company's real bank accounts
// ("1931 Nordnet") with no cash_accounts row behind them. Handing one out
// as free is how a SEK företagskonto got proposed as someone else's
// brokerage account.
const supabase = makeSupabase([{ ledger_account: '1930', bank_connection_id: 'conn-1' }], {
chart: ['1930', '1931', '1935'],
})
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1936')
})
it('still returns the currency default when the chart holds it', async () => {
// 1930 exists in every chart; that must not push the SEK account into
// overflow when no PSD2 row actually claims it.
const supabase = makeSupabase([], { chart: ['1930'] })
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1930')
})
it('falls back to a chart-occupied slot when nothing unnamed is left', async () => {
const chart: string[] = []
for (let n = 1931; n <= 1959; n++) chart.push(String(n))
const supabase = makeSupabase([{ ledger_account: '1930', bank_connection_id: 'conn-1' }], {
chart,
})
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1931')
})
it('allocates normally when the chart lookup fails', async () => {
const supabase = makeSupabase([{ ledger_account: '1930', bank_connection_id: 'conn-1' }], {
chartError: { message: 'boom' },
})
expect(await findFreeLedgerAccount(supabase, 'c1', 'SEK')).toBe('1931')
})
})
describe('normalizeIban', () => {
it('strips formatting so the same account compares equal', () => {
expect(normalizeIban('SE45 5000 0000 0583 9825 7466')).toBe('SE4550000000058398257466')
expect(normalizeIban('se4550000000058398257466')).toBe('SE4550000000058398257466')
expect(normalizeIban(null)).toBeNull()
expect(normalizeIban(' ')).toBeNull()
})
})
describe('resolvePsd2LedgerAccount', () => {
const IBAN = 'SE4550000000058398257466'
it('reuses the ledger of the row with the same IBAN instead of allocating', async () => {
// The reconnect case: the bank minted a new account uid (and possibly a
// whole new connection row), but it is the same physical account.
const supabase = makeSupabase([
{ id: 'row-1', ledger_account: '1930', bank_connection_id: 'conn-old', iban: IBAN },
])
const resolved = await resolvePsd2LedgerAccount(supabase, 'c1', 'u1', {
iban: IBAN,
currency: 'SEK',
})
expect(resolved).toEqual({
ledgerAccount: '1930',
reuseCashAccountId: 'row-1',
source: 'iban',
})
// No chart write: we are adopting an account that already exists.
expect(mockSyncMappedAccounts).not.toHaveBeenCalled()
})
it('matches on IBAN across formatting differences', async () => {
const supabase = makeSupabase([
{
id: 'row-1',
ledger_account: '1941',
bank_connection_id: 'conn-old',
iban: 'SE45 5000 0000 0583 9825 7466',
},
])
const resolved = await resolvePsd2LedgerAccount(supabase, 'c1', 'u1', {
iban: 'se4550000000058398257466',
currency: 'EUR',
})
expect(resolved?.ledgerAccount).toBe('1941')
expect(resolved?.source).toBe('iban')
})
it('reuses even when the previous holder connection is still active', async () => {
// The bank killed the old session without telling us, so the old row still
// reads as a live claim. One IBAN is one account: the connection that just
// authorized owns it.
const supabase = makeSupabase(
[{ id: 'row-1', ledger_account: '1930', bank_connection_id: 'conn-old', iban: IBAN }],
{ connections: [{ id: 'conn-old', status: 'active' }] },
)
const resolved = await resolvePsd2LedgerAccount(supabase, 'c1', 'u1', {
iban: IBAN,
currency: 'SEK',
})
expect(resolved?.ledgerAccount).toBe('1930')
expect(resolved?.reuseCashAccountId).toBe('row-1')
})
it('allocates when the IBAN is unknown', async () => {
const supabase = makeSupabase([
{ id: 'row-1', ledger_account: '1930', bank_connection_id: 'conn-1', iban: 'SE9999' },
])
const resolved = await resolvePsd2LedgerAccount(supabase, 'c1', 'u1', {
iban: IBAN,
currency: 'SEK',
})
expect(resolved?.source).toBe('allocated')
expect(resolved?.reuseCashAccountId).toBeNull()
expect(resolved?.ledgerAccount).toBe('1931')
})
it('allocates when the IBAN match was already claimed earlier in the loop', async () => {
// Two accounts cannot share a ledger: the UNIQUE (company_id,
// ledger_account) constraint would reject the second write.
const supabase = makeSupabase([
{ id: 'row-1', ledger_account: '1930', bank_connection_id: null, iban: IBAN },
])
const resolved = await resolvePsd2LedgerAccount(supabase, 'c1', 'u1', {
iban: IBAN,
currency: 'SEK',
exclude: new Set(['1930']),
})
expect(resolved?.source).toBe('allocated')
expect(resolved?.ledgerAccount).not.toBe('1930')
})
it('allocates for an account the bank gave no IBAN for', async () => {
const supabase = makeSupabase([])
const resolved = await resolvePsd2LedgerAccount(supabase, 'c1', 'u1', {
iban: null,
currency: 'SEK',
})
expect(resolved).toEqual({
ledgerAccount: '1930',
reuseCashAccountId: null,
source: 'allocated',
})
})
})
// ---------------------------------------------------------------------------
// upsertFromPsd2: promote-in-place + duplicate merge (issue #916)
// ---------------------------------------------------------------------------
interface UpsertStub {
/** Row currently holding (company_id, ledger_account), if any. */
holder?: { id: string; bank_connection_id: string | null } | null
/** bank_connections rows for the revoked-status lookup. */
connections?: ConnRow[]
/** Existing row for (company_id, bank_connection_id, external_uid) on another ledger. */
ownRow?: { id: string; is_primary: boolean } | null
/**
* Transactions bound to the duplicate ownRow. `booked` rows carry a
* journal_entry_id (or a confirmed invoice match); `anchor` rows are bound
* to a verifikat WITHOUT journal_entry_id through the named table (bulk-book
* N>1 via transaction_voucher_links, multi-allocation via a payment row).
* The mock mutates this list as rebinds land, so the demote-or-delete probe
* sees the post-rebind state like the real table would.
*/
ownTransactions: OwnTx[]
upsertError?: { message: string } | null
/** Fail the pre-check SELECT on this anchor table. */
anchorError?: { table: AnchorTable; message: string } | null
/** Fail the rebind UPDATE on transactions. */
rebindError?: { message: string } | null
// Captured writes:
updates: Array<{ payload: Record<string, unknown>; id: unknown }>
deletes: unknown[]
upserts: Array<Record<string, unknown>>
rpcCalls: Array<{ fn: string; args: Record<string, unknown> }>
/** Filters applied to the transactions SELECTs (candidate scan + probe). */
transactionFilters: Array<{ col: string; value: unknown }>
/** Rebind UPDATEs from the overflow duplicate onto the promoted row, with their filters. */
txRebinds: Array<{ payload: Record<string, unknown>; filters: TxFilter[] }>
/** Anchor tables consulted before the rebind, the ids they were probed with and their eq filters. */
anchorProbes: Array<{ table: string; ids: string[]; filters: Array<{ col: string; value: unknown }> }>
}
type AnchorTable = 'transaction_voucher_links' | 'invoice_payments' | 'supplier_invoice_payments'
type OwnTx = { id: string; booked?: boolean; anchor?: AnchorTable }
type TxFilter = { op: 'eq' | 'is' | 'in'; col: string; value: unknown }
function makeUpsertStub(partial: Partial<UpsertStub> = {}): UpsertStub {
return {
updates: [],
deletes: [],
upserts: [],
rpcCalls: [],
transactionFilters: [],
txRebinds: [],
anchorProbes: [],
ownTransactions: [],
...partial,
}
}
function makeUpsertSupabase(stub: UpsertStub) {
return {
rpc: vi.fn((fn: string, args: Record<string, unknown>) => {
stub.rpcCalls.push({ fn, args })
return Promise.resolve({ error: null })
}),
from: vi.fn((table: string) => {
if (table === 'bank_connections') {
return {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
in: vi.fn((_col: string, ids: string[]) =>
Promise.resolve({
data: (stub.connections ?? []).filter(c => ids.includes(c.id)),
error: null,
}),
),
}
}
if (table === 'transactions') {
const selectChain = {
eq: vi.fn((col: string, value: unknown) => {
stub.transactionFilters.push({ col, value })
return selectChain
}),
is: vi.fn((col: string, value: unknown) => {
stub.transactionFilters.push({ col, value })
return selectChain
}),
order: vi.fn(() => selectChain),
// Candidate scan (movable rows): mirrors the .is(null) column gate.
range: vi.fn((from: number, to: number) =>
Promise.resolve({
data: stub.ownTransactions
.filter((t) => !t.booked)
.slice(from, to + 1)
.map((t) => ({ id: t.id })),
error: null,
}),
),
// Demote-or-delete probe: anything still bound after the rebind.
limit: vi.fn(() =>
Promise.resolve({
data: stub.ownTransactions.slice(0, 1).map((t) => ({ id: t.id })),
error: null,
}),
),
}
return {
select: vi.fn(() => selectChain),
update: vi.fn((payload: Record<string, unknown>) => {
const rebind = { payload, filters: [] as TxFilter[] }
stub.txRebinds.push(rebind)
let ids: string[] = []
const chain = {
eq: vi.fn((col: string, value: unknown) => {
rebind.filters.push({ op: 'eq', col, value })
return chain
}),
is: vi.fn((col: string, value: unknown) => {
rebind.filters.push({ op: 'is', col, value })
return chain
}),
in: vi.fn((col: string, value: string[]) => {
rebind.filters.push({ op: 'in', col, value })
ids = value
return chain
}),
select: vi.fn(() => {
if (stub.rebindError) {
return Promise.resolve({ data: null, error: stub.rebindError })
}
const moved = stub.ownTransactions.filter((t) => ids.includes(t.id) && !t.booked)
stub.ownTransactions = stub.ownTransactions.filter((t) => !moved.includes(t))
return Promise.resolve({ data: moved.map((t) => ({ id: t.id })), error: null })
}),
}
return chain
}),
}
}
if (
table === 'transaction_voucher_links' ||
table === 'invoice_payments' ||
table === 'supplier_invoice_payments'
) {
const filters: Array<{ col: string; value: unknown }> = []
const chain = {
select: vi.fn(() => chain),
eq: vi.fn((col: string, value: unknown) => {
filters.push({ col, value })
return chain
}),
in: vi.fn((_col: string, ids: string[]) => {
stub.anchorProbes.push({ table, ids, filters })
if (stub.anchorError && stub.anchorError.table === table) {
return Promise.resolve({ data: null, error: { message: stub.anchorError.message } })
}
const rows = stub.ownTransactions
.filter((t) => t.anchor === table && ids.includes(t.id))
.map((t) => ({ transaction_id: t.id }))
return Promise.resolve({ data: rows, error: null })
}),
}
return chain
}
// cash_accounts
return {
select: vi.fn((cols: string) => {
const chain = {
eq: vi.fn(() => chain),
neq: vi.fn(() => chain),
maybeSingle: vi.fn(() => {
// Holder lookup selects bank_connection_id; duplicate lookup
// selects is_primary. Route by the requested columns.
if (cols.includes('bank_connection_id')) {
return Promise.resolve({ data: stub.holder ?? null, error: null })
}
return Promise.resolve({ data: stub.ownRow ?? null, error: null })
}),
}
return chain
}),
update: vi.fn((payload: Record<string, unknown>) => ({
eq: vi.fn((_col: string, id: unknown) => {
stub.updates.push({ payload, id })
const result = Promise.resolve({ data: null, error: null })
return {
select: vi.fn(() => Promise.resolve({ data: [{ id }], error: null })),
then: result.then.bind(result),
catch: result.catch.bind(result),
}
}),
})),
delete: vi.fn(() => ({
eq: vi.fn((_col: string, id: unknown) => {
stub.deletes.push(id)
return Promise.resolve({ error: null })
}),
})),
upsert: vi.fn((payload: Record<string, unknown>) => {
stub.upserts.push(payload)
return Promise.resolve({ error: stub.upsertError ?? null })
}),
}
}),
} as unknown as SupabaseClient
}
const UPSERT_INPUT = {
bank_connection_id: 'conn-new',
external_uid: 'uid-1',
currency: 'SEK',
ledger_account: '1930',
}
describe('upsertFromPsd2', () => {
it('plain-upserts when no row holds the target ledger', async () => {
const stub = makeUpsertStub({ holder: null })
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT)
expect(stub.upserts).toHaveLength(1)
expect(stub.upserts[0]).toMatchObject({
company_id: 'c1',
bank_connection_id: 'conn-new',
external_uid: 'uid-1',
ledger_account: '1930',
})
expect(stub.updates).toHaveLength(0)
})
it('promotes a MANUAL holder row in place (seed row or demoted-on-disconnect row)', async () => {
const stub = makeUpsertStub({ holder: { id: 'row-manual', bank_connection_id: null } })
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT)
expect(stub.updates).toHaveLength(1)
expect(stub.updates[0].id).toBe('row-manual')
expect(stub.updates[0].payload).toMatchObject({
bank_connection_id: 'conn-new',
external_uid: 'uid-1',
ledger_account: '1930',
})
expect(stub.upserts).toHaveLength(0)
})
it('promotes a holder owned by a REVOKED connection (orphan self-heal, issue #916)', async () => {
const stub = makeUpsertStub({
holder: { id: 'row-old', bank_connection_id: 'conn-old' },
connections: [{ id: 'conn-old', status: 'revoked' }],
})
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT)
// The orphaned row keeps its id (transaction links survive) and is
// re-bound to the new connection on its original ledger account.
expect(stub.updates).toHaveLength(1)
expect(stub.updates[0].id).toBe('row-old')
expect(stub.updates[0].payload).toMatchObject({
bank_connection_id: 'conn-new',
external_uid: 'uid-1',
ledger_account: '1930',
})
expect(stub.upserts).toHaveLength(0)
expect(stub.deletes).toHaveLength(0)
})
it('does NOT promote a holder owned by an ACTIVE foreign connection', async () => {
const stub = makeUpsertStub({
holder: { id: 'row-other', bank_connection_id: 'conn-other' },
connections: [{ id: 'conn-other', status: 'active' }],
})
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT)
// Falls through to the plain upsert; the DB unique constraint is the
// final arbiter for a genuine conflict.
expect(stub.updates).toHaveLength(0)
expect(stub.upserts).toHaveLength(1)
})
it('promotes an IBAN-matched holder even when a live connection still holds it', async () => {
// The reconnect fix: resolvePsd2LedgerAccount matched this row by IBAN, so
// it is this account under a stale owner. Promoting keeps the row id (and
// its linked transactions) and re-points it at the new connection; without
// this the INSERT would trip the (company_id, ledger_account) constraint
// and the user's 1930 mapping would land on an overflow slot instead.
const stub = makeUpsertStub({
holder: { id: 'row-known', bank_connection_id: 'conn-old' },
connections: [{ id: 'conn-old', status: 'active' }],
})
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', {
...UPSERT_INPUT,
reuse_cash_account_id: 'row-known',
})
expect(stub.upserts).toHaveLength(0)
expect(stub.updates).toHaveLength(1)
expect(stub.updates[0].id).toBe('row-known')
expect(stub.updates[0].payload).toMatchObject({
bank_connection_id: 'conn-new',
external_uid: 'uid-1',
ledger_account: '1930',
})
})
it('ignores a reuse id that does not match the row holding the ledger', async () => {
const stub = makeUpsertStub({
holder: { id: 'row-other', bank_connection_id: 'conn-other' },
connections: [{ id: 'conn-other', status: 'active' }],
})
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', {
...UPSERT_INPUT,
reuse_cash_account_id: 'row-stale',
})
expect(stub.updates).toHaveLength(0)
expect(stub.upserts).toHaveLength(1)
})
it('routes a holder owned by the SAME connection through the plain upsert', async () => {
const stub = makeUpsertStub({
holder: { id: 'row-self', bank_connection_id: 'conn-new' },
})
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT)
expect(stub.updates).toHaveLength(0)
expect(stub.upserts).toHaveLength(1)
})
it('promotes a SAME-connection holder under a retired uid when explicitly named via reuse_cash_account_id', async () => {
// Issue #1709: in-place reconnect of a no-IBAN account whose ASPSP minted
// a new uid. The callback pairs the account with the connection's own old
// row and names it explicitly; the promote re-keys that row to the new uid
// in place, so its id (and the transactions linked to it) survive. Without
// the explicit name the plain upsert would INSERT and trip the
// (company_id, ledger_account) UNIQUE constraint; the previous test pins
// that an UNNAMED same-connection holder still routes through the plain
// upsert (the general active-holder rejection is not loosened).
const stub = makeUpsertStub({
holder: { id: 'row-self', bank_connection_id: 'conn-new' },
})
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', {
...UPSERT_INPUT,
external_uid: 'uid-2',
reuse_cash_account_id: 'row-self',
})
expect(stub.upserts).toHaveLength(0)
expect(stub.updates).toHaveLength(1)
expect(stub.updates[0].id).toBe('row-self')
expect(stub.updates[0].payload).toMatchObject({
bank_connection_id: 'conn-new',
external_uid: 'uid-2',
ledger_account: '1930',
})
})
it('deletes an empty duplicate row for the same connection+uid before promoting', async () => {
// Stuck-user recovery: the reconnect callback mirrored uid-1 onto 1939
// while 1930 was wrongly blocked. On remap to 1930 the empty 1939
// duplicate is removed and the orphaned holder is promoted, freeing 1939.
const stub = makeUpsertStub({
holder: { id: 'row-old', bank_connection_id: 'conn-old' },
connections: [{ id: 'conn-old', status: 'revoked' }],
ownRow: { id: 'row-dup', is_primary: false },
})
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT)
expect(stub.deletes).toEqual(['row-dup'])
expect(stub.txRebinds).toHaveLength(0)
expect(stub.updates).toHaveLength(1)
expect(stub.updates[0].id).toBe('row-old')
expect(stub.rpcCalls).toHaveLength(0)
})
it('rebinds linked transactions then deletes the overflow duplicate', async () => {
// The reconnect callback mirrored uid-1 onto 1931 and the sync imported
// rows there; the user then mapped the IBAN to 1930. Unbooked rows follow
// the promoted ledger so booking proposes 1930, and the emptied 1931
// mirror is removed.
const stub = makeUpsertStub({
holder: { id: 'row-old', bank_connection_id: 'conn-old' },
connections: [{ id: 'conn-old', status: 'revoked' }],
ownRow: { id: 'row-dup', is_primary: false },
ownTransactions: [{ id: 'tx-1' }, { id: 'tx-2' }],
})
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT)
expect(stub.txRebinds).toHaveLength(1)
expect(stub.txRebinds[0].payload).toEqual({ cash_account_id: 'row-old' })
// The movable gate (#1570) is re-asserted on the UPDATE itself.
expect(stub.txRebinds[0].filters).toEqual(
expect.arrayContaining([
{ op: 'eq', col: 'company_id', value: 'c1' },
{ op: 'eq', col: 'cash_account_id', value: 'row-dup' },
{ op: 'in', col: 'id', value: ['tx-1', 'tx-2'] },
{ op: 'is', col: 'journal_entry_id', value: null },
{ op: 'is', col: 'invoice_id', value: null },
{ op: 'is', col: 'supplier_invoice_id', value: null },
]),
)
// Junction/payment anchors (no journal_entry_id on the tx) were checked,
// each scoped by company (the callback path runs with a service-role
// client, where RLS does not apply).
expect(stub.anchorProbes.map((p) => p.table).sort()).toEqual([
'invoice_payments',
'supplier_invoice_payments',
'transaction_voucher_links',
])
for (const probe of stub.anchorProbes) {
expect(probe.filters).toEqual([{ col: 'company_id', value: 'c1' }])
}
expect(stub.deletes).toEqual(['row-dup'])
expect(stub.updates).toHaveLength(1)
expect(stub.updates[0].id).toBe('row-old')
expect(stub.updates[0].payload).toMatchObject({ bank_connection_id: 'conn-new' })
})
it('demotes (not deletes) the duplicate when booked or voucher-anchored rows remain', async () => {
// tx-booked has a journal_entry_id and tx-anchored a transaction_voucher_links
// row: both vouchers carry the old 19xx line, so they stay on the duplicate,
// which survives as a released manual twin (the #1643 guards handle it).
const stub = makeUpsertStub({
holder: { id: 'row-old', bank_connection_id: 'conn-old' },
connections: [{ id: 'conn-old', status: 'revoked' }],
ownRow: { id: 'row-dup', is_primary: false },
ownTransactions: [
{ id: 'tx-movable' },
{ id: 'tx-booked', booked: true },
{ id: 'tx-anchored', anchor: 'transaction_voucher_links' },
],
})
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT)
// Only the movable row moved; the anchored one was excluded by the pre-check.
expect(stub.txRebinds).toHaveLength(1)
expect(stub.txRebinds[0].filters).toContainEqual({ op: 'in', col: 'id', value: ['tx-movable'] })
expect(stub.ownTransactions.map((t) => t.id).sort()).toEqual(['tx-anchored', 'tx-booked'])
expect(stub.deletes).toHaveLength(0)
expect(stub.updates).toHaveLength(2)
// First write releases the duplicate's PSD2 binding, preserving the row
// (and its transactions.cash_account_id links) as a manual account.
expect(stub.updates[0].id).toBe('row-dup')
expect(stub.updates[0].payload).toEqual({ bank_connection_id: null, external_uid: null })
// Second write promotes the holder.
expect(stub.updates[1].id).toBe('row-old')
expect(stub.updates[1].payload).toMatchObject({ bank_connection_id: 'conn-new' })
})
it('skips the rebind UPDATE when every bound row is booked', async () => {
const stub = makeUpsertStub({
holder: { id: 'row-old', bank_connection_id: 'conn-old' },
connections: [{ id: 'conn-old', status: 'revoked' }],
ownRow: { id: 'row-dup', is_primary: false },
ownTransactions: [{ id: 'tx-booked', booked: true }],
})
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT)
expect(stub.txRebinds).toHaveLength(0)
expect(stub.anchorProbes).toHaveLength(0)
expect(stub.deletes).toHaveLength(0)
expect(stub.updates[0]).toEqual({
id: 'row-dup',
payload: { bank_connection_id: null, external_uid: null },
})
})
it.each<AnchorTable>(['invoice_payments', 'supplier_invoice_payments'])(
'keeps a row anchored through %s on the duplicate and demotes it',
async (table) => {
// Multi-allocation (match_batch_allocate) anchors through a payment row
// with journal_entry_id NULL on the transaction, so the column gate alone
// would move it. The pre-check must exclude it.
const stub = makeUpsertStub({
holder: { id: 'row-old', bank_connection_id: 'conn-old' },
connections: [{ id: 'conn-old', status: 'revoked' }],
ownRow: { id: 'row-dup', is_primary: false },
ownTransactions: [{ id: 'tx-movable' }, { id: 'tx-paid', anchor: table }],
})
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT)
expect(stub.txRebinds).toHaveLength(1)
expect(stub.txRebinds[0].filters).toContainEqual({
op: 'in',
col: 'id',
value: ['tx-movable'],
})
expect(stub.ownTransactions.map((t) => t.id)).toEqual(['tx-paid'])
expect(stub.deletes).toHaveLength(0)
expect(stub.updates[0]).toEqual({
id: 'row-dup',
payload: { bank_connection_id: null, external_uid: null },
})
expect(stub.updates[1].id).toBe('row-old')
},
)
it('aborts before any cash_accounts write when an anchor pre-check fails', async () => {
const stub = makeUpsertStub({
holder: { id: 'row-old', bank_connection_id: 'conn-old' },
connections: [{ id: 'conn-old', status: 'revoked' }],
ownRow: { id: 'row-dup', is_primary: true },
ownTransactions: [{ id: 'tx-1' }],
anchorError: { table: 'supplier_invoice_payments', message: 'probe boom' },
})
await expect(
upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT),
).rejects.toThrow(/cash_accounts upsert failed: probe boom/)
expect(stub.txRebinds).toHaveLength(0)
expect(stub.updates).toHaveLength(0)
expect(stub.deletes).toHaveLength(0)
expect(stub.upserts).toHaveLength(0)
expect(stub.rpcCalls).toHaveLength(0)
})
it('aborts before any cash_accounts write when the rebind UPDATE fails', async () => {
const stub = makeUpsertStub({
holder: { id: 'row-old', bank_connection_id: 'conn-old' },
connections: [{ id: 'conn-old', status: 'revoked' }],
ownRow: { id: 'row-dup', is_primary: true },
ownTransactions: [{ id: 'tx-1' }],
rebindError: { message: 'rebind boom' },
})
await expect(
upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT),
).rejects.toThrow(/cash_accounts upsert failed: rebind boom/)
expect(stub.txRebinds).toHaveLength(1)
expect(stub.ownTransactions.map((t) => t.id)).toEqual(['tx-1'])
expect(stub.updates).toHaveLength(0)
expect(stub.deletes).toHaveLength(0)
expect(stub.upserts).toHaveLength(0)
expect(stub.rpcCalls).toHaveLength(0)
})
it('chunks the anchor probes and the rebind UPDATE at 100 ids', async () => {
const ids = Array.from({ length: 150 }, (_, i) => `tx-${String(i).padStart(3, '0')}`)
const stub = makeUpsertStub({
holder: { id: 'row-old', bank_connection_id: 'conn-old' },
connections: [{ id: 'conn-old', status: 'revoked' }],
ownRow: { id: 'row-dup', is_primary: false },
ownTransactions: ids.map((id) => ({ id })),
})
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT)
// 3 anchor tables x 2 chunks, each chunk probed against every table.
expect(stub.anchorProbes).toHaveLength(6)
for (const table of [
'transaction_voucher_links',
'invoice_payments',
'supplier_invoice_payments',
]) {
const probes = stub.anchorProbes.filter((p) => p.table === table)
expect(probes.map((p) => p.ids)).toEqual([ids.slice(0, 100), ids.slice(100)])
}
const inLists = stub.txRebinds.map(
(r) => r.filters.find((f) => f.op === 'in' && f.col === 'id')?.value,
)
expect(inLists).toEqual([ids.slice(0, 100), ids.slice(100)])
expect(stub.ownTransactions).toHaveLength(0)
expect(stub.deletes).toEqual(['row-dup'])
})
it('scopes the duplicate linked-transactions probe by company (service-role defense in depth)', async () => {
const stub = makeUpsertStub({
holder: { id: 'row-old', bank_connection_id: 'conn-old' },
connections: [{ id: 'conn-old', status: 'revoked' }],
ownRow: { id: 'row-dup', is_primary: false },
})
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT)
expect(stub.transactionFilters).toEqual(
expect.arrayContaining([
{ col: 'company_id', value: 'c1' },
{ col: 'cash_account_id', value: 'row-dup' },
]),
)
})
it('transfers the primary flag when the deleted duplicate was primary', async () => {
const stub = makeUpsertStub({
holder: { id: 'row-old', bank_connection_id: 'conn-old' },
connections: [{ id: 'conn-old', status: 'revoked' }],
ownRow: { id: 'row-dup', is_primary: true },
})
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT)
expect(stub.deletes).toEqual(['row-dup'])
expect(stub.rpcCalls).toEqual([
{
fn: 'set_cash_account_primary',
args: { p_company_id: 'c1', p_cash_account_id: 'row-old' },
},
])
})
it('transfers the primary flag when the DEMOTED duplicate was primary', async () => {
// Otherwise the stale manual row keeps is_primary=true and the
// __PRIMARY_SEK__ sentinel resolves to the wrong row.
const stub = makeUpsertStub({
holder: { id: 'row-old', bank_connection_id: 'conn-old' },
connections: [{ id: 'conn-old', status: 'revoked' }],
ownRow: { id: 'row-dup', is_primary: true },
ownTransactions: [{ id: 'tx-booked', booked: true }],
})
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT)
expect(stub.deletes).toHaveLength(0)
expect(stub.updates[0].id).toBe('row-dup')
expect(stub.updates[0].payload).toEqual({ bank_connection_id: null, external_uid: null })
expect(stub.rpcCalls).toEqual([
{
fn: 'set_cash_account_primary',
args: { p_company_id: 'c1', p_cash_account_id: 'row-old' },
},
])
})
it('throws when the plain upsert fails', async () => {
const stub = makeUpsertStub({ holder: null, upsertError: { message: 'duplicate key' } })
await expect(
upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT),
).rejects.toThrow(/duplicate key/)
})
})
// ── ensureManualCashAccount ──────────────────────────────────────────────
interface ManualStub {
lookup: { data: { id: string; currency?: string } | null; error?: { message: string } | null }
insert?: { data: { id: string } | null; error?: { message: string; code?: string } | null }
reread?: { data: { id: string } | null; error?: { message: string } | null }
inserted: Array<Record<string, unknown>>
lookupCount: number
}
function makeManualSupabase(stub: ManualStub): SupabaseClient {
return {
from: vi.fn((table: string) => {
expect(table).toBe('cash_accounts')
return {
// lookup / reread path: select().eq().eq().maybeSingle()
select: vi.fn(() => ({
eq: vi.fn(() => ({
eq: vi.fn(() => ({
maybeSingle: vi.fn(() => {
stub.lookupCount += 1
// First maybeSingle = initial lookup; a second = post-23505 reread.
const r = stub.lookupCount === 1 ? stub.lookup : stub.reread ?? { data: null }
return Promise.resolve({ data: r.data, error: r.error ?? null })
}),
})),
})),
})),
// insert().select('id').single()
insert: vi.fn((payload: Record<string, unknown>) => {
stub.inserted.push(payload)
return {
select: vi.fn(() => ({
single: vi.fn(() =>
Promise.resolve({
data: stub.insert?.data ?? null,
error: stub.insert?.error ?? null,
}),
),
})),
}
}),
}
}),
} as unknown as SupabaseClient
}
describe('ensureManualCashAccount', () => {
it('returns the existing row id without inserting when the currency matches', async () => {
const stub: ManualStub = { lookup: { data: { id: 'ca-1', currency: 'SEK' } }, inserted: [], lookupCount: 0 }
const id = await ensureManualCashAccount(makeManualSupabase(stub), 'c1', '1935', 'sek')
expect(id).toBe('ca-1')
expect(stub.inserted).toHaveLength(0)
})
it('throws when the existing row is a different currency (UNIQUE ledger conflict)', async () => {
const stub: ManualStub = { lookup: { data: { id: 'ca-usd', currency: 'USD' } }, inserted: [], lookupCount: 0 }
await expect(
ensureManualCashAccount(makeManualSupabase(stub), 'c1', '1935', 'SEK'),
).rejects.toThrow(/denominated in USD, not SEK/)
expect(stub.inserted).toHaveLength(0)
})
it('creates a manual row (source=manual, uppercased currency) when none exists', async () => {
const stub: ManualStub = {
lookup: { data: null },
insert: { data: { id: 'ca-new' } },
inserted: [],
lookupCount: 0,
}
const id = await ensureManualCashAccount(makeManualSupabase(stub), 'c1', '1935', 'sek')
expect(id).toBe('ca-new')
expect(stub.inserted[0]).toMatchObject({
company_id: 'c1',
ledger_account: '1935',
currency: 'SEK',
source: 'manual',
is_primary: false,
enabled: true,
})
})
it('re-reads the winner on a 23505 race instead of throwing', async () => {
const stub: ManualStub = {
lookup: { data: null },
insert: { data: null, error: { message: 'duplicate key', code: '23505' } },
reread: { data: { id: 'ca-winner' } },
inserted: [],
lookupCount: 0,
}
const id = await ensureManualCashAccount(makeManualSupabase(stub), 'c1', '1935', 'SEK')
expect(id).toBe('ca-winner')
})
it('throws on a non-race insert failure', async () => {
const stub: ManualStub = {
lookup: { data: null },
insert: { data: null, error: { message: 'boom' } },
inserted: [],
lookupCount: 0,
}
await expect(
ensureManualCashAccount(makeManualSupabase(stub), 'c1', '1935', 'SEK'),
).rejects.toThrow(/boom/)
})
})
// ---------------------------------------------------------------------------
// updateBalancesFromSync: balance mirror from the PSD2 sync loop
// ---------------------------------------------------------------------------
describe('updateBalancesFromSync', () => {
interface BalanceUpdate {
payload: Record<string, unknown>
filters: Array<[string, unknown]>
}
function makeBalanceStub(updateError: { message: string } | null = null) {
const updates: BalanceUpdate[] = []
const supabase = {
from: vi.fn((table: string) => {
if (table !== 'cash_accounts') throw new Error(`unexpected table ${table}`)
return {
update: vi.fn((payload: Record<string, unknown>) => {
const entry: BalanceUpdate = { payload, filters: [] }
updates.push(entry)
const chain = {
eq: vi.fn((col: string, val: unknown) => {
entry.filters.push([col, val])
return chain
}),
lt: vi.fn((col: string, val: unknown) => {
entry.filters.push([`lt:${col}`, val])
return chain
}),
is: vi.fn((col: string, val: unknown) => {
entry.filters.push([`is:${col}`, val])
return chain
}),
then: (onFulfilled: (value: unknown) => unknown) =>
Promise.resolve({ error: updateError }).then(onFulfilled),
}
return chain
}),
}
}),
} as unknown as SupabaseClient
return { supabase, updates }
}
it('updates only balance fields, keyed on company + connection + uid', async () => {
const { supabase, updates } = makeBalanceStub()
await updateBalancesFromSync(supabase, 'c1', 'conn-1', [
{
external_uid: 'uid-1',
balance: 1000.5,
available_balance: 950.25,
balance_updated_at: '2026-09-01T05:00:00.000Z',
},
])
// Two writes per account: one for rows with an OLDER timestamp, one for
// rows with NO timestamp. Together they are the stale-writer guard: an
// older sync run finishing later must not move the mirror backwards.
expect(updates).toHaveLength(2)
for (const u of updates) {
expect(u.payload).toEqual({
balance: 1000.5,
available_balance: 950.25,
balance_updated_at: '2026-09-01T05:00:00.000Z',
})
}
expect(updates[0].filters).toEqual([
['company_id', 'c1'],
['bank_connection_id', 'conn-1'],
['external_uid', 'uid-1'],
['lt:balance_updated_at', '2026-09-01T05:00:00.000Z'],
])
expect(updates[1].filters).toEqual([
['company_id', 'c1'],
['bank_connection_id', 'conn-1'],
['external_uid', 'uid-1'],
['is:balance_updated_at', null],
])
})
it('skips accounts without a timestamped balance (never nulls a stored one)', async () => {
const { supabase, updates } = makeBalanceStub()
await updateBalancesFromSync(supabase, 'c1', 'conn-1', [
{ external_uid: 'uid-no-balance', balance: null, balance_updated_at: '2026-09-01T05:00:00.000Z' },
{ external_uid: 'uid-no-timestamp', balance: 100 },
{ external_uid: 'uid-ok', balance: 200, balance_updated_at: '2026-09-01T05:00:00.000Z' },
])
expect(updates).toHaveLength(2)
expect(updates[0].filters).toContainEqual(['external_uid', 'uid-ok'])
// A refresh without an available type writes null: a stale available
// figure next to a fresh booked figure would misstate what can be spent.
expect(updates[0].payload.available_balance).toBeNull()
})
it('logs update failures instead of throwing (mirror must not fail the sync)', async () => {
const { supabase } = makeBalanceStub({ message: 'boom' })
await expect(
updateBalancesFromSync(supabase, 'c1', 'conn-1', [
{ external_uid: 'uid-1', balance: 1, balance_updated_at: '2026-09-01T05:00:00.000Z' },
]),
).resolves.toBeUndefined()
})
})