fix(reconciliation): direction-aware NULL-link settlement for transfer legs (#2018)

* fix(reconciliation): direction-aware NULL-link settlement for transfer legs

A linked transaction with no resolvable cash account counted as settling
its voucher for every account. For an own-account transfer on a
non-primary bankavstamning card this hid the near leg while the far leg
stayed listed, producing a false oforklarat (reported: momskonto card
with differens 0 kr but oforklarat -2 593,75).

get_account_gl_lines_for_matching now discounts a NULL-attributed link
(pointer or junction) only when ALL of: the card is a non-primary
account, the voucher touches >= 2 of the company's cash-account ledgers,
and the row's sign contradicts the voucher's net leg on the account. All
other shapes keep byte-identical legacy behavior, protecting
unbackfilled single-leg rows (measured -37 000 kr false-alarm risk under
the naive primary-only rule, simulated per-card against prod before
choosing this rule; see DECISIONS.md).

Footprint measured on prod: 24 vouchers on 7 cards in 6 companies; 5
cards improve (3 to exactly 0,00), 2 surface a real user-fixable
mislink that was previously hidden.

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

* test(reconciliation): isolate the primary-card exemption in pg coverage

The existing primary-card assertion also passes via sign match; a reverse
transfer (1931 -> 1930) whose NULL row contradicts the primary's +net leg
pins condition 1 (legacy_null_ok) on its own. (CodeRabbit nitpick.)

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-28 23:51:27 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 7f0f25b558
commit 1a8fe36bc4
4 changed files with 457 additions and 5 deletions
+10 -3
View File
@@ -957,7 +957,12 @@ export async function getReconciliationStatus(
// Account-scoped since 20260723160000: a voucher whose links all sit on another
// cash account (a transfer's other leg) counts as unmatched HERE, keeping this
// number in agreement with the "Omatchade verifikationer" table the
// reconciliation view derives from the same RPC.
// reconciliation view derives from the same RPC. Direction-aware since
// 20260828220000: on a NON-primary account, an own-account-transfer voucher
// whose only link is a NULL-cash_account transaction with a contradicting
// sign counts as unmatched here too (that row is the transfer's OTHER leg),
// so both legs of a transfer land in this list symmetrically instead of one
// leg polluting unexplained_difference.
// effectiveFrom, NOT the caller's dateFrom: countedLines and countedTx are both
// clamped to the IB floor above, and this list has to describe the SAME window
// or the card contradicts itself. With the raw dateFrom, a window that opens
@@ -1518,8 +1523,10 @@ export async function fetchJunctionLinkedTxIds(
/** A match candidate that carries how many transactions already point at it. */
export interface GLLineForMatching extends UnlinkedGLLine {
/** Transactions settling this entry ON THE REQUESTED ACCOUNT (plus legacy
* rows with no cash_account_id, which count everywhere). A transaction on
* another cash account, e.g. the outgoing leg of an own-account transfer,
* rows with no cash_account_id, which count everywhere EXCEPT on a
* non-primary account when the voucher is an own-account transfer and the
* row's sign contradicts this account's leg; 20260828220000). A transaction
* on another cash account, e.g. the outgoing leg of an own-account transfer,
* does not mark the voucher as matched here (issue #1026). */
linked_transaction_count: number
}