fix(cash-accounts): never propose or accept an orphaned twin ledger as counter-account; match and re-point across sibling ledgers (#1643) (#2010)
* 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>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
1a8fe36bc4
commit
4e1eb3d662
@@ -872,6 +872,9 @@ describe('manualLink', () => {
|
||||
|
||||
function createQueueMockSupabase() {
|
||||
const resultQueue: { data: unknown; error: unknown }[] = []
|
||||
// Every chained method call, in order, so a test can assert on the
|
||||
// payload handed to .update(...) (the #1643 re-point cases below).
|
||||
const calls: Array<{ method: string; args: unknown[] }> = []
|
||||
|
||||
const enqueue = (...results: { data?: unknown; error?: unknown }[]) => {
|
||||
for (const r of results) {
|
||||
@@ -886,7 +889,10 @@ describe('manualLink', () => {
|
||||
const next = resultQueue.shift() ?? { data: null, error: null }
|
||||
return (resolve: (v: unknown) => void) => resolve(next)
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain()
|
||||
return (...args: unknown[]) => {
|
||||
calls.push({ method: String(prop), args })
|
||||
return buildChain()
|
||||
}
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
@@ -897,7 +903,10 @@ describe('manualLink', () => {
|
||||
rpc: vi.fn().mockImplementation(() => buildChain()),
|
||||
}
|
||||
|
||||
return { supabase, enqueue }
|
||||
const updatePayloads = () =>
|
||||
calls.filter((c) => c.method === 'update').map((c) => c.args[0] as Record<string, unknown>)
|
||||
|
||||
return { supabase, enqueue, updatePayloads }
|
||||
}
|
||||
|
||||
it('rejects when transaction not found', async () => {
|
||||
@@ -1029,6 +1038,8 @@ describe('manualLink', () => {
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
// Cross-check: cash account maps to the account being reconciled
|
||||
enqueue({ data: { ledger_account: '1930' } })
|
||||
// Sibling-ledger scan (#1643): a row without an IBAN has no siblings.
|
||||
enqueue({ data: [{ id: 'ca-1930', iban: null, ledger_account: '1930', currency: 'SEK', enabled: true, bank_connection_id: null }] })
|
||||
// Line exists on 1930
|
||||
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
|
||||
// Update succeeds: .select('id') returns the updated row
|
||||
@@ -1039,6 +1050,296 @@ describe('manualLink', () => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('links a transaction stranded on an orphaned row to a voucher on the live sibling ledger and re-points it there (#1643)', async () => {
|
||||
const { supabase, enqueue, updatePayloads } = createQueueMockSupabase()
|
||||
const iban = 'SE4550000000058398257466'
|
||||
// Transaction stamped on the orphaned reconnect row (ledger 1931); the
|
||||
// verifikat it settles is booked on the live ledger 1940 of the SAME
|
||||
// physical account (same IBAN).
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
journal_entry_id: null,
|
||||
cash_account_id: 'ca-orphan',
|
||||
currency: 'SEK',
|
||||
})
|
||||
|
||||
enqueue({ data: tx })
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
// Cross-check: the transaction's own ledger IS the requested account.
|
||||
enqueue({ data: { ledger_account: '1931' } })
|
||||
// Sibling scan: the own (demoted) row carries the IBAN and the live row
|
||||
// shares it on 1940.
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'ca-orphan', iban, ledger_account: '1931', currency: 'SEK', enabled: true, bank_connection_id: null },
|
||||
{ id: 'ca-live', iban, ledger_account: '1940', currency: 'SEK', enabled: true, bank_connection_id: 'conn-live' },
|
||||
],
|
||||
})
|
||||
enqueue({ data: [{ id: 'conn-live', status: 'active' }] })
|
||||
// The voucher's bank leg sits on the sibling ledger, not on 1931.
|
||||
enqueue({ data: [{ debit_amount: 217.04, credit_amount: 0, account_number: '1940' }] })
|
||||
enqueue({ data: [{ id: 'tx-1' }] })
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1931')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
// The same locked UPDATE that stamps the link moves the row to the live
|
||||
// sibling row, so neither account's reconciliation counts a cross-account
|
||||
// link as an imbalance.
|
||||
expect(updatePayloads()).toEqual([
|
||||
expect.objectContaining({ journal_entry_id: 'je-1', cash_account_id: 'ca-live' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('does not re-point when the voucher line is on the transaction\'s own ledger (#1643)', async () => {
|
||||
const { supabase, enqueue, updatePayloads } = createQueueMockSupabase()
|
||||
const iban = 'SE4550000000058398257466'
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null, cash_account_id: 'ca-orphan' })
|
||||
|
||||
enqueue({ data: tx })
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
enqueue({ data: { ledger_account: '1931' } })
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'ca-orphan', iban, ledger_account: '1931', currency: 'SEK', enabled: true, bank_connection_id: null },
|
||||
{ id: 'ca-live', iban, ledger_account: '1940', currency: 'SEK', enabled: true, bank_connection_id: 'conn-live' },
|
||||
],
|
||||
})
|
||||
enqueue({ data: [{ id: 'conn-live', status: 'active' }] })
|
||||
// Bank leg on 1931 itself: an ordinary same-ledger link.
|
||||
enqueue({ data: [{ debit_amount: 217.04, credit_amount: 0, account_number: '1931' }] })
|
||||
enqueue({ data: [{ id: 'tx-1' }] })
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1931')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(updatePayloads()[0]).not.toHaveProperty('cash_account_id')
|
||||
})
|
||||
|
||||
it('rejects a voucher whose only bank line is on another-currency pocket of the same IBAN (#1643)', async () => {
|
||||
const { supabase, enqueue, updatePayloads } = createQueueMockSupabase()
|
||||
const iban = 'SE4550000000058398257466'
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
journal_entry_id: null,
|
||||
cash_account_id: 'ca-sek',
|
||||
currency: 'SEK',
|
||||
})
|
||||
|
||||
enqueue({ data: tx })
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
enqueue({ data: { ledger_account: '1931' } })
|
||||
// Multi-currency account: the EUR pocket shares the IBAN but is another
|
||||
// account, so it is not a sibling and the line check stays on 1931.
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'ca-sek', iban, ledger_account: '1931', currency: 'SEK', enabled: true, bank_connection_id: 'conn-live' },
|
||||
{ id: 'ca-eur', iban, ledger_account: '1932', currency: 'EUR', enabled: true, bank_connection_id: 'conn-live' },
|
||||
],
|
||||
})
|
||||
enqueue({ data: [{ id: 'conn-live', status: 'active' }] })
|
||||
enqueue({ data: [] }) // no line on 1931 (the voucher only has a 1932 leg)
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1931')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Verifikationen saknar rad på 1931')
|
||||
expect(updatePayloads()).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses a voucher that sits only on a sibling that is NOT the live row (#1643 round 4)', async () => {
|
||||
const { supabase, enqueue, updatePayloads } = createQueueMockSupabase()
|
||||
const iban = 'SE4550000000058398257466'
|
||||
// Reverse direction: the transaction synced onto the live 1940 row, the
|
||||
// voucher was booked on the now-revoked 1931 before the reconnect. The
|
||||
// voucher is what is wrong; the row must not be parked on the dead row,
|
||||
// and a cross-account link (money on 1940, voucher on 1931) would show
|
||||
// as an imbalance on both ledgers, so the link is refused outright.
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
journal_entry_id: null,
|
||||
cash_account_id: 'ca-live',
|
||||
currency: 'SEK',
|
||||
})
|
||||
|
||||
enqueue({ data: tx })
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
enqueue({ data: { ledger_account: '1940' } })
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'ca-live', iban, ledger_account: '1940', currency: 'SEK', enabled: true, bank_connection_id: 'conn-live' },
|
||||
{ id: 'ca-orphan', iban, ledger_account: '1931', currency: 'SEK', enabled: true, bank_connection_id: 'conn-old' },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'conn-live', status: 'active' },
|
||||
{ id: 'conn-old', status: 'revoked' },
|
||||
],
|
||||
})
|
||||
enqueue({ data: [{ debit_amount: 0, credit_amount: 1000, account_number: '1931' }] })
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1940')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe(
|
||||
'Verifikationen är bokförd på 1931, som inte är transaktionens konto (1940). Rätta verifikationen eller flytta transaktionen först.',
|
||||
)
|
||||
expect(updatePayloads()).toEqual([])
|
||||
})
|
||||
|
||||
it('refuses to link an EXPIRED own row to a voucher only on a demoted twin (renewable consent, #1643 rounds 3-4)', async () => {
|
||||
const { supabase, enqueue, updatePayloads } = createQueueMockSupabase()
|
||||
const iban = 'SE4550000000058398257466'
|
||||
// Prod shape: 1930 on an expired connection (still the syncing account,
|
||||
// re-auth renews it in place) beside a demoted manual twin 1931. Moving
|
||||
// the row onto 1931 would strand it on the orphan once consent is renewed.
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null, cash_account_id: 'ca-expired', currency: 'SEK' })
|
||||
|
||||
enqueue({ data: tx })
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
enqueue({ data: { ledger_account: '1930' } })
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'ca-expired', iban, ledger_account: '1930', currency: 'SEK', enabled: true, bank_connection_id: 'conn-expired' },
|
||||
{ id: 'ca-demoted', iban, ledger_account: '1931', currency: 'SEK', enabled: true, bank_connection_id: null },
|
||||
],
|
||||
})
|
||||
enqueue({ data: [{ id: 'conn-expired', status: 'expired' }] })
|
||||
enqueue({ data: [{ debit_amount: 0, credit_amount: 1000, account_number: '1931' }] })
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('bokförd på 1931')
|
||||
expect(updatePayloads()).toEqual([])
|
||||
})
|
||||
|
||||
it('re-points to the LIVE sibling when the voucher touches a dead twin and the live twin, whatever the line order (#1643 round 4)', async () => {
|
||||
const { supabase, enqueue, updatePayloads } = createQueueMockSupabase()
|
||||
const iban = 'SE4550000000058398257466'
|
||||
// An old cross-ledger "transfer" between two rows of one physical
|
||||
// account: lines on the revoked 1931 and the live 1940, none on the
|
||||
// demoted 1930 the transaction sits on. The query has no ORDER BY, so
|
||||
// the dead line comes first here; the destination must still be 1940.
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null, cash_account_id: 'ca-1930', currency: 'SEK' })
|
||||
|
||||
enqueue({ data: tx })
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
enqueue({ data: { ledger_account: '1930' } })
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'ca-1930', iban, ledger_account: '1930', currency: 'SEK', enabled: true, bank_connection_id: null },
|
||||
{ id: 'ca-1931', iban, ledger_account: '1931', currency: 'SEK', enabled: true, bank_connection_id: 'conn-old' },
|
||||
{ id: 'ca-1940', iban, ledger_account: '1940', currency: 'SEK', enabled: true, bank_connection_id: 'conn-live' },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'conn-old', status: 'revoked' },
|
||||
{ id: 'conn-live', status: 'active' },
|
||||
],
|
||||
})
|
||||
enqueue({
|
||||
data: [
|
||||
{ debit_amount: 0, credit_amount: 1000, account_number: '1931' },
|
||||
{ debit_amount: 1000, credit_amount: 0, account_number: '1940' },
|
||||
],
|
||||
})
|
||||
enqueue({ data: [{ id: 'tx-1' }] })
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(updatePayloads()).toEqual([
|
||||
expect.objectContaining({ journal_entry_id: 'je-1', cash_account_id: 'ca-1940' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('re-points to the sibling the voucher was booked on when BOTH rows are live (#1643 round 2)', async () => {
|
||||
const { supabase, enqueue, updatePayloads } = createQueueMockSupabase()
|
||||
const iban = 'SE4550000000058398257466'
|
||||
// 1930 and 1931 both enabled on one active connection (the common prod
|
||||
// shape): the voucher settled on 1931, the transaction sits on 1930. A
|
||||
// cross-account link would show a difference on both ledgers.
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null, cash_account_id: 'ca-1930', currency: 'SEK' })
|
||||
|
||||
enqueue({ data: tx })
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
enqueue({ data: { ledger_account: '1930' } })
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'ca-1930', iban, ledger_account: '1930', currency: 'SEK', enabled: true, bank_connection_id: 'conn-live' },
|
||||
{ id: 'ca-1931', iban, ledger_account: '1931', currency: 'SEK', enabled: true, bank_connection_id: 'conn-live' },
|
||||
],
|
||||
})
|
||||
enqueue({ data: [{ id: 'conn-live', status: 'active' }] })
|
||||
enqueue({ data: [{ debit_amount: 0, credit_amount: 1000, account_number: '1931' }] })
|
||||
enqueue({ data: [{ id: 'tx-1' }] })
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(updatePayloads()).toEqual([
|
||||
expect.objectContaining({ journal_entry_id: 'je-1', cash_account_id: 'ca-1931' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('re-points to the sibling the voucher was booked on when NEITHER row is live (#1643 round 2)', async () => {
|
||||
const { supabase, enqueue, updatePayloads } = createQueueMockSupabase()
|
||||
const iban = 'SE4550000000058398257466'
|
||||
// Full disconnect: both rows demoted to manual, IBAN kept. The voucher is
|
||||
// the source of truth for where the money was booked.
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null, cash_account_id: 'ca-1931', currency: 'SEK' })
|
||||
|
||||
enqueue({ data: tx })
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
enqueue({ data: { ledger_account: '1931' } })
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'ca-1931', iban, ledger_account: '1931', currency: 'SEK', enabled: true, bank_connection_id: null },
|
||||
{ id: 'ca-1940', iban, ledger_account: '1940', currency: 'SEK', enabled: true, bank_connection_id: null },
|
||||
],
|
||||
})
|
||||
// No bank_connection ids: the status lookup is skipped.
|
||||
enqueue({ data: [{ debit_amount: 217.04, credit_amount: 0, account_number: '1940' }] })
|
||||
enqueue({ data: [{ id: 'tx-1' }] })
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1931')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(updatePayloads()).toEqual([
|
||||
expect.objectContaining({ journal_entry_id: 'je-1', cash_account_id: 'ca-1940' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('still rejects a voucher with no line on the account or any sibling ledger', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
const iban = 'SE4550000000058398257466'
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
journal_entry_id: null,
|
||||
cash_account_id: 'ca-orphan',
|
||||
})
|
||||
|
||||
enqueue({ data: tx })
|
||||
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
||||
enqueue({ data: { ledger_account: '1931' } })
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'ca-orphan', iban, ledger_account: '1931', currency: 'SEK', enabled: true, bank_connection_id: null },
|
||||
{ id: 'ca-live', iban, ledger_account: '1940', currency: 'SEK', enabled: true, bank_connection_id: 'conn-live' },
|
||||
],
|
||||
})
|
||||
enqueue({ data: [{ id: 'conn-live', status: 'active' }] })
|
||||
enqueue({ data: [] }) // no line on 1931 OR 1940
|
||||
|
||||
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1931')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('Verifikationen saknar rad på 1931 eller 1940')
|
||||
})
|
||||
|
||||
it('allows N:1, does not reject when the verifikat already has a linked transaction', async () => {
|
||||
const { supabase, enqueue } = createQueueMockSupabase()
|
||||
// This transaction is itself unlinked; the TARGET entry already has another
|
||||
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
ledgerLineAmountIn,
|
||||
type LedgerLineAmount,
|
||||
} from '@/lib/bookkeeping/ledger-line-amount'
|
||||
import {
|
||||
describeCashAccountSiblings,
|
||||
shouldRepointToSibling,
|
||||
type CashAccountSiblings,
|
||||
} from '@/lib/cash-accounts/service'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('reconciliation.bank')
|
||||
@@ -1094,6 +1099,16 @@ export async function manualLink(
|
||||
// reconciled. A transaction bound to 1930 must not be linked against a 1931
|
||||
// voucher even if the caller passes accountNumber=1931. Legacy rows with no
|
||||
// cash_account_id fall through (the UI list already gates them by currency).
|
||||
//
|
||||
// Sibling ledgers of the SAME physical account (rows sharing the IBAN, in
|
||||
// the same currency) are additionally accepted for the voucher-line check
|
||||
// below: a transaction stranded on an orphaned reconnect row (e.g. 1931)
|
||||
// must be linkable to the verifikat booked on the live ledger of that same
|
||||
// account (e.g. 1940), issue #1643 problem 1. Unrelated accounts, and the
|
||||
// other currency pockets of a multi-currency account (same IBAN, other
|
||||
// currency), stay rejected.
|
||||
let allowedLineAccounts: string[] = [accountNumber]
|
||||
let siblingInfo: CashAccountSiblings | null = null
|
||||
if (tx.cash_account_id) {
|
||||
const { data: txCa } = await supabase
|
||||
.from('cash_accounts')
|
||||
@@ -1107,19 +1122,76 @@ export async function manualLink(
|
||||
error: `Transaktionen hör till ${txCa.ledger_account}, inte ${accountNumber}`,
|
||||
}
|
||||
}
|
||||
siblingInfo = await describeCashAccountSiblings(supabase, companyId, tx.cash_account_id)
|
||||
if (siblingInfo && siblingInfo.siblings.length > 0) {
|
||||
allowedLineAccounts = [
|
||||
...new Set([accountNumber, ...siblingInfo.siblings.map((row) => row.ledger_account)]),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Check for a bank account line on the SELECTED settlement account. The old
|
||||
// Check for a bank account line on the SELECTED settlement account (or a
|
||||
// sibling ledger of the same physical account, see above). The old
|
||||
// "any 19xx line" check let a 1930 transaction link to a voucher that only
|
||||
// touched 1931: a cross-account link that silently hides a real imbalance.
|
||||
const { data: lines } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('debit_amount, credit_amount, account_number')
|
||||
.eq('journal_entry_id', journalEntryId)
|
||||
.eq('account_number', accountNumber)
|
||||
.in('account_number', allowedLineAccounts)
|
||||
|
||||
if (!lines || lines.length === 0) {
|
||||
return { success: false, error: `Verifikationen saknar rad på ${accountNumber}` }
|
||||
return { success: false, error: `Verifikationen saknar rad på ${allowedLineAccounts.join(' eller ')}` }
|
||||
}
|
||||
|
||||
// When the voucher's bank leg sits on a SIBLING ledger only, the row moves
|
||||
// to that sibling in the same write that links it: siblings are the same
|
||||
// physical account in the same currency, and the voucher is the source of
|
||||
// truth for where the money was booked. A cross-account link would leave
|
||||
// the money on one ledger while the voucher settles on the other, and the
|
||||
// account-keyed reconciliation would count it as an imbalance on BOTH
|
||||
// accounts. Same gate as PATCH /api/transactions/[id]/cash-account: the row
|
||||
// is unbooked by construction (the locked UPDATE below asserts that). This
|
||||
// covers the stranded row linking to the live ledger, two live twins of one
|
||||
// connection, and two demoted rows after a full disconnect. The decision
|
||||
// is about the DESTINATION: the row moves when the sibling is live, or
|
||||
// when its own holder is definitively gone (demoted to manual or revoked)
|
||||
// and no other sibling is live either. A row whose connection is merely
|
||||
// expired/error/pending is still the syncing account (re-auth renews it in
|
||||
// place), so a voucher booked ONLY on a dead sibling is REFUSED (round 4):
|
||||
// the voucher is what is wrong, moving the row would strand it on the
|
||||
// orphan the moment consent is renewed, and writing the link anyway would
|
||||
// be the cross-account link the line check above exists to refuse (the
|
||||
// REST and MCP callers reach this directly, without the unmatched-entries
|
||||
// filter that hides such vouchers from the dialog). The same rule keeps a
|
||||
// live row from being parked on a row no connection can sync again.
|
||||
// A voucher touching several sibling ledgers (an old "transfer" between
|
||||
// two rows of one physical account) is judged on the best of them, never
|
||||
// on whichever line the query happened to return first: a live sibling
|
||||
// wins, else the first sibling the row may move to.
|
||||
const typedLines = lines as Array<{ account_number: string }>
|
||||
let repointCashAccountId: string | null = null
|
||||
if (!typedLines.some((line) => line.account_number === accountNumber)) {
|
||||
const siblingLedgers = [...new Set(typedLines.map((line) => line.account_number))]
|
||||
const candidates = siblingLedgers
|
||||
.map((ledger) => siblingInfo?.siblings.find((row) => row.ledger_account === ledger) ?? null)
|
||||
.filter((row): row is NonNullable<typeof row> => row !== null)
|
||||
.filter((row) => siblingInfo !== null && shouldRepointToSibling(siblingInfo, row))
|
||||
const destination = candidates.find((row) => row.live) ?? candidates[0] ?? null
|
||||
if (destination) {
|
||||
repointCashAccountId = destination.id
|
||||
} else {
|
||||
log.warn('manualLink: refused a link to a voucher booked only on a dead sibling ledger', {
|
||||
companyId,
|
||||
transactionId,
|
||||
accountNumber,
|
||||
siblingLedgers,
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: `Verifikationen är bokförd på ${siblingLedgers.join(' och ')}, som inte är transaktionens konto (${accountNumber}). Rätta verifikationen eller flytta transaktionen först.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// N:1 is intentionally allowed: several bank transactions may settle ONE
|
||||
@@ -1148,6 +1220,7 @@ export async function manualLink(
|
||||
journal_entry_id: journalEntryId,
|
||||
reconciliation_method: 'manual' as ReconciliationMethod,
|
||||
is_business: true,
|
||||
...(repointCashAccountId ? { cash_account_id: repointCashAccountId } : {}),
|
||||
})
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
Reference in New Issue
Block a user