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:
Jakob Wennberg
2026-06-15 20:46:41 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent fa4daf6f98
commit e5f1d7a916
12 changed files with 445 additions and 109 deletions
@@ -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 ─────────────────────────────────────────────
+43 -4
View File
@@ -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 ─────────────────────────────────────────────────