* fix(cash-accounts): never propose or accept an orphaned cash-account ledger as counter-account (#1643) A broken bank reconnect leaves cash_accounts rows that share the live account's IBAN (held by a revoked connection, or demoted to manual by the #916 fix). Three consequences are fixed here: - Problem 4 (silent mis-booking): the own-account transfer detector paired with such an orphan and proposed its ledger as the counter-account, and a counterparty template learned from that result replayed as 1940/1931 in the booking dialog. The detector now tolerates several rows on one IBAN, never pairs with the transaction's own row, a disabled row, or a revoked holder; the mapping engine drops a "transfer" whose counter equals the settlement account; suggest-categories withholds learned suggestions that reference an orphaned ledger; and both commit paths (POST /api/transactions/[id]/categorize, categorizeMatchedTransaction) reject with the new TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT (400). Orphans are only refused in the COUNTER position: a stranded row still settles on its own ledger, and a manual account without a live IBAN twin is never treated as orphaned, so transfers between two live accounts keep booking. - Problem 1 (match dialog): the ranked unmatched-entries path also offers vouchers booked on sibling ledgers of the same IBAN, and manualLink accepts a voucher line on a sibling ledger. When it does, the same locked UPDATE re-points transactions.cash_account_id to the live sibling row (currency-gated, like PATCH /api/transactions/[id]/cash-account) so the account-keyed reconciliation does not count a cross-account link as an imbalance on both ledgers. - Problem 3 (naming): allocatePsd2LedgerAccount names the chart account BAS-style (BAS reference name for a standard slot, else "Bankkonto <CUR>") instead of the ASPSP-reported holder name. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): address review findings on the orphaned-ledger guards (#1643) One in-memory topology (cash_accounts rows + bank_connections status) now defines "live", "orphaned" and "same physical account" for the transfer detector, the match/link flows and every commit guard, so a proposal is never made that a guard later rejects. - Finding 1/4/6 (own IBAN as counterparty): findPairableCashAccountByIban treats the transaction's own IBAN as "not a transfer": every same-currency row on that IBAN is the same physical account, whichever is live, so interest stamped with the own IBAN never pairs with a twin (two active rows, a demoted-manual twin, or a live twin of a stranded row). Only a pocket in another currency on that IBAN can still pair. guardCounterLegs refuses a same-IBAN same-currency twin in the counter position on every commit path, even when both rows are active. - Finding 3: with several surviving candidates (currency pockets with no discriminator, or two active twins) the finder returns null instead of picking the lowest ledger, which is what the pre-PR lookup did. - Finding 5: the finder drops every row in the orphaned set, the same predicate the commit guards use (demoted-manual twins included). - Finding 9: "live" means enabled + connection status 'active'; an expired/error twin of a live row is orphaned, a lone expired connection (re-auth window) is not. - Finding 2: siblings are keyed on (normalized IBAN, currency) in describeCashAccountSiblings and the unmatched-entries route, so a SEK transaction can no longer link to a voucher whose only bank leg is on the EUR pocket of the same IBAN; manualLink rejects that as before. - Finding 8: manualLink re-points a row only when the voucher sits on the LIVE sibling and the own row is not live; the reverse direction links without moving the row. - Finding 7: the v1 REST categorize route runs the same guardCounterLegs check after account_override and returns TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT. MCP stages through categorizeMatchedTransaction, already covered. - Finding 10: a learned template whose stale 19xx leg is a twin of the settlement row is rewritten to the settlement account (it is the bank leg, not the counter) instead of refused; suggest-categories exempts each transaction's own settlement ledger before withholding a suggestion. The error message now covers both the twin and the disconnected case. Tests pin each behavior (service, detector, manualLink, unmatched-entries, dashboard and v1 categorize routes, suggest-categories). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): address round-2 review findings (#1643) 1+3. Orphan derivation keyed on (IBAN, currency): loadCashAccountTopology now keys the live twin on normalized IBAN plus currency (the rule every other "same physical account" check in the PR already used), so a manual or deselected GBP/EUR pocket beside a live SEK pocket of a multi-currency account is never orphaned, still pairs in the transfer detector and is accepted as counter at commit. Twin computation is shared (twinLedgersOf). 2. suggest-categories mirrors guardCounterLegs: a learned 19xx leg that is a twin of the transaction's own row is rewritten to the settlement ledger in the offered suggestion instead of being withheld; only a true counter-position orphan (or a twin that would book the settlement ledger against itself) is withheld. One topology load per batch (loadCounterLegTopology). 4. The free-form dialog path (POST /api/transactions/[id]/book) gets a line-level guard (guardBookedCounterLines): a 19xx line that is a twin of the transaction's own row or an orphaned ledger, alongside the settlement leg, is refused with TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT. Only runs when the lines touch two distinct 19xx ledgers. The twin rewrite in suggest-categories (2) covers the both-active shape before the dialog is even opened. 5. manualLink re-points the row onto the sibling ledger the voucher was booked on whenever the sibling is live or the own row is not (both-live twins and both-dead rows included); only a live row whose voucher sits on a dead sibling links without moving. unmatched-entries now uses describeCashAccountSiblings and does not offer dead-sibling vouchers to a live row. DECISIONS.md: the PR's existing review follow-up line amended. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): address round-3 review findings (#1643) 1. Revoked-held rows are no longer orphaned unconditionally. A row whose connection is revoked is orphaned only under the twin rule (not live AND a live row shares its normalized IBAN + currency), so a disconnected-but-real account (the company's only 1930, or two real accounts on one revoked connection) stays pairable by the transfer detector and bookable as counter on every guarded path. Tests cover the no-twin case for getOrphanedCounterLedgers, findPairableCashAccountByIban, detectOwnAccountTransfer, guardCounterLegs and guardBookedCounterLines; the existing revoked tests now use a twin shape. 2. manualLink / unmatched-entries decide the re-point on the destination: a new shouldRepointToSibling moves onto a live sibling, or onto a dead one only when the own row's holder is gone (released: bank_connection_id null or revoked) and no sibling is live. An expired/error/pending own row links without moving. SiblingCashAccount gains `released`. Tests: expired own row + demoted twin links without moving and the twin's vouchers are not offered. 3. loadCounterLegTopology is exercised directly: settlement ledger and twins, other-currency pocket, null/unknown ids, cache, orphan set equal to guardCounterLegs' refusals on the same fixture, lookup failure. 4. guardBookedCounterLines docstring and the /book route comment now state that only the two-cash-legs shape is inspected; a single hand-typed 19xx line is not (covering it would cost a cash_accounts lookup on every ordinary booking). DECISIONS.md lines amended accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): address round-4 review findings (#1643) 1. Same-connection re-registration twins (the dominant prod shape): two enabled rows on one active connection sharing (IBAN, currency) are now told apart by balance_updated_at; only the most recently synced row is live, the other is a stale twin (orphaned as a counter, never a re-point destination, and the transfer detector pairs with the syncing row alone). Rows with no stamp or the same stamp both stay live. 2. POST /book: a single 19xx line that is a sibling ledger the row should move to (the live twin of a stranded row) re-points cash_account_id in the same locked UPDATE that links the voucher, mirroring manualLink. guardBookedCounterLines returns { refusedLedger, repointCashAccountId }; an ordinary booking pays one PK read of the own row. 3. manualLink refuses the link (success:false, Swedish error) when the voucher sits only on a dead sibling instead of writing a cross-account link with a server-side warn; the REST and MCP link callers reach it without the unmatched-entries filter. 4. manualLink judges a voucher touching several sibling ledgers on the best of them (a live sibling, else the first the row may move to) instead of the first line PostgREST returns. Tests pinned in lib/cash-accounts, lib/reconciliation and the /book route; the two DECISIONS.md lines for #1643 amended in place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): address round-5 review findings (#1643) 1/2/5. Same-connection twin liveness no longer ranks on cash_accounts.balance_updated_at (a connect-time snapshot the sync never refreshes, inverted on prod in 4 of 5 stamped groups). The live row is the one whose external_uid the bank still lists in bank_connections.accounts_data (rewritten on every sync); no listing, both listed or neither listed keeps both rows live (round-3 behavior). getConnectionStatuses selects accounts_data in the same query. 3. guardBookedCounterLines single-19xx-line shape: a twin the row may not move to (dead or disabled) is refused with TX_CATEGORIZE_ORPHANED_COUNTER_ACCOUNT instead of posting the only bank leg on the dead ledger; an unrelated 19xx line still posts as typed. Route test added. 4. Disabled cash_accounts rows are never siblings, so neither manualLink nor /book re-points a transaction onto a deselected row; a voucher booked only there is refused as a cross-account link. 6. PR body rewritten to the final rules; DECISIONS.md round-4 line amended (signal correction, /book refusal, disabled siblings). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): never treat a null external_uid as listed by the bank (#1643) CashAccount.external_uid is nullable in the shared type; the same-connection twin rule now skips null uids instead of passing them to Set.has, which failed the strict type check in CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna * fix(cash-accounts): drop the same-connection twin liveness rule; both rows stay live (#1643) Two enabled rows on one active bank connection sharing (IBAN, currency) are no longer ranked. Round 4 ranked on cash_accounts.balance_updated_at and round 5 on external_uid presence in bank_connections.accounts_data; each was verified against prod and each was contradicted by it (ingest routes by the accounts_data entry's ledger_account, which in two groups points at the OLD row, so the "stale" row is the one still being fed). Restores the round-3 behavior: neither twin is orphaned, the transfer finder returns null when both survive, no guard refuses either, and shouldRepointToSibling treats both as live siblings. No replacement signal; how to model the shape is a founder decision (PR #2010 review). getConnectionStatuses no longer selects accounts_data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
952 lines
35 KiB
TypeScript
952 lines
35 KiB
TypeScript
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,
|
||
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
|
||
/** Whether the duplicate ownRow has linked transactions. */
|
||
ownHasTransactions?: boolean
|
||
upsertError?: { 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> }>
|
||
/** .eq() filters applied to the linked-transactions probe. */
|
||
transactionFilters: Array<{ col: string; value: unknown }>
|
||
}
|
||
|
||
function makeUpsertStub(partial: Partial<UpsertStub> = {}): UpsertStub {
|
||
return {
|
||
updates: [],
|
||
deletes: [],
|
||
upserts: [],
|
||
rpcCalls: [],
|
||
transactionFilters: [],
|
||
...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 chain = {
|
||
select: vi.fn(() => chain),
|
||
eq: vi.fn((col: string, value: unknown) => {
|
||
stub.transactionFilters.push({ col, value })
|
||
return chain
|
||
}),
|
||
limit: vi.fn(() =>
|
||
Promise.resolve({
|
||
data: stub.ownHasTransactions ? [{ id: 'tx-1' }] : [],
|
||
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 },
|
||
ownHasTransactions: false,
|
||
})
|
||
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT)
|
||
|
||
expect(stub.deletes).toEqual(['row-dup'])
|
||
expect(stub.updates).toHaveLength(1)
|
||
expect(stub.updates[0].id).toBe('row-old')
|
||
expect(stub.rpcCalls).toHaveLength(0)
|
||
})
|
||
|
||
it('demotes (not deletes) a duplicate that has linked transactions', 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 },
|
||
ownHasTransactions: true,
|
||
})
|
||
await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT)
|
||
|
||
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('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 },
|
||
ownHasTransactions: 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 },
|
||
ownHasTransactions: false,
|
||
})
|
||
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 },
|
||
ownHasTransactions: 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/)
|
||
})
|
||
})
|