Files
accounted/lib/reports/supplier-reconciliation.ts
T
Jakob WennbergandClaude Opus 4.8 e5f1d7a916 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>
2026-06-15 20:46:41 +02:00

110 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { SupabaseClient } from '@supabase/supabase-js'
import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
export interface ReconciliationResult {
supplier_ledger_total: number
account_2440_balance: number
difference: number
is_reconciled: boolean
/**
* Number of foreign-currency invoices that lacked an exchange_rate, so their
* remaining_amount could not be converted to SEK. When > 0 the difference
* field may be misleading: any reported gap could be missing-data rather
* than a true reconciliation break.
*/
unconverted_fx_count: number
}
/**
* Compare sum of open supplier invoices against account 2440 balance.
*
* Conversion uses each invoice's stored exchange_rate (the invoice-date rate),
* which matches what was originally posted to 2440. This means the report will
* diverge from the GL once partial payments settle at a different rate (the
* delta is correctly booked as valutakursvinst/-förlust to 3960/7960 per
* ML 8 kap 21–23 §). A subledger-derived total would reconcile through that
* difference; deferred to a follow-up.
*/
export async function generateReconciliation(
supabase: SupabaseClient,
companyId: string,
periodId: string
): Promise<ReconciliationResult> {
// remaining_amount is stored in invoice currency; account 2440 is in SEK
// (booked at invoice-date rate), so convert each row before summing.
const { data: invoices } = await supabase
.from('supplier_invoices')
.select('remaining_amount, currency, exchange_rate')
.eq('company_id', companyId)
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
let unconvertedFxCount = 0
const supplierLedgerTotal = (invoices || [])
.reduce((sum, inv) => {
const isFx = inv.currency && inv.currency !== 'SEK'
const hasRate = inv.exchange_rate != null && Number(inv.exchange_rate) > 0
// Skip unconvertible FX rows from the sum — adding raw foreign amounts
// to a SEK total is arithmetically unsound. Counted instead.
if (isFx && !hasRate) {
unconvertedFxCount += 1
return sum
}
const sek = resolveSekAmount(
Number(inv.remaining_amount) || 0,
null,
inv.currency,
inv.exchange_rate
)
return Math.round((sum + sek) * 100) / 100
}, 0)
// Get account 2440 balance from the ledger in this period. We count posted
// AND reversed entries together — the SAME inclusion rule the trial balance /
// balance sheet use. A corrected supplier invoice flips its original
// registration to status='reversed' (storno-service.ts); that reversed credit
// on 2440 is cancelled by the posted storno's debit, so BOTH legs must be
// summed or the report double-counts the payment debit and shows a phantom
// debit balance. (This is exactly the false −41 121,25 kr "Ej avstämd" gap a
// fully-paid, fully-corrected company hit: posted-only = −41 121,25, but
// posted+reversed = 0, matching the leverantörsreskontra.)
const { data: journalLines } = await supabase
.from('journal_entry_lines')
.select(`
debit_amount,
credit_amount,
journal_entry:journal_entries!inner(
status,
company_id,
fiscal_period_id
)
`)
.eq('account_number', '2440')
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', periodId)
.in('journal_entries.status', ['posted', 'reversed'])
// Account 2440 is a liability: credit normal balance
// Balance = credits - debits
let account2440Balance = 0
if (journalLines) {
for (const line of journalLines) {
account2440Balance = Math.round((account2440Balance + (line.credit_amount || 0) - (line.debit_amount || 0)) * 100) / 100
}
}
const difference = Math.round((supplierLedgerTotal - account2440Balance) * 100) / 100
return {
supplier_ledger_total: Math.round(supplierLedgerTotal * 100) / 100,
account_2440_balance: Math.round(account2440Balance * 100) / 100,
difference,
// BFL 5 kap requires the reconciliation to cover all affärshändelser. If
// any row was excluded for a missing exchange rate, the calculation is
// incomplete by construction and we cannot honestly stamp the period
// Avstämd — the user must fix the underlying data first.
is_reconciled: Math.abs(difference) < 0.01 && unconvertedFxCount === 0,
unconverted_fx_count: unconvertedFxCount,
}
}