feat(reconciliation): page-owned window, automatic matching, and a way out for unbookable rows (#1742)
Second half of the reconciliation redesign, on top of the bridge in #1737. **Toolbar.** The view hosted its own "Datum från / Datum till" inputs behind a Filtrera button: a second period control competing with the header's räkenskapsår picker (convention 8), and the source of a "typed but not applied" state that needed its own attention line to explain. The window is now owned by the page, narrowed through the shared ReportDateRange like every other report, and applied on change. The view holds no date state at all, which also removes the ref-synchronisation dance and the off-by-one it existed to prevent (a year switch fetching the previous year's window because the refs updated a commit late). Reconciliation opens on the FULL year, not the family default of YTD, and keeps its own preset memory: a reconciliation runs over a whole räkenskapsår, and inheriting a "Denna månad" last used on Resultatrapport would show an alarming difference for a window nobody chose here. ReportDateRange gained defaultPreset and storageKeyPrefix for that; every existing caller keeps its behaviour. **Automatic matching.** "Förhandsgranska" told the user nothing about what it did, and the ochre line above it existed only to point at it: people matched a whole migration row by row next to a button they never found. The matcher now runs by itself, once per window+account, whenever there is unmatched work. It is a dry run, so nothing is written and Tillämpa still requires an explicit click. The button stays as a re-run and is renamed to what it does. ?autorun=1 keeps a distinct meaning (run even on a clean window) so the transactions-inbox deep link still produces a result rather than silence. **A way out for rows that cannot be paired.** An unmatched bank row that no voucher on the account could settle is not reconciliation work, it is an unbooked affärshändelse, and the match picker held nothing for it. Those rows now offer "Bokför" into /transactions?highlight=<id>, with a bulk link in the section header. The rule (direction-compatible and equal to the öre) is extracted to lib/reconciliation/voucher-candidate.ts so it is testable and so the component never imports the server-only reconciliation module. Deliberately strict: a false negative offers booking on a row that could also have been paired, which is a legitimate outcome, while a false positive sends the user into an empty picker. 11 new tests for the candidate rule, covering direction, öre equality, float noise, PostgREST numeric strings and the foreign-account case where the candidate RPC projects no FX amount and no match may be claimed. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Opus 5
parent
b5e908f9ea
commit
5a8dd21931
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { hasVoucherCandidate, type CandidateLine } from '../voucher-candidate'
|
||||
|
||||
/** A ledger line as the candidate RPCs project it: SEK columns, no FX. */
|
||||
const line = (debit: number, credit: number): CandidateLine => ({
|
||||
debit_amount: debit,
|
||||
credit_amount: credit,
|
||||
})
|
||||
|
||||
describe('hasVoucherCandidate', () => {
|
||||
it('matches money out against a credit of the same amount', () => {
|
||||
expect(hasVoucherCandidate(-1250, [line(0, 1250)], 'SEK')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches money in against a debit of the same amount', () => {
|
||||
expect(hasVoucherCandidate(1250, [line(1250, 0)], 'SEK')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a same-amount line on the wrong side', () => {
|
||||
// A credit cannot settle an incoming payment: the bank account is debited
|
||||
// when money arrives. Matching on amount alone would offer every voucher of
|
||||
// that size regardless of direction.
|
||||
expect(hasVoucherCandidate(1250, [line(0, 1250)], 'SEK')).toBe(false)
|
||||
expect(hasVoucherCandidate(-1250, [line(1250, 0)], 'SEK')).toBe(false)
|
||||
})
|
||||
|
||||
it('requires equality to the öre', () => {
|
||||
expect(hasVoucherCandidate(-1250.5, [line(0, 1250.5)], 'SEK')).toBe(true)
|
||||
expect(hasVoucherCandidate(-1250.5, [line(0, 1250.49)], 'SEK')).toBe(false)
|
||||
expect(hasVoucherCandidate(-1250, [line(0, 1249.99)], 'SEK')).toBe(false)
|
||||
})
|
||||
|
||||
it('compares as integer öre, so float noise cannot decide it', () => {
|
||||
// 0.1 + 0.2 is 0.30000000000000004; a raw === would say these differ.
|
||||
expect(hasVoucherCandidate(-(0.1 + 0.2), [line(0, 0.3)], 'SEK')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles PostgREST numeric strings on the ledger side', () => {
|
||||
expect(
|
||||
hasVoucherCandidate(-1250, [{ debit_amount: '0', credit_amount: '1250.00' }], 'SEK'),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('finds a candidate anywhere in the list', () => {
|
||||
const lines = [line(0, 99), line(500, 0), line(0, 1250)]
|
||||
expect(hasVoucherCandidate(-1250, lines, 'SEK')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for an empty candidate list', () => {
|
||||
expect(hasVoucherCandidate(-1250, [], 'SEK')).toBe(false)
|
||||
})
|
||||
|
||||
it('never claims a candidate on a foreign account whose lines carry no FX amount', () => {
|
||||
// get_account_gl_lines_for_matching projects neither currency nor
|
||||
// amount_in_currency, so on a EUR account no row can be expressed in EUR.
|
||||
// Reading the raw SEK columns would offer a 1 250 SEK leg as the settlement
|
||||
// for a 1 250 EUR bank line.
|
||||
expect(hasVoucherCandidate(-1250, [line(0, 1250)], 'EUR')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches a foreign line that DOES carry the amount in that currency', () => {
|
||||
expect(
|
||||
hasVoucherCandidate(
|
||||
-1250,
|
||||
[{ debit_amount: 0, credit_amount: 14375, currency: 'EUR', amount_in_currency: 1250 }],
|
||||
'EUR',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores a zero-amount row and zero-amount lines', () => {
|
||||
// A zero bank row is not a settlement question, and a zero ledger line
|
||||
// settles nothing: neither may produce a match on "amounts are equal".
|
||||
expect(hasVoucherCandidate(0, [line(0, 0)], 'SEK')).toBe(false)
|
||||
expect(hasVoucherCandidate(-1250, [line(0, 0)], 'SEK')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* "Could this bank row be settled by one of these vouchers?"
|
||||
*
|
||||
* Split out of the reconciliation view so it can be unit-tested and so the
|
||||
* component never has to import `lib/reconciliation/bank-reconciliation`, which
|
||||
* pulls in the event bus and the match log and must not reach the client
|
||||
* bundle. Only `ledgerLineAmountIn` is needed, and that module is pure.
|
||||
*
|
||||
* This is the coarse first pass the server matcher opens with, not the matcher
|
||||
* itself: it answers whether the reconciliation surface has anything to offer
|
||||
* for a row at all. A row it says no to is not reconciliation work, it is an
|
||||
* unbooked affärshändelse, and the UI sends it to the bookkeeping surface
|
||||
* instead of a picker that holds nothing for it.
|
||||
*/
|
||||
import { ledgerLineAmountIn, type LedgerLineAmount } from '@/lib/bookkeeping/ledger-line-amount'
|
||||
|
||||
/** The minimum a ledger line has to expose to be considered here. */
|
||||
export type CandidateLine = LedgerLineAmount
|
||||
|
||||
/**
|
||||
* True when at least one line could settle `amount` on an account reconciled in
|
||||
* `currency`.
|
||||
*
|
||||
* Two rules, both the server matcher's:
|
||||
* - **Direction.** Money in (`amount > 0`) is settled by a debit on the bank
|
||||
* account; money out by a credit. `ledgerLineAmountIn` already returns the
|
||||
* line signed like a bank movement, so this is a sign comparison.
|
||||
* - **Amount.** Equal to the öre. Compared as integer öre rather than with a
|
||||
* float epsilon, the same way every other money comparison in this codebase
|
||||
* settles the question.
|
||||
*
|
||||
* Lines that carry no amount in `currency` (a foreign account, whose candidate
|
||||
* rows hold no FX figure) can never be shown to settle anything: there is no
|
||||
* honest comparison to make, and claiming one would offer a 1 150 SEK ledger
|
||||
* leg as the settlement for a 1 150 EUR bank line.
|
||||
*
|
||||
* Deliberately strict, because the two errors are not symmetric. A false
|
||||
* NEGATIVE offers "Bokför" on a row that could also have been paired, and
|
||||
* booking it is a legitimate outcome. A false POSITIVE sends the user into a
|
||||
* picker with nothing in it, which is the state the whole page was in before.
|
||||
*/
|
||||
export function hasVoucherCandidate(
|
||||
amount: number,
|
||||
lines: readonly CandidateLine[],
|
||||
currency: string,
|
||||
): boolean {
|
||||
if (!Number.isFinite(amount) || amount === 0) return false
|
||||
const targetOre = Math.round(Math.abs(amount) * 100)
|
||||
return lines.some((line) => {
|
||||
const lineAmount = ledgerLineAmountIn(line, currency)
|
||||
if (lineAmount === null) return false
|
||||
// Opposite sign, or zero: cannot settle this row.
|
||||
if (amount > 0 ? lineAmount <= 0 : lineAmount >= 0) return false
|
||||
return Math.round(Math.abs(lineAmount) * 100) === targetOre
|
||||
})
|
||||
}
|
||||
@@ -298,7 +298,13 @@ export const REPORT_CATALOG: ReportDescriptor[] = [
|
||||
// which left the view to host its OWN fiscal-year selector inside a
|
||||
// loading-gated action bar: a render deadlock that hung the page on a
|
||||
// permanent skeleton (#771).
|
||||
params: 'fiscal',
|
||||
//
|
||||
// 'fiscal-range' since 2026-08-20: the view used to host its own "Datum
|
||||
// från / Datum till" inputs plus a Filtrera button, a second period control
|
||||
// competing with the header's räkenskapsår picker (convention 8). It now
|
||||
// uses the shared ReportDateRange like every other report, mounted with a
|
||||
// full-year default and its own preset memory (see FocusedReport).
|
||||
params: 'fiscal-range',
|
||||
},
|
||||
|
||||
// --- Export & arkiv: library-only ---
|
||||
|
||||
Reference in New Issue
Block a user