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
@@ -828,60 +828,91 @@ describe('getReconciliationStatus', () => {
expect(status.gl_1930_period_movement).toBe(200)
})
it('nets a book-only correction (storno + rättelse) to a reconciled period', async () => {
// The reported bug: a correction made via the storno flow puts a posted
// storno AND a posted correction on 1930, while the original flips to
// 'reversed'. Both posted vouchers have no bank-feed counterpart. Before the
// fix they inflated the period movement and showed as omatchade
// verifikationer, manufacturing a phantom diff. They must be excluded from
// the movement so a fully-matched period reconciles.
it('reconciles a corrected bank receipt and keeps gl_1930_balance equal to the balance sheet', async () => {
// A +25000 deposit was booked to the wrong counter-account, then corrected
// via the storno flow: the original flips to 'reversed', a storno (credit
// 25000) and a correction (debit 25000) are posted, and correctEntry
// re-points the bank transaction to the live correction (je-corr).
//
// Reconciliation now sums posted+reversed on 1930 — exactly as the trial
// balance / balance sheet do — so the cluster nets to the true +25000 and
// the period reconciles. gl_1930_balance must equal what the balansräkning
// shows for 1930 (the bug this widget used to have was the two disagreeing).
const { supabase, enqueue } = createQueueMockSupabase()
// 1) transactions: one real matched outflow of -9908.75
// 1) transactions: the +25000 deposit, re-pointed to the correction
enqueue({
data: [{ amount: -9908.75, journal_entry_id: 'je-others', reconciliation_method: 'auto_exact' }],
data: [{ amount: 25000, journal_entry_id: 'je-corr', reconciliation_method: 'manual' }],
})
// 2) GL lines on 1930: the matched outflow, plus the correction cluster.
// Original (credit 25000) is status='reversed'; storno (debit 25000) and
// correction (debit 25000) are posted. None of the cluster is linked.
enqueue({
data: [
{ debit_amount: 0, credit_amount: 9908.75, journal_entries: { id: 'je-others', status: 'posted', source_type: 'bank_import' } },
{ debit_amount: 0, credit_amount: 25000, journal_entries: { id: 'je-orig', status: 'reversed', source_type: 'manual' } },
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-storno', status: 'posted', source_type: 'storno' } },
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-corr', status: 'posted', source_type: 'correction' } },
],
})
// 3) RPC: empty (migration excludes storno/correction; reversed isn't posted)
// 2) GL lines on 1930: reversed original (debit 25000), storno (credit
// 25000), correction (debit 25000). All three are summed.
const lines = [
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-orig', status: 'reversed', source_type: 'bank_transaction' } },
{ debit_amount: 0, credit_amount: 25000, journal_entries: { id: 'je-storno', status: 'posted', source_type: 'storno' } },
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-corr', status: 'posted', source_type: 'correction' } },
]
enqueue({ data: lines })
// 3) RPC: empty
enqueue({ data: [] })
const status = await getReconciliationStatus(supabase as never, 'company-1')
// Posted balance still includes the storno/correction (+50000) and the
// matched outflow (-9908.75); the reversed original is not posted.
expect(status.gl_1930_balance).toBe(40091.25)
// …but those +50000 are book-only and excluded from the period movement.
expect(status.gl_1930_correction_adjustment).toBe(50000)
expect(status.gl_1930_period_movement).toBe(-9908.75)
expect(status.bank_transaction_total).toBe(-9908.75)
// Balance-sheet-equivalent: posted+reversed summed = 25000 - 25000 + 25000.
const balanceSheet1930 = lines.reduce(
(s, l) => s + l.debit_amount - l.credit_amount,
0,
)
expect(status.gl_1930_balance).toBe(balanceSheet1930) // 25000 — matches BS
expect(status.gl_1930_correction_adjustment).toBe(0) // storno + correction net
expect(status.gl_1930_period_movement).toBe(25000)
expect(status.bank_transaction_total).toBe(25000)
expect(status.difference).toBe(0)
expect(status.is_reconciled).toBe(true)
})
it('does not create a new phantom when a matched deposit is later corrected', async () => {
// Case 2: a +25000 deposit was matched to an entry, then that entry was
// corrected. The deposit's link stays on the now-'reversed' original. The
// movement excludes the storno/correction, so to stay symmetric the deposit
// (linked to a reversed entry) must drop off the bank side too — otherwise
// we'd swap the old -50000 phantom for a +25000 one.
it('reconciles an amount correction even though the correction adjustment is non-zero', async () => {
// Regression for the de-reconcile bug: a 25000 receipt was booked as 24000,
// then corrected to 25000. The storno (credit 24000) and correction (debit
// 25000) net to +1000 on 1930, and correctEntry re-points the real 25000
// feed transaction to the correction. The OLD code subtracted that +1000
// correction bucket from the movement while still counting the re-pointed
// 25000 transaction → a phantom 1000 diff. The unified inclusion rule nets
// it correctly: gl_balance = 25000 = the feed, difference = 0.
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({
data: [{ amount: 25000, journal_entry_id: 'je-corr', reconciliation_method: 'manual' }],
})
enqueue({
data: [
{ debit_amount: 24000, credit_amount: 0, journal_entries: { id: 'je-orig', status: 'reversed', source_type: 'bank_transaction' } },
{ debit_amount: 0, credit_amount: 24000, journal_entries: { id: 'je-storno', status: 'posted', source_type: 'storno' } },
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-corr', status: 'posted', source_type: 'correction' } },
],
})
enqueue({ data: [] })
const status = await getReconciliationStatus(supabase as never, 'company-1')
expect(status.gl_1930_balance).toBe(25000)
expect(status.gl_1930_correction_adjustment).toBe(1000) // -24000 + 25000
expect(status.gl_1930_period_movement).toBe(25000)
expect(status.bank_transaction_total).toBe(25000)
expect(status.difference).toBe(0)
expect(status.is_reconciled).toBe(true)
})
it('reconciles a legacy deposit still linked to the reversed original (no special-case drop)', async () => {
// Pre-relink data: the +25000 deposit was matched, the entry corrected, but
// the transaction was never re-pointed and still references the reversed
// original. With posted+reversed summed on the GL side and NO reversed-link
// dropping on the bank side, this still nets to zero — symmetric without any
// special case.
const { supabase, enqueue } = createQueueMockSupabase()
// 1) transactions: the +25000 deposit, still linked to the reversed original
enqueue({
data: [{ amount: 25000, journal_entry_id: 'je-orig', reconciliation_method: 'manual' }],
})
// 2) GL lines: reversed original (debit 25000), storno (credit 25000),
// correction (debit 25000)
enqueue({
data: [
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-orig', status: 'reversed', source_type: 'bank_transaction' } },
@@ -889,15 +920,37 @@ describe('getReconciliationStatus', () => {
{ debit_amount: 25000, credit_amount: 0, journal_entries: { id: 'je-corr', status: 'posted', source_type: 'correction' } },
],
})
// 3) RPC: empty
enqueue({ data: [] })
const status = await getReconciliationStatus(supabase as never, 'company-1')
// Deposit excluded (linked to a reversed entry); cluster excluded from movement.
expect(status.bank_transaction_total).toBe(0)
expect(status.gl_1930_period_movement).toBe(0)
expect(status.bank_transaction_total).toBe(25000) // counted, not dropped
expect(status.gl_1930_period_movement).toBe(25000)
expect(status.difference).toBe(0)
expect(status.is_reconciled).toBe(true)
})
it('flags a book-only entry that moves the bank balance with no feed counterpart', async () => {
// Intentional behaviour: a manual posting that moves 1930 without a matching
// bank-feed transaction (e.g. interest the feed import missed, booked debit
// 1930 / credit 8310) is a genuine reconciliation break — the GL balance no
// longer matches the statement. It must surface as a difference, not be
// silently swept under a "correction" exclusion.
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: [] }) // no bank-feed transactions
enqueue({
data: [
{ debit_amount: 500, credit_amount: 0, journal_entries: { id: 'je-manual', status: 'posted', source_type: 'manual' } },
],
})
enqueue({ data: [] })
const status = await getReconciliationStatus(supabase as never, 'company-1')
expect(status.gl_1930_period_movement).toBe(500)
expect(status.bank_transaction_total).toBe(0)
expect(status.difference).toBe(-500)
expect(status.is_reconciled).toBe(false)
})
})
+56 -46
View File
@@ -41,24 +41,25 @@ export interface ReconciliationRunResult {
export interface ReconciliationStatus {
bank_transaction_total: number
/**
* @deprecated Use `gl_1930_period_movement` for the reconciliation diff. This
* field is preserved for back-compat with persisted status snapshots produced
* before the IB-exclusion change; new consumers reading this to compute the
* "real" difference will be off by the IB amount whenever a SIE-imported
* opening balance exists on 1930. The `difference` field on this interface
* is computed against `gl_1930_period_movement`, not this.
* The real ledger balance on the bank account, incl. IB — computed from the
* SAME `['posted','reversed']` lines the trial balance and balance sheet sum,
* so this value is identical to what the balansräkning reports for this
* account. (Use `gl_1930_period_movement` for the reconciliation diff, since
* this figure still includes the opening balance.)
*/
gl_1930_balance: number
/** Ledger movement on 1930 excluding opening_balance AND storno/correction
* lines — i.e. only movements that have a bank-feed counterpart. */
/** Ledger movement on the bank account excluding only opening_balance — i.e.
* the ledger balance minus IB. Storno/correction lines ARE included here
* (they're part of the balance), so a corrected bank line reconciles against
* its re-pointed feed transaction. This is what `difference` compares against. */
gl_1930_period_movement: number
/** IB on 1930 within the date range — surfaced separately so reconciliation
* doesn't treat it as an unmatched bank transaction. */
/** IB on the bank account within the date range — surfaced separately so
* reconciliation doesn't treat it as an unmatched bank transaction. */
gl_1930_opening_balance: number
/** Net of posted storno/correction lines on 1930 within the date range.
* Excluded from gl_1930_period_movement (and from the unmatched-voucher set)
* because a book-only correction has no counterpart in the bank feed. Surfaced
* separately so the UI can explain why a corrected period still reconciles. */
/** Net of posted storno/correction lines on the bank account within the date
* range. INFORMATIONAL ONLY — it is part of the ledger balance and is included
* in gl_1930_period_movement, not subtracted from it. Surfaced so the UI can
* show how much of the period's movement came from corrections. */
gl_1930_correction_adjustment: number
/** bankTotal − gl_1930_period_movement. Zero when every period transaction is matched. */
difference: number
@@ -363,13 +364,16 @@ export async function getReconciliationStatus(
const { data: transactions } = await txQuery
// Get GL bank account lines. Pull id/status/source_type from the join so we
// can (a) split out lines that have no bank-feed counterpart — opening_balance
// (prior year's closing balance) and storno/correction (book-only corrections)
// — and (b) identify reversed originals, whose still-linked bank transactions
// are superseded by the correction and must drop off the bank side too.
// 'reversed' is fetched alongside 'posted' precisely to resolve those links;
// reversed lines are NOT counted in any movement total.
// Get GL bank-account lines. We fetch posted AND reversed entries and count
// them TOGETHER — the exact inclusion rule the trial balance and balance sheet
// use (see lib/reports/trial-balance.ts, which sums `['posted','reversed']`).
// A reversed original stays in the ledger and is cancelled by its storno, so
// both legs must be summed; counting only the storno would leave a dangling
// half-correction. Using the identical rule here is what guarantees
// gl_1930_balance can never disagree with the balansräkning for this account —
// the headline bug this widget had (a corrected bank receipt showed one figure
// here and a different one on the balance sheet). source_type is still pulled
// so we can split out the opening balance and surface correction activity.
let glQuery = supabase
.from('journal_entry_lines')
.select('debit_amount, credit_amount, journal_entries!inner(id, company_id, entry_date, status, source_type)')
@@ -399,41 +403,47 @@ export async function getReconciliationStatus(
return (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
}
const allLines = (glLines || []) as GlLineRow[]
const postedLines = allLines.filter((l) => entryOf(l)?.status === 'posted')
// posted + reversed = the ledger balance, exactly as the trial balance counts
// it. The .in() filter on the query already excludes draft/cancelled.
const countedLines = (glLines || []) as GlLineRow[]
// Reversed originals retain their bank-transaction link (the storno flow never
// re-points it), so a transaction pointing at one is a superseded booking —
// drop it from the bank side to keep the comparison symmetric with the
// movement, which excludes the matching storno/correction below.
const reversedEntryIds = new Set<string>(
allLines
.filter((l) => entryOf(l)?.status === 'reversed')
.map((l) => entryOf(l)?.id)
.filter((id): id is string => Boolean(id))
// Bank side: every feed transaction in range, full stop. We deliberately do
// NOT special-case rows linked to a reversed entry any more. Because the GL
// side now counts the reversed original, its storno AND the correction
// together (just like the balance sheet), a corrected bank line nets to its
// true amount on both sides and reconciles on its own — whether correctEntry
// re-pointed the transaction to the live corrected entry or a legacy row still
// points at the reversed original, the result is identical. The previous
// "drop reversed-linked transactions" rule paired with the now-removed
// correction subtraction below; together with correctEntry re-pointing the
// transaction they manufactured a difference equal to a corrected amount.
const bankTotal = (transactions || []).reduce(
(sum, tx) => sum + (Number(tx.amount) || 0),
0
)
// Calculate totals. Exclude transactions whose linked entry was reversed —
// their booking lives on in the correction, which is itself excluded from the
// movement, so counting the transaction would resurrect a phantom diff.
const bankTotal = (transactions || []).reduce((sum, tx) => {
if (tx.journal_entry_id && reversedEntryIds.has(tx.journal_entry_id)) return sum
return sum + (Number(tx.amount) || 0)
}, 0)
// gl_1930_balance keeps its historical meaning: the posted balance incl. IB.
const glBalance = postedLines.reduce((sum, line) => sum + lineAmount(line), 0)
const glOpeningBalance = postedLines
// gl_1930_balance: the real ledger balance on this account incl. IB —
// byte-for-byte the figure the balansräkning / saldobalans report.
const glBalance = countedLines.reduce((sum, line) => sum + lineAmount(line), 0)
// IB is last year's closing position, not a movement with a bank-feed
// counterpart — surfaced separately and excluded from the period movement.
const glOpeningBalance = countedLines
.filter((l) => entryOf(l)?.source_type === 'opening_balance')
.reduce((sum, line) => sum + lineAmount(line), 0)
const glCorrectionAdjustment = postedLines
// Net storno/correction activity on the account this period. Surfaced for
// transparency ONLY — it is part of the ledger balance and is INCLUDED in the
// movement, never subtracted. (Subtracting it while still counting the
// re-pointed bank transaction is exactly what produced the old phantom diff.)
const glCorrectionAdjustment = countedLines
.filter((l) => {
const st = entryOf(l)?.source_type
return st === 'storno' || st === 'correction'
})
.reduce((sum, line) => sum + lineAmount(line), 0)
// Period movement = only the lines that have a bank-feed counterpart.
const glPeriodMovement = glBalance - glOpeningBalance - glCorrectionAdjustment
// Period movement = the ledger balance minus the opening balance. Everything
// else (real bookings, stornos and corrections alike) has — or should have —
// a bank-feed counterpart, so it stays in.
const glPeriodMovement = glBalance - glOpeningBalance
const matchedCount = (transactions || []).filter(
(tx) => tx.journal_entry_id !== null