fix(reports): count reversed entries in bank/supplier/AR reconciliation (#734)
A reversed (storno-corrected) journal entry must be summed together with
its storno + correction, exactly as the trial balance and balance sheet
already do (.in('status', ['posted','reversed'])). The reconciliation
paths used posted-only and manufactured phantom differences.
- bank-reconciliation getReconciliationStatus: gl_1930_balance now counts
posted+reversed (so it equals the balansräkning for the account). Dropped
the correction_adjustment subtraction and the reversed-linked-tx drop — a
corrected/amount-corrected bank receipt now reconciles. The prior model
broke once correctEntry began re-pointing the bank transaction to the live
correction (the two changes were mutually inconsistent and produced a
difference equal to the corrected amount).
- supplier-reconciliation (2440) and ar-reconciliation (1510/1513): same
posted -> posted+reversed fix. Removes the false "Ej avstämd" gap a fully
paid, fully corrected company shows — the books net to 0 over posted+reversed
while a posted-only query double-counted the payment legs.
Also in this change:
- MCP gnubok_get_reconciliation_status: add account_number param (was hardcoded
to 1930; the lib already supported per-account reconciliation).
- counterparty-templates normalizeCounterpartyName: strip trailing month /
personal-initials tokens so "ngrok JW" / "Ngrok Mars" learn as one merchant.
- pending-operation reject 409 (route + MCP tool): clarify a resolved op was
approved explicitly (no auto-commit path exists) instead of a bare
"already committed".
- create_voucher: normalize description with String().trim() for consistency
with line_description.
- migration 20260625120000: backfill stripped diacritics on ~830 companies'
seeded chart-of-accounts names (Foretagskonto -> Företagskonto, etc.); the
seed function was fixed for new companies in 20260516130000 but never
backfilled. UPDATE-only and idempotent. Already applied to production.
Tests: 899 passing across the touched suites; lint + typecheck clean.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
fa4daf6f98
commit
e5f1d7a916
@@ -69,6 +69,23 @@ describe('counterparty-templates', () => {
|
||||
it('preserves meaningful content', () => {
|
||||
expect(normalizeCounterpartyName('HEMKÖP LINNÉ')).toBe('hemköp linné')
|
||||
})
|
||||
|
||||
it('collapses trailing personal initials and month labels to one merchant', () => {
|
||||
// Regression: three identical ngrok bookings ("ngrok JW", "Ngrok Mars",
|
||||
// "ngrok JW") splintered into separate counterparty names, so matching
|
||||
// never learned. They must all normalize to the same canonical merchant.
|
||||
expect(normalizeCounterpartyName('ngrok JW')).toBe('ngrok')
|
||||
expect(normalizeCounterpartyName('Ngrok Mars')).toBe('ngrok')
|
||||
expect(normalizeCounterpartyName('SPOTIFY januari')).toBe('spotify')
|
||||
expect(normalizeCounterpartyName('ICA MAXI AK')).toBe('ica maxi')
|
||||
})
|
||||
|
||||
it('does not strip multi-letter trailing words or 3+ letter brands', () => {
|
||||
// Conservative guard: only 1–2 char all-caps initials and month tokens go.
|
||||
expect(normalizeCounterpartyName('SWISH ANDERS JOHANSSON')).toBe('anders johansson')
|
||||
expect(normalizeCounterpartyName('NORDEA SEB')).toBe('nordea seb') // SEB is 3 chars — kept
|
||||
expect(normalizeCounterpartyName('KLARNA')).toBe('klarna') // single token kept
|
||||
})
|
||||
})
|
||||
|
||||
// ── Confidence ─────────────────────────────────────────────
|
||||
|
||||
@@ -22,12 +22,49 @@ import type { SIEVoucher } from '@/lib/import/types'
|
||||
|
||||
// ── Normalization ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Month tokens (Swedish + English, abbreviated and full) that show up as a
|
||||
* trailing period label on a bank-feed description ("Ngrok Mars", "Spotify
|
||||
* januari") rather than as part of the merchant's identity.
|
||||
*/
|
||||
const TRAILING_MONTH_TOKENS = new Set([
|
||||
'jan', 'feb', 'mar', 'apr', 'maj', 'may', 'jun', 'jul', 'aug', 'sep', 'sept',
|
||||
'okt', 'oct', 'nov', 'dec',
|
||||
'januari', 'februari', 'mars', 'april', 'juni', 'juli', 'augusti',
|
||||
'september', 'oktober', 'november', 'december',
|
||||
])
|
||||
|
||||
/**
|
||||
* Strip trailing tokens that label *when/who* rather than *what merchant*:
|
||||
* a month name, or a 1–2 letter all-caps personal initial ("ngrok JW",
|
||||
* "ngrok JW", "Ngrok Mars" all describe the same merchant). Without this, one
|
||||
* merchant splinters into many un-learnable variants and counterparty matching
|
||||
* never fires (the reported ngrok bug: three prior bookings, zero matches).
|
||||
*
|
||||
* Conservative by design: only acts on a TRAILING token, only on 1–2 char
|
||||
* all-caps initials (so 3-letter brands like SEB/ICA and any lowercased word
|
||||
* survive), and always keeps at least one core token (never strips to empty).
|
||||
*/
|
||||
function stripTrailingNoiseTokens(s: string): string {
|
||||
const tokens = s.trim().split(/\s+/).filter(Boolean)
|
||||
while (tokens.length > 1) {
|
||||
const last = tokens[tokens.length - 1]
|
||||
const isMonth = TRAILING_MONTH_TOKENS.has(last.toLowerCase())
|
||||
// Personal initials: 1–2 letters, all-caps in the ORIGINAL casing (run
|
||||
// before normalizeMerchantName lowercases everything).
|
||||
const isInitials = /^[A-ZÅÄÖ]{1,2}$/.test(last)
|
||||
if (!isMonth && !isInitials) break
|
||||
tokens.pop()
|
||||
}
|
||||
return tokens.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a transaction description to a canonical counterparty name.
|
||||
*
|
||||
* Strips bank transfer prefixes, trailing dates, invoice references,
|
||||
* and trailing digit sequences, then delegates to normalizeMerchantName()
|
||||
* for Swedish company suffix removal and lowercasing.
|
||||
* Strips bank transfer prefixes, trailing dates, invoice references, trailing
|
||||
* digit sequences, and trailing period/initials tokens, then delegates to
|
||||
* normalizeMerchantName() for Swedish company suffix removal and lowercasing.
|
||||
*/
|
||||
export function normalizeCounterpartyName(raw: string): string {
|
||||
const cleaned = raw
|
||||
@@ -42,7 +79,9 @@ export function normalizeCounterpartyName(raw: string): string {
|
||||
.replace(/\s+\d{4,}\s*$/g, '')
|
||||
.trim()
|
||||
|
||||
return normalizeMerchantName(cleaned)
|
||||
// Drop trailing month/initials tokens before merchant-name normalization so
|
||||
// "ngrok JW" and "Ngrok Mars" collapse to the same canonical "ngrok".
|
||||
return normalizeMerchantName(stripTrailingNoiseTokens(cleaned))
|
||||
}
|
||||
|
||||
// ── Confidence ─────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user