diff --git a/DECISIONS.md b/DECISIONS.md index 93d2e45d..0ffd14b3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1107,3 +1107,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-20] Bokio getCompany accepts both the spec envelope and the live flat body: the published v1 spec (bokio/bokio-api company-api.yaml) wraps company-information in `companyInformation`, but api.bokio.se/v1 returned the company object flat on a 200 in prod (BokioResponseError in logs, customer script showed the same). Tolerating both instead of picking one means a spec/live drift in either direction can no longer turn a valid integration token into a connection failure. [2026-08-20] Detail pages (kundfaktura first, then the stale card-pile siblings: leverantörsfaktura, verifikat, kreditfaktura, avyttring, lön) adopt the register-detail document grammar from #1624 instead of card stacks: DetailSection/DefRow groups, one status element per the list pages' chips-mark-exceptions rule, one primary next step plus "Förhandsgranska" visible and everything else behind a ⋯ overflow menu, the line table on the dry-table idiom with the headline total in the serif. Considered keeping a two-column card sidebar with fewer cards; rejected because the card border carried no hierarchy the hairline kicker does not already carry, and a second column of stacked boxes is exactly what the founder called clutter. +[2026-08-20] The bank-reconciliation card leads with `unexplained_difference` (difference minus the two unmatched-list totals) instead of the raw `difference`. Every krona of the difference is, by construction, (unmatched bank rows) - (unmatched vouchers), so the raw figure alarms about ordinary mid-year backlog while saying nothing; the residual is the only part that can mean something is wrong. Verified on prod: Arcim 1930 2025-07-17..2026-08-20 shows 70 884,49 difference and exactly 0,00 residual. +[2026-08-20] `unmatched_gl_line_total` is null (not 0) on a foreign cash account: get_account_gl_lines_for_matching projects neither currency nor amount_in_currency, so its rows carry no amount in the account's currency. The card falls back to the flat figures there rather than showing a bridge whose middle row would silently be a SEK number in a EUR column. +[2026-08-20] getReconciliationStatus now passes effectiveFrom (the IB floor) to fetchGLLinesForMatching instead of the caller's raw dateFrom. countedLines and countedTx were already clamped to that floor, so a window opening before the account's opening balance (the v1 endpoint's default, or any multi-year range) counted vouchers from a period whose movements the reconciliation deliberately drops. +[2026-08-20] A non-zero unexplained difference is stated factually, never in destructive red. Measured on prod over the 206 single-1930-account companies with >=10 transactions: 136 are exactly 0,00 and 63 are >=100 kr out, and the dominant cause is ledger lines the candidate RPC hides (posted/storno on 127 companies, reversed/bank_transaction on 66, posted/correction on 49) rather than user error. Surfacing those hidden lines is tracked as follow-up work, not fixed here. diff --git a/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts b/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts index f431b43e..f98b1e34 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/bank/status/route.ts @@ -30,12 +30,21 @@ const StatusResponse = z.object({ gl_1930_opening_balance: z.number(), /** Net storno/correction activity in the window. Informational; included in the movement. */ gl_1930_correction_adjustment: z.number(), - /** bank_transaction_total − gl_1930_period_movement. */ + /** bank_transaction_total minus gl_1930_period_movement: how far apart the two + * sides stand. Expected to be large mid-year; see unexplained_difference. */ difference: z.number(), is_reconciled: z.boolean(), matched_count: z.number().int(), unmatched_transaction_count: z.number().int(), + /** Sum of the unmatched bank transactions: one of the two components of difference. */ + unmatched_transaction_total: z.number(), unmatched_gl_line_count: z.number().int(), + /** Sum of the unmatched ledger lines, signed like a bank movement. null on a + * foreign account, whose candidate lines carry no amount in that currency. */ + unmatched_gl_line_total: z.number().nullable(), + /** difference - unmatched_transaction_total + unmatched_gl_line_total: what is + * left once both work lists are accounted for. null when the GL total is. */ + unexplained_difference: z.number().nullable(), }) registerEndpoint({ @@ -53,6 +62,7 @@ registerEndpoint({ 'A non-zero difference is normal between sync runs (uncleared cheques, in-flight transfers). Investigate only if it persists across reconciliations.', 'difference compares against gl_1930_period_movement (movement excl. opening balance), NOT gl_1930_balance. Do not display gl_1930_balance next to difference.', 'is_reconciled means |difference| < 0.01 for the window, an aggregate check, not a per-transaction guarantee.', + 'Judge health on unexplained_difference, NOT on difference. difference is just the gap between the two sides and is expected to be large mid-year; it is fully explained while every krona of it sits in unmatched_transaction_total or unmatched_gl_line_total. A non-zero unexplained_difference is the real finding: a matched pair disagreeing in amount, a voucher with several lines on the account, or a storno/correction line the candidate list hides.', 'Ignored transactions are excluded from bank_transaction_total and difference (they never get a ledger counterpart); their count and sum are reported separately.', ], example: { @@ -69,7 +79,10 @@ registerEndpoint({ is_reconciled: true, matched_count: 142, unmatched_transaction_count: 3, + unmatched_transaction_total: 1250, unmatched_gl_line_count: 2, + unmatched_gl_line_total: 1250, + unexplained_difference: 0, }, meta: { request_id: 'req_…', api_version: '2026-05-12' }, }, diff --git a/components/reports/BankReconciliationView.tsx b/components/reports/BankReconciliationView.tsx index a083b5d8..14aa8ede 100644 --- a/components/reports/BankReconciliationView.tsx +++ b/components/reports/BankReconciliationView.tsx @@ -1,7 +1,7 @@ 'use client' import Link from 'next/link' -import { useState, useEffect, useCallback, useRef } from 'react' +import { Fragment, useState, useEffect, useCallback, useRef } from 'react' import { useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' @@ -11,6 +11,8 @@ import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Switch } from '@/components/ui/switch' import { Skeleton } from '@/components/ui/skeleton' +import { Progress } from '@/components/ui/progress' +import { InfoTooltip } from '@/components/ui/info-tooltip' import { EmptyState } from '@/components/ui/empty-state' import { AttnLine } from '@/components/ui/attn-line' import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table' @@ -43,6 +45,27 @@ function formatAmount(amount: number): string { return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } +/** A bridge step, always carrying its sign so the column reads as a running + * adjustment. Only the plus is added: Intl already renders negatives with a + * real minus sign (U+2212), and prefixing an ASCII hyphen to an absolute value + * would put a different glyph in this column than every other amount on the + * page. */ +function formatSigned(amount: number, currency?: string): string { + const rendered = formatCurrency(amount, currency) + return amount > 0 ? `+${rendered}` : rendered +} + +/** Anchors for the bridge rows: clicking a step scrolls to the list it names. + * The dashboard panel is the scroll container, so scrollIntoView (which walks + * up to the nearest scrollable ancestor) is correct here and window.scrollTo + * would not be. */ +const UNMATCHED_TX_SECTION_ID = 'recon-unmatched-transactions' +const UNMATCHED_GL_SECTION_ID = 'recon-unmatched-gl-lines' + +function scrollToSection(id: string) { + document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) +} + const METHOD_LABELS: Record = { auto_exact: 'Exakt matchning', auto_date_range: 'Datumintervall', @@ -130,7 +153,15 @@ interface ReconciliationStatus { is_reconciled: boolean matched_count: number unmatched_transaction_count: number + /** Sum behind unmatched_transaction_count: one leg of the bridge. */ + unmatched_transaction_total: number unmatched_gl_line_count: number + /** Sum behind unmatched_gl_line_count, signed like a bank movement. null on a + * foreign account whose candidate lines carry no amount in that currency. */ + unmatched_gl_line_total: number | null + /** What is left of the difference once both lists are accounted for; null + * whenever unmatched_gl_line_total is. */ + unexplained_difference: number | null } interface UnmatchedTransaction { @@ -280,6 +311,10 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank // MatchVoucherDialog gets on the Transactions page. Keyed by transaction id; // cleared whenever the lists refetch (the candidate set may have changed). const [rankedCandidates, setRankedCandidates] = useState>({}) + /** The one unmatched row whose match picker is open. Single-open by design: + * the picker is a heavy control (it fetches ranked candidates per row), and + * rendering one per row is exactly what made this list unusable. */ + const [expandedTxId, setExpandedTxId] = useState(null) const rankedFetchInFlight = useRef>(new Set()) // Bumped whenever fetchAll clears the ranked cache: an in-flight ranked // response from before the clear must not repopulate the fresh cache, or @@ -701,6 +736,18 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank [accountNumber, includeMatched, rankedCandidates, appliedDates], ) + /** Open one row's match picker (closing any other) and fetch its ranked + * candidates. The fetch used to hang off onFocusCapture on an always-rendered + * picker; it now runs on expand, which is the same moment the user asks for + * candidates but only for rows they actually open. */ + const toggleExpandedTx = useCallback( + (transactionId: string) => { + setExpandedTxId((current) => (current === transactionId ? null : transactionId)) + void ensureRankedCandidates(transactionId) + }, + [ensureRankedCandidates], + ) + const handleManualLink = async (transactionId: string) => { const journalEntryId = selectedMatch[transactionId] if (!journalEntryId) return @@ -731,6 +778,7 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank delete next[transactionId] return next }) + setExpandedTxId((current) => (current === transactionId ? null : current)) toast({ variant: 'success', title: 'Transaktionen matchades mot verifikationen' }) await fetchAll({ silent: true }) } @@ -1010,6 +1058,51 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank ) } + // Bridge derivations. `unexplained_difference` is null exactly when the GL + // side cannot be expressed in the account's currency (a foreign account: the + // candidate RPCs project no FX columns), and the card falls back to the flat + // figures rather than showing a bridge whose middle row has no honest amount. + const bridgeDerivable = status?.unexplained_difference != null + const reconcilableCount = status + ? status.matched_count + status.unmatched_transaction_count + : 0 + const matchedPercent = + reconcilableCount > 0 ? Math.round((status!.matched_count / reconcilableCount) * 100) : 0 + + // What the reconciliation leaves out, as one line instead of three stacked + // paragraphs. Amounts stay on screen (BFL: the user must be able to see what + // was excluded); the legal reasoning moved into the tooltip beside them. + const excludedItems: string[] = [] + if (status) { + if (status.gl_1930_opening_balance !== 0) { + excludedItems.push( + t('recon_excl_ib', { amount: formatCurrency(status.gl_1930_opening_balance) }) + ) + } + if (status.ignored_transaction_count > 0) { + excludedItems.push( + t('recon_excl_ignored', { + count: status.ignored_transaction_count, + amount: formatCurrency(status.ignored_transaction_total, accountCurrency), + }) + ) + } + } + const exclusionNotes: string[] = [] + if (excludedItems.length > 0) { + exclusionNotes.push(t('recon_excl_prefix', { items: excludedItems.join(', ') })) + } + // Corrections are the opposite case: INCLUDED, exactly as on the balance + // sheet. Stated here because a large correction figure is the single most + // common "why is the booked amount so big" question on this card. + if (status && status.gl_1930_correction_adjustment !== 0) { + exclusionNotes.push( + t('recon_excl_corrections', { + amount: formatCurrency(status.gl_1930_correction_adjustment), + }) + ) + } + return (
{error && ( @@ -1030,72 +1123,152 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank
Avstämning mot + {/* Convention 5: chips mark exceptions. Being mid-year and not yet + reconciled is the NORMAL state, so a permanent destructive + "Ej avstämd" badge marked nothing and just manufactured alarm. + Fully reconciled is the state worth marking. */} {status.is_reconciled ? ( - Avstämd + Avstämd ) : ( - Ej avstämd + + {t('recon_open_items', { + count: + status.unmatched_transaction_count + status.unmatched_gl_line_count, + })} + )}
-
-
- Banktransaktioner i perioden - - {formatCurrency(status.bank_transaction_total, accountCurrency)} - -
- {/* GL-side figures (bokfört, IB, rättelser, differens) stay in - SEK: journal entries are booked in SEK regardless of the - cash account's currency. Only the bank-feed total above is in - the account's own currency. */} -
- Bokfört på i perioden - - {formatCurrency(status.gl_1930_period_movement)} - -
-
- Differens - - {formatCurrency(status.difference)} - -
- {status.ignored_transaction_count > 0 && ( -

- {t('recon_ignored_note', { - count: status.ignored_transaction_count, - amount: formatCurrency(status.ignored_transaction_total, accountCurrency), - })} -

+
+ {/* Reconciliation is a count-down-to-zero task; the only progress + signal used to be three comma-separated numbers in 12px grey. */} + {reconcilableCount > 0 && ( +
+ +
+ + {t('recon_progress', { + matched: status.matched_count, + total: reconcilableCount, + })} + + {matchedPercent} % +
+
)} - {status.gl_1930_opening_balance !== 0 && ( -

- Ingående balans (IB) på :{' '} + + {/* THE BRIDGE. The old card printed the bank total, the ledger + total and a red difference, leaving the user to work out what + the difference consisted of: the page already knew, exactly. + Every krona of it is (unmatched bank rows) - (unmatched + vouchers), so the two middle rows both explain the number AND + navigate to the list that resolves them. Only the residual + after those two can mean something is actually wrong. + Falls back to the flat figures when the residual is not + derivable (a foreign account: see unmatched_gl_line_total). */} +

+
+ Banktransaktioner i perioden - {formatCurrency(status.gl_1930_opening_balance)} + {formatCurrency(status.bank_transaction_total, accountCurrency)} + +
+ + {bridgeDerivable && ( + <> + {status.unmatched_transaction_count > 0 && ( + + )} + {status.unmatched_gl_line_count > 0 && ( + + )} + + )} + + {/* GL-side figures (bokfört, IB, rättelser, differens) stay in + SEK: journal entries are booked in SEK regardless of the + cash account's currency. Only the bank-feed total above is in + the account's own currency. */} +
+ + Bokfört på i perioden - , räknas inte i avstämningen. -

- )} - {status.gl_1930_correction_adjustment !== 0 && ( -

- Varav rättelser och stornon på i perioden:{' '} - {formatCurrency(status.gl_1930_correction_adjustment)} + {formatCurrency(status.gl_1930_period_movement)} - , ingår i det bokförda beloppet och i avstämningen, precis som i balansräkningen. +

+ + {bridgeDerivable ? ( +
+ + {t('recon_unexplained_label')} + + + + {formatCurrency(status.unexplained_difference ?? 0, accountCurrency)} + +
+ ) : ( +
+ Differens + + {formatCurrency(status.difference)} + +
+ )} + + {bridgeDerivable && Math.abs(status.unexplained_difference ?? 0) >= 0.01 && ( +

+ {t('recon_unexplained_note')} +

+ )} +
+ + {/* What the reconciliation deliberately leaves out. Three stacked + paragraphs of legal prose used to sit in the card; the amounts + stay visible (they are compliance-relevant) but the reasoning + moved behind the tooltip. */} + {exclusionNotes.length > 0 && ( +

+ {exclusionNotes.join(' ')} +

)} -
- Matchade: {status.matched_count} - Omatchade transaktioner: {status.unmatched_transaction_count} - Omatchade verifikationer: {status.unmatched_gl_line_count} -
@@ -1234,7 +1407,7 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank {/* Unmatched Transactions */} {unmatchedTx.length > 0 && ( -
+

Omatchade transaktioner ({unmatchedTx.length}) @@ -1260,193 +1433,237 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank Visar de senaste 500 transaktionerna: begränsa datumintervallet för att se fler.

)} -
- {unmatchedTx.map((tx) => { - const isPositive = tx.amount > 0 - // Quick-book options matching the transaction's direction. The - // bank leg books to the SELECTED account (the categorize endpoint - // rewrites it from the cash_account_id), so these are correct on - // any account, not just 1930. - const quickBooks = QUICK_BOOK_TEMPLATES.filter((t) => - isPositive ? t.direction === 'income' : t.direction === 'expense', - ) - // Other enabled cash accounts this row could move to. Same - // currency only: the server hard-rejects a cross-currency move - // (the row would vanish from every report's currency scope). - const moveTargets = cashAccounts.filter( - (a) => - a.enabled && - a.ledger_account !== accountNumber && - a.currency.toUpperCase() === tx.currency.toUpperCase(), - ) - return ( -
- {/* Header row: meta + description + amount + menu */} -
-
-
- {formatDate(tx.date)} - · - {tx.currency} - {tx.reference && ( - <> - · - Ref: {tx.reference} - - )} -
-
{tx.description}
-
-
-

)} {/* Unmatched GL Lines */} {unmatchedGlLines.length > 0 && ( -
+

Omatchade verifikationer på ({unmatchedGlLines.length})

@@ -1535,7 +1752,7 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank Beskrivning Valuta Belopp - + diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 2914eed6..4f370384 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -9611,7 +9611,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_get_reconciliation_status', title: 'Bank Reconciliation Status', - description: 'Bank reconciliation for one cash account: matched/unmatched counts, bank vs ledger balance, difference. Defaults to 1930, or the primary cash account if there is no 1930; pass account_number for 1940/1932 etc. Optional date range.', + description: 'Bank reconciliation for one cash account: matched/unmatched counts and totals. Judge health on unexplained_difference, not difference (large mid-year by design). Defaults to 1930, else the primary cash account; pass account_number for 1940/1932. Optional date range.', inputSchema: { type: 'object', additionalProperties: false, diff --git a/lib/reconciliation/__tests__/bank-reconciliation.test.ts b/lib/reconciliation/__tests__/bank-reconciliation.test.ts index 70f3bc58..37d1d986 100644 --- a/lib/reconciliation/__tests__/bank-reconciliation.test.ts +++ b/lib/reconciliation/__tests__/bank-reconciliation.test.ts @@ -1416,6 +1416,132 @@ describe('getReconciliationStatus', () => { expect(status.bank_transaction_total).toBe(0) }) + it('decomposes the difference into the two work lists (unexplained residual 0)', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + + // Bank side: one matched 1000 deposit + one unbooked 500 deposit. + enqueue({ + data: [ + { amount: 1000, journal_entry_id: 'je-matched', reconciliation_method: 'auto_exact' }, + { amount: 500, journal_entry_id: null, reconciliation_method: null }, + ], + }) + // GL side: the matched 1000 debit + a 300 credit voucher with no bank row. + enqueue({ + data: [ + { id: 'je-matched', status: 'posted', source_type: 'bank_transaction' }, + { id: 'je-lonely', status: 'posted', source_type: 'manual' }, + ], + }) + enqueue({ + data: [ + { debit_amount: 1000, credit_amount: 0, journal_entry_id: 'je-matched' }, + { debit_amount: 0, credit_amount: 300, journal_entry_id: 'je-lonely' }, + ], + }) + // Candidate RPC: the lonely voucher is the one unmatched GL line. + enqueue({ + data: [ + { + line_id: 'l-lonely', + journal_entry_id: 'je-lonely', + debit_amount: 0, + credit_amount: 300, + entry_date: '2026-03-01', + voucher_number: 7, + voucher_series: 'A', + entry_description: 'Avgift', + source_type: 'manual', + linked_transaction_count: 0, + }, + ], + }) + + const status = await getReconciliationStatus(supabase as never, 'company-1') + + expect(status.bank_transaction_total).toBe(1500) + expect(status.gl_1930_period_movement).toBe(700) + expect(status.difference).toBe(800) + // The two lists the user can actually open... + expect(status.unmatched_transaction_total).toBe(500) + expect(status.unmatched_gl_line_total).toBe(-300) + // ...account for every krona of it: 800 - 500 + (-300) = 0. + expect(status.unexplained_difference).toBe(0) + }) + + it('surfaces a residual when a matched pair disagrees in amount', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + + // The bank moved 1000 but the voucher it is matched to books only 900: + // nothing sits in either work list, yet the sides do not agree. This is the + // finding `difference` alone can never distinguish from ordinary backlog. + enqueue({ + data: [{ amount: 1000, journal_entry_id: 'je-short', reconciliation_method: 'manual' }], + }) + enqueue({ data: [{ id: 'je-short', status: 'posted', source_type: 'bank_transaction' }] }) + enqueue({ data: [{ debit_amount: 900, credit_amount: 0, journal_entry_id: 'je-short' }] }) + enqueue({ data: [] }) + + const status = await getReconciliationStatus(supabase as never, 'company-1') + + expect(status.difference).toBe(100) + expect(status.unmatched_transaction_total).toBe(0) + expect(status.unmatched_gl_line_total).toBe(0) + expect(status.unexplained_difference).toBe(100) + }) + + it('reports no residual on a foreign account whose candidate lines carry no FX amount', async () => { + const { supabase, enqueue } = createQueueMockSupabase() + + // EUR account. The candidate RPC projects neither currency nor + // amount_in_currency, so its lines cannot be expressed in EUR: summing the + // raw SEK columns would claim a EUR figure that is off by the rate. + enqueue({ data: [{ amount: 100, journal_entry_id: null, reconciliation_method: null }] }) + enqueue({ data: [{ id: 'je-eur', status: 'posted', source_type: 'manual' }] }) + enqueue({ + data: [ + { + debit_amount: 1150, + credit_amount: 0, + currency: 'EUR', + amount_in_currency: 100, + journal_entry_id: 'je-eur', + }, + ], + }) + enqueue({ + data: [ + { + line_id: 'l-eur', + journal_entry_id: 'je-eur', + debit_amount: 1150, + credit_amount: 0, + entry_date: '2026-03-01', + voucher_number: 8, + voucher_series: 'A', + entry_description: 'EU-faktura', + source_type: 'manual', + linked_transaction_count: 0, + }, + ], + }) + + const status = await getReconciliationStatus( + supabase as never, + 'company-1', + undefined, + undefined, + '1932', + 'EUR', + ) + + // Never 0: that would assert the vouchers net to nothing. + expect(status.unmatched_gl_line_total).toBeNull() + expect(status.unexplained_difference).toBeNull() + // The count still works; only the sum is withheld. + expect(status.unmatched_gl_line_count).toBe(1) + }) + 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 diff --git a/lib/reconciliation/bank-reconciliation.ts b/lib/reconciliation/bank-reconciliation.ts index f2d9aa0e..905c7c86 100644 --- a/lib/reconciliation/bank-reconciliation.ts +++ b/lib/reconciliation/bank-reconciliation.ts @@ -158,7 +158,52 @@ export interface ReconciliationStatus { is_reconciled: boolean matched_count: number unmatched_transaction_count: number + /** + * Sum of the unmatched bank transactions behind `unmatched_transaction_count`, + * in `currency`. Together with {@link unmatched_gl_line_total} this decomposes + * `difference` into the two work lists the user can actually open, instead of + * leaving it an unexplained scalar. + */ + unmatched_transaction_total: number unmatched_gl_line_count: number + /** + * Sum of the unmatched ledger lines behind `unmatched_gl_line_count`, in + * `currency`, signed like a bank movement (+ in, - out). + * + * `null` when at least one of those lines carries no amount in `currency`: + * the two candidate RPCs project neither `currency` nor `amount_in_currency`, + * so on a foreign account every line is unconvertible and there is no honest + * sum to report. Reporting 0 there would claim the vouchers net to nothing. + * Always a number on a SEK account (see {@link ledgerLineAmountIn}). + */ + unmatched_gl_line_total: number | null + /** + * What is left of `difference` once both work lists are accounted for: + * `difference - unmatched_transaction_total + unmatched_gl_line_total`. + * + * `difference` is merely how far apart the two sides currently stand; mid-year + * it is expected to be large and it is fully explained as long as every krona + * of it sits in one of the two lists. The residual is what does NOT. + * + * It reduces to (sum of matched transactions - sum of the ledger lines they + * settle), so it is non-zero when a matched pair disagrees in amount, when one + * voucher carries several lines on this account, or when a ledger line the + * candidate RPC hides has no bank counterpart. That last cause dominates: + * `get_account_gl_lines_for_matching` returns only `status='posted'` entries + * and excludes storno / correction outright, so an unlinked storno moves this + * account's movement while staying invisible in "Omatchade verifikationer". + * Measured on prod 2026-08-20 over the 206 single-1930-account companies with + * >=10 transactions: 136 reconcile to exactly 0,00, 63 land >=100 kr out, and + * the unlinked-hidden-line buckets behind that are posted/storno (127 + * companies), reversed/bank_transaction (66) and posted/correction (49). + * + * A non-zero residual is therefore a real finding but usually NOT user error, + * so the UI states it factually rather than in destructive red. + * + * `null` whenever `unmatched_gl_line_total` is null: no honest residual + * exists then either. + */ + unexplained_difference: number | null /** Counted ledger lines on the account that carry no amount in `currency` * (see {@link ledgerLineAmountIn}). Always 0 on a SEK account. */ unconvertible_gl_line_count: number @@ -877,9 +922,12 @@ export async function getReconciliationStatus( // rows behind bank_transaction_total. const matchedCount = reconcilableTx.filter((tx) => tx.journal_entry_id !== null).length - const unmatchedTransactionCount = reconcilableTx.filter( - (tx) => tx.journal_entry_id === null - ).length + const unmatchedTx = reconcilableTx.filter((tx) => tx.journal_entry_id === null) + const unmatchedTransactionCount = unmatchedTx.length + const unmatchedTransactionTotal = unmatchedTx.reduce( + (sum, tx) => sum + (Number(tx.amount) || 0), + 0 + ) // Unmatched GL lines count (RPC excludes opening_balance, storno and correction // since 20260601120000_unlinked_gl_lines_exclude_storno_correction.sql). @@ -887,10 +935,41 @@ export async function getReconciliationStatus( // cash account (a transfer's other leg) counts as unmatched HERE, keeping this // number in agreement with the "Omatchade verifikationer" table the // reconciliation view derives from the same RPC. - const unlinkedLines = await fetchGLLinesForMatching(supabase, companyId, bankAccount, dateFrom, dateTo) + // effectiveFrom, NOT the caller's dateFrom: countedLines and countedTx are both + // clamped to the IB floor above, and this list has to describe the SAME window + // or the card contradicts itself. With the raw dateFrom, a window that opens + // before the account's opening balance (the v1 endpoint's "company history" + // default, or any multi-year range) counted vouchers from a period whose + // movements the reconciliation deliberately drops: unmatched_gl_line_count was + // inflated by prior-period history, and the bridge below could never close. + const unlinkedLines = await fetchGLLinesForMatching( + supabase, + companyId, + bankAccount, + effectiveFrom ?? undefined, + dateTo + ) const difference = Math.round((bankTotal - glPeriodMovement) * 100) / 100 + // The candidate RPCs project neither `currency` nor `amount_in_currency`, so + // on a foreign account every line resolves to null and there is no sum to + // report. Deliberately all-or-nothing: a partial sum silently understates the + // side it is meant to explain. On SEK, ledgerLineAmountIn never returns null, + // so this is always a number for the 95% case. + const unmatchedGlAmounts = unlinkedLines.map((line) => ledgerLineAmountIn(line, currency)) + const unmatchedGlLineTotal = unmatchedGlAmounts.some((a) => a === null) + ? null + : roundOre(unmatchedGlAmounts.reduce((sum: number, a) => sum + (a ?? 0), 0)) + + // difference - unmatched transactions + unmatched vouchers. See the field doc: + // zero means every krona of the difference is identified and sitting in a list + // the user can open. + const unexplainedDifference = + unmatchedGlLineTotal === null + ? null + : roundOre(difference - roundOre(unmatchedTransactionTotal) + unmatchedGlLineTotal) + const notReconcilableReason = unconvertibleLines.length > 0 ? 'gl_lines_missing_currency_amount' : null @@ -919,7 +998,10 @@ export async function getReconciliationStatus( unmatchedTransactionCount === 0, matched_count: matchedCount, unmatched_transaction_count: unmatchedTransactionCount, + unmatched_transaction_total: roundOre(unmatchedTransactionTotal), unmatched_gl_line_count: unlinkedLines.length, + unmatched_gl_line_total: unmatchedGlLineTotal, + unexplained_difference: unexplainedDifference, unconvertible_gl_line_count: unconvertibleLines.length, not_reconcilable_reason: notReconcilableReason, } diff --git a/messages/en.json b/messages/en.json index 381ba25d..a508ab7c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6529,7 +6529,18 @@ "help_bank_reconciliation_ib": "Is a manually booked or imported voucher actually an opening balance? Mark it as IB and it is excluded from the reconciliation and shown separately.", "help_bank_reconciliation_ignored": "Ignored transactions are hidden from the reconciliation without being booked. They do not affect the balance and can be restored at any time.", "recon_unmatched_attn": "{count, plural, one {1 unmatched transaction: Preview finds automatic matches.} other {# unmatched transactions: Preview finds automatic matches.}}", - "recon_ignored_note": "{count, plural, one {1 ignored transaction ({amount}) is excluded from the reconciliation. You can restore it under Ignored transactions below.} other {# ignored transactions ({amount}) are excluded from the reconciliation. You can restore them under Ignored transactions below.}}", + "recon_progress": "{matched} of {total} bank transactions matched", + "recon_open_items": "{count, plural, =0 {Nothing left to explain} one {1 item left to explain} other {# items left to explain}}", + "recon_bridge_unmatched_tx": "{count, plural, one {1 bank transaction awaiting a voucher} other {# bank transactions awaiting a voucher}}", + "recon_bridge_unmatched_gl": "{count, plural, one {1 voucher awaiting a bank transaction} other {# vouchers awaiting a bank transaction}}", + "recon_unexplained_label": "Unexplained difference", + "recon_unexplained_help": "A gap between the bank and the ledger is normal mid-year: it is what you have not booked yet. This line shows only what remains once both lists above are accounted for. Zero means every krona of the difference is identified.", + "recon_unexplained_note": "This amount does not come from the lists above. The usual causes are corrections or reversals on the account with no bank transaction, a match made against a different amount, or a voucher with several lines on the account.", + "recon_excl_prefix": "Outside the reconciliation: {items}.", + "recon_excl_ib": "opening balance {amount}", + "recon_excl_ignored": "{count, plural, one {1 ignored transaction ({amount})} other {# ignored transactions ({amount})}}", + "recon_excl_corrections": "Corrections and reversals {amount} are included in the booked amount.", + "recon_exclusions_help": "The opening balance is last year's closing position, not a transaction in this period, so it has no bank counterpart to match against. Ignored transactions are ones you marked as not to be booked, such as duplicates from the bank. Corrections and reversals are included in the booked amount exactly as they are on the balance sheet: they are never subtracted.", "recon_apply_strong": "{count, plural, one {Match 1 strong match} other {Match # strong matches}}", "switch_report": "Switch report", "calendar_badge": "Calendar", diff --git a/messages/sv.json b/messages/sv.json index 6f7e0df2..b1e42d35 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -6529,7 +6529,18 @@ "help_bank_reconciliation_ib": "Är en manuellt bokförd eller importerad verifikation egentligen en ingående balans? Märk den som IB så räknas den inte med i avstämningen utan visas separat.", "help_bank_reconciliation_ignored": "Ignorerade transaktioner döljs från avstämningen utan att bokföras. De påverkar inte saldot och kan återställas när som helst.", "recon_unmatched_attn": "{count, plural, one {1 omatchad transaktion: Förhandsgranska hittar automatiska träffar.} other {# omatchade transaktioner: Förhandsgranska hittar automatiska träffar.}}", - "recon_ignored_note": "{count, plural, one {1 ignorerad transaktion ({amount}) räknas inte med i avstämningen. Du kan återställa den under Ignorerade transaktioner nedan.} other {# ignorerade transaktioner ({amount}) räknas inte med i avstämningen. Du kan återställa dem under Ignorerade transaktioner nedan.}}", + "recon_progress": "{matched} av {total} banktransaktioner matchade", + "recon_open_items": "{count, plural, =0 {Inget kvar att förklara} one {1 post kvar att förklara} other {# poster kvar att förklara}}", + "recon_bridge_unmatched_tx": "{count, plural, one {1 banktransaktion väntar på verifikat} other {# banktransaktioner väntar på verifikat}}", + "recon_bridge_unmatched_gl": "{count, plural, one {1 verifikation väntar på banktransaktion} other {# verifikationer väntar på banktransaktion}}", + "recon_unexplained_label": "Oförklarad differens", + "recon_unexplained_help": "Skillnaden mellan banken och bokföringen är normal mitt i ett räkenskapsår: den består av det du ännu inte hunnit bokföra. Här visas bara det som blir kvar när båda listorna ovan är avräknade. Noll betyder att varje krona i differensen är identifierad.", + "recon_unexplained_note": "Beloppet kommer inte från listorna ovan. Vanligast är rättelser eller stornon på kontot som saknar banktransaktion, en matchning mot ett annat belopp, eller ett verifikat med flera rader på kontot.", + "recon_excl_prefix": "Utanför avstämningen: {items}.", + "recon_excl_ib": "ingående balans {amount}", + "recon_excl_ignored": "{count, plural, one {1 ignorerad transaktion ({amount})} other {# ignorerade transaktioner ({amount})}}", + "recon_excl_corrections": "Rättelser och stornon {amount} ingår i det bokförda beloppet.", + "recon_exclusions_help": "Ingående balans är föregående års utgående saldo, inte en affärshändelse i perioden, och har därför ingen banktransaktion att matchas mot. Ignorerade transaktioner har du markerat som sådant du inte ska bokföra, till exempel dubbletter från banken. Rättelser och stornon ingår i det bokförda beloppet precis som i balansräkningen: de dras aldrig bort.", "recon_apply_strong": "{count, plural, one {Matcha 1 stark träff} other {Matcha # starka träffar}}", "switch_report": "Byt rapport", "calendar_badge": "Kalender", diff --git a/skills/accounted-api/references/banking.md b/skills/accounted-api/references/banking.md index 46ed8f80..315c802e 100644 --- a/skills/accounted-api/references/banking.md +++ b/skills/accounted-api/references/banking.md @@ -144,6 +144,7 @@ Returns matched / unmatched counts and the balance delta between the bank ledger - A non-zero difference is normal between sync runs (uncleared cheques, in-flight transfers). Investigate only if it persists across reconciliations. - difference compares against gl_1930_period_movement (movement excl. opening balance), NOT gl_1930_balance. Do not display gl_1930_balance next to difference. - is_reconciled means |difference| < 0.01 for the window, an aggregate check, not a per-transaction guarantee. +- Judge health on unexplained_difference, NOT on difference. difference is just the gap between the two sides and is expected to be large mid-year; it is fully explained while every krona of it sits in unmatched_transaction_total or unmatched_gl_line_total. A non-zero unexplained_difference is the real finding: a matched pair disagreeing in amount, a voucher with several lines on the account, or a storno/correction line the candidate list hides. - Ignored transactions are excluded from bank_transaction_total and difference (they never get a ledger counterpart); their count and sum are reported separately. | Parameter | In | Type | Required | Notes | @@ -165,7 +166,10 @@ Response `200`: is_reconciled: boolean, matched_count: number, unmatched_transaction_count: number, - unmatched_gl_line_count: number + unmatched_transaction_total: number, + unmatched_gl_line_count: number, + unmatched_gl_line_total: number, + unexplained_difference: number }, meta: { request_id: string,