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
@@ -73,8 +73,21 @@ export async function POST(
|
||||
}
|
||||
|
||||
if (op.status !== 'pending') {
|
||||
// There is no auto-commit path (removed in 20260505190027), so a non-pending
|
||||
// status here means the op was resolved explicitly — almost always the user
|
||||
// pressed Godkänn in the /pending (Att göra) UI in parallel, or another
|
||||
// client already rejected it. Spell that out so an agent doesn't read the
|
||||
// generic 409 as "the system committed it behind my back".
|
||||
const explained =
|
||||
op.status === 'rejected'
|
||||
? 'Operation already rejected.'
|
||||
: op.status === 'expired'
|
||||
? 'Operation already expired and can no longer be rejected.'
|
||||
: `Operation already ${op.status} — it was approved explicitly (most likely via the ` +
|
||||
'Att göra / pending UI in parallel), not auto-committed. It can no longer be rejected; ' +
|
||||
'reverse or correct the resulting verifikat instead.'
|
||||
return NextResponse.json(
|
||||
{ error: `Operation already ${op.status}` },
|
||||
{ error: explained, status: op.status },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -618,9 +618,9 @@ export function BankReconciliationView() {
|
||||
)}
|
||||
{status.gl_1930_correction_adjustment !== 0 && (
|
||||
<p className="pt-2 text-xs text-muted-foreground">
|
||||
Rättelser och stornon på <AccountNumber number={accountNumber} /> i perioden:{' '}
|
||||
Varav rättelser och stornon på <AccountNumber number={accountNumber} /> i perioden:{' '}
|
||||
<span className="font-mono">{formatCurrency(status.gl_1930_correction_adjustment)}</span>
|
||||
{' '}— bokföringsmässiga rättelser utan motsvarande bankhändelse, räknas inte i avstämningen.
|
||||
{' '}— ingår i det bokförda beloppet och i avstämningen, precis som i balansräkningen.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-4 pt-2 text-xs text-muted-foreground">
|
||||
|
||||
@@ -5808,13 +5808,17 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_get_reconciliation_status',
|
||||
title: 'Bank Reconciliation Status',
|
||||
description: 'Bank reconciliation status: matched/unmatched counts, match rate, bank vs ledger balance, difference. Optional date range.',
|
||||
description: 'Bank reconciliation for one cash account: matched/unmatched counts, bank vs ledger balance, difference. Defaults to 1930; pass account_number for 1940/1932 etc. Optional date range.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
date_from: { type: 'string', description: 'Start date YYYY-MM-DD' },
|
||||
date_to: { type: 'string', description: 'End date YYYY-MM-DD' },
|
||||
account_number: {
|
||||
type: 'string',
|
||||
description: 'Cash-account BAS code to reconcile, e.g. "1940". Defaults to "1930".',
|
||||
},
|
||||
},
|
||||
},
|
||||
outputSchema: { type: 'object' },
|
||||
@@ -5827,7 +5831,37 @@ export const tools: McpTool[] = [
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const dateFrom = args.date_from as string | undefined
|
||||
const dateTo = args.date_to as string | undefined
|
||||
return await getReconciliationStatus(supabase, companyId, dateFrom, dateTo)
|
||||
const accountNumber = (args.account_number as string | undefined) || '1930'
|
||||
|
||||
// Pair the bank account with its currency + cash_account_id so EUR GL
|
||||
// movements aren't compared against SEK transactions, and so a secondary
|
||||
// same-currency account doesn't pool the primary's unassigned rows. Mirrors
|
||||
// app/api/reconciliation/bank/status/route.ts.
|
||||
const { data: cashAccount } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('id, currency, is_primary')
|
||||
.eq('company_id', companyId)
|
||||
.eq('ledger_account', accountNumber)
|
||||
.maybeSingle()
|
||||
|
||||
if (!cashAccount && accountNumber !== '1930') {
|
||||
throw new Error(`Okänt kassakonto ${accountNumber} för det här företaget`)
|
||||
}
|
||||
|
||||
const currency = (cashAccount?.currency as string | undefined) ?? 'SEK'
|
||||
const cashAccountId = cashAccount?.id as string | undefined
|
||||
const includeUnassigned = cashAccount ? Boolean(cashAccount.is_primary) : true
|
||||
|
||||
return await getReconciliationStatus(
|
||||
supabase,
|
||||
companyId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
accountNumber,
|
||||
currency,
|
||||
cashAccountId,
|
||||
includeUnassigned,
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8413,7 +8447,10 @@ export const tools: McpTool[] = [
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const entryDate = args.entry_date as string
|
||||
const description = args.description as string
|
||||
// Normalize like line_description (and gnubok_create_transactions) — coerce
|
||||
// to string and trim, so a non-string or whitespace-only description is
|
||||
// caught by the guard below instead of slipping into the preview/voucher.
|
||||
const description = String(args.description ?? '').trim()
|
||||
const rawLines = args.lines as Array<Record<string, unknown>> | undefined
|
||||
|
||||
if (!entryDate || !description || !Array.isArray(rawLines) || rawLines.length < 2) {
|
||||
@@ -9436,7 +9473,18 @@ export const tools: McpTool[] = [
|
||||
.single()
|
||||
|
||||
if (fetchError || !op) throw new Error('Pending operation not found')
|
||||
if (op.status !== 'pending') throw new Error(`Operation already ${op.status}`)
|
||||
if (op.status !== 'pending') {
|
||||
// No auto-commit path exists (removed in 20260505190027). A non-pending
|
||||
// status means the op was resolved explicitly — usually the user
|
||||
// approved it in the Att göra / pending UI in parallel. Make that
|
||||
// explicit so the agent doesn't read it as a silent auto-commit.
|
||||
throw new Error(
|
||||
op.status === 'rejected'
|
||||
? 'Operation already rejected.'
|
||||
: `Operation already ${op.status} — approved explicitly (likely via the pending UI), ` +
|
||||
'not auto-committed. Reverse or correct the resulting verifikat instead.',
|
||||
)
|
||||
}
|
||||
|
||||
// Atomic claim — flips pending → rejected only when the row is still
|
||||
// pending AND in the caller's tenant (V8.3.1, CC6.3 tenant isolation).
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,11 +6,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
let resultIdx: number
|
||||
let results: Array<{ data?: unknown; error?: unknown }>
|
||||
let calls: Array<{ method: string; args: unknown[] }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
b[m] = vi.fn().mockImplementation((...args: unknown[]) => {
|
||||
calls.push({ method: m, args })
|
||||
return b
|
||||
})
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
|
||||
@@ -32,6 +36,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resultIdx = 0
|
||||
results = []
|
||||
calls = []
|
||||
supabase = makeClient()
|
||||
})
|
||||
|
||||
@@ -232,6 +237,43 @@ describe('generateARReconciliation', () => {
|
||||
expect(result.is_reconciled).toBe(true)
|
||||
})
|
||||
|
||||
it('counts posted AND reversed 1510 lines (corrected invoice nets correctly)', async () => {
|
||||
// Same fix as supplier-reconciliation: a corrected customer invoice flips its
|
||||
// original to status='reversed'. The reversed leg must be summed with the
|
||||
// posted storno/correction or a corrected, settled invoice shows a phantom
|
||||
// gap against the kundreskontra.
|
||||
results = [
|
||||
// 0: invoices — single 5 000 SEK invoice still open
|
||||
{
|
||||
data: [{ total: 5000, paid_amount: 0, currency: 'SEK', exchange_rate: null }],
|
||||
error: null,
|
||||
},
|
||||
// 1: 1510 lines as returned by posted+reversed: original (reversed debit
|
||||
// 5000), storno (credit 5000), correction (debit 5000). Net = 5000.
|
||||
{
|
||||
data: [
|
||||
{ debit_amount: 5000, credit_amount: 0, journal_entry_id: 'reg-reversed' },
|
||||
{ debit_amount: 0, credit_amount: 5000, journal_entry_id: 'storno' },
|
||||
{ debit_amount: 5000, credit_amount: 0, journal_entry_id: 'correction' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
]
|
||||
|
||||
const result = await generateARReconciliation(supabase, 'company-1', 'period-1')
|
||||
|
||||
expect(result.ar_ledger_total).toBe(5000)
|
||||
expect(result.account_1510_balance).toBe(5000)
|
||||
expect(result.difference).toBe(0)
|
||||
expect(result.is_reconciled).toBe(true)
|
||||
|
||||
// Guard the actual fix: the 1510/1513 query must include reversed entries.
|
||||
const statusFilter = calls.find(
|
||||
(c) => c.method === 'in' && c.args[0] === 'journal_entries.status',
|
||||
)
|
||||
expect(statusFilter?.args[1]).toEqual(['posted', 'reversed'])
|
||||
})
|
||||
|
||||
it('uses Math.round for monetary precision', async () => {
|
||||
results = [
|
||||
{
|
||||
|
||||
@@ -6,11 +6,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
let resultIdx: number
|
||||
let results: Array<{ data?: unknown; error?: unknown }>
|
||||
let calls: Array<{ method: string; args: unknown[] }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
b[m] = vi.fn().mockImplementation((...args: unknown[]) => {
|
||||
calls.push({ method: m, args })
|
||||
return b
|
||||
})
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
|
||||
@@ -32,6 +36,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resultIdx = 0
|
||||
results = []
|
||||
calls = []
|
||||
supabase = makeClient()
|
||||
})
|
||||
|
||||
@@ -208,6 +213,46 @@ describe('generateReconciliation', () => {
|
||||
expect(result.is_reconciled).toBe(false)
|
||||
})
|
||||
|
||||
it('counts posted AND reversed 2440 lines (corrected invoice nets correctly)', async () => {
|
||||
// Regression for the Arcim Technology AB false "Ej avstämd" gap: two supplier
|
||||
// invoices were registered, corrected via the storno flow, and fully paid.
|
||||
// The corrected registrations flip to status='reversed'. The leverantörs-
|
||||
// reskontra shows 0 outstanding, and over posted+reversed the 2440 balance is
|
||||
// 0 too — but a posted-only query saw only the storno + correction + payment
|
||||
// legs and reported a phantom −41 121,25 kr debit. The query must include the
|
||||
// reversed registration leg so both reconcile.
|
||||
results = [
|
||||
// 0: supplier_invoices — both paid, nothing outstanding
|
||||
{ data: [], error: null },
|
||||
// 1: 2440 lines as returned by the posted+reversed query for one corrected,
|
||||
// paid invoice of 11 231,25: registration (reversed credit), storno
|
||||
// (debit), correction (credit), payment (debit). Net credit−debit = 0.
|
||||
{
|
||||
data: [
|
||||
{ debit_amount: 0, credit_amount: 11231.25, journal_entry_id: 'reg-reversed' },
|
||||
{ debit_amount: 11231.25, credit_amount: 0, journal_entry_id: 'storno' },
|
||||
{ debit_amount: 0, credit_amount: 11231.25, journal_entry_id: 'correction' },
|
||||
{ debit_amount: 11231.25, credit_amount: 0, journal_entry_id: 'payment' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
]
|
||||
|
||||
const result = await generateReconciliation(supabase, 'company-1', 'period-1')
|
||||
|
||||
expect(result.supplier_ledger_total).toBe(0)
|
||||
expect(result.account_2440_balance).toBe(0)
|
||||
expect(result.difference).toBe(0)
|
||||
expect(result.is_reconciled).toBe(true)
|
||||
|
||||
// Guard the actual fix: the 2440 query must include reversed entries, not
|
||||
// filter to posted-only (which excluded the reversed registration leg).
|
||||
const statusFilter = calls.find(
|
||||
(c) => c.method === 'in' && c.args[0] === 'journal_entries.status',
|
||||
)
|
||||
expect(statusFilter?.args[1]).toEqual(['posted', 'reversed'])
|
||||
})
|
||||
|
||||
it('uses Math.round for monetary precision', async () => {
|
||||
results = [
|
||||
{
|
||||
|
||||
@@ -62,12 +62,17 @@ export async function generateARReconciliation(
|
||||
return Math.round((sum + sek) * 100) / 100
|
||||
}, 0)
|
||||
|
||||
// Get AR receivable balance from posted journal entry lines in this period.
|
||||
// We sum 1510 (Kundfordringar) AND 1513 (Kundfordringar – delad faktura) so
|
||||
// the comparison stays correct under ROT/RUT fakturamodellen, where the
|
||||
// customer portion sits on 1510 and the Skatteverket claim on 1513 — both
|
||||
// are open AR receivable from the company's perspective. 1513 is zero today
|
||||
// (no fakturamodellen postings yet) so this is a forward-looking defense.
|
||||
// Get AR receivable balance from the ledger in this period. We sum 1510
|
||||
// (Kundfordringar) AND 1513 (Kundfordringar – delad faktura) so the comparison
|
||||
// stays correct under ROT/RUT fakturamodellen, where the customer portion sits
|
||||
// on 1510 and the Skatteverket claim on 1513 — both are open AR receivable
|
||||
// from the company's perspective. 1513 is zero today (no fakturamodellen
|
||||
// postings yet) so this is a forward-looking defense.
|
||||
//
|
||||
// We count posted AND reversed entries together — the SAME inclusion rule the
|
||||
// trial balance / balance sheet use. A corrected invoice flips its original to
|
||||
// status='reversed'; that reversed leg is cancelled by the posted storno, so
|
||||
// both must be summed or a corrected invoice manufactures a phantom gap.
|
||||
const { data: journalLines } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(`
|
||||
@@ -82,7 +87,7 @@ export async function generateARReconciliation(
|
||||
.in('account_number', ['1510', '1513'])
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.fiscal_period_id', periodId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
|
||||
// Both 1510 and 1513 are debit-normal assets: balance = debits - credits
|
||||
let account1510Balance = 0
|
||||
|
||||
@@ -59,7 +59,15 @@ export async function generateReconciliation(
|
||||
return Math.round((sum + sek) * 100) / 100
|
||||
}, 0)
|
||||
|
||||
// Get account 2440 balance from posted journal entry lines in this period
|
||||
// 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(`
|
||||
@@ -74,7 +82,7 @@ export async function generateReconciliation(
|
||||
.eq('account_number', '2440')
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.fiscal_period_id', periodId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
|
||||
// Account 2440 is a liability: credit normal balance
|
||||
// Balance = credits - debits
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
-- Backfill: restore Swedish diacritics (å/ä/ö) on seeded chart-of-accounts names.
|
||||
--
|
||||
-- The seed_chart_of_accounts() helper shipped between 2026-03-30
|
||||
-- (20260330130000_multi_tenant_company_refactor.sql) and 2026-05-16 with its
|
||||
-- account-name string literals stripped of å/ä/ö — e.g. 'Foretagskonto /
|
||||
-- checkkonto', 'Leverantorsskulder', 'Arets resultat', 'Loner'. The function was
|
||||
-- repaired for NEW companies by 20260516130000_seed_chart_of_accounts_restore_
|
||||
-- swedish_chars.sql, but the ~830 companies seeded in that window were never
|
||||
-- backfilled, so their books still render bank/expense/VAT accounts without
|
||||
-- diacritics (the reported "Foretagskonto / checkkonto" / "Ovriga bankkonton").
|
||||
--
|
||||
-- This restores the diacritics on the AFFECTED rows only. It maps each corrupted
|
||||
-- name back to the diacritic form OF THAT SAME ACCOUNT (same account_number and
|
||||
-- same wording) — it deliberately does NOT adopt the later seed's restructured
|
||||
-- VAT accounts (2610/2611/2612 stay as-is with their names fixed; they are not
|
||||
-- renumbered to 2611/2621/2631 — that is a structural change, out of scope for a
|
||||
-- charset repair).
|
||||
--
|
||||
-- Safe + idempotent: the join matches on account_number AND the exact corrupted
|
||||
-- account_name, so a company that renamed an account, or a row already carrying
|
||||
-- the correct name, is left untouched. Re-running is a no-op.
|
||||
|
||||
UPDATE public.chart_of_accounts AS coa
|
||||
SET account_name = fix.correct_name
|
||||
FROM (
|
||||
VALUES
|
||||
('1930', 'Foretagskonto / checkkonto', 'Företagskonto / checkkonto'),
|
||||
('1940', 'Ovriga bankkonton', 'Övriga bankkonton'),
|
||||
('2013', 'Ovriga egna uttag', 'Övriga egna uttag'),
|
||||
('2018', 'Ovriga egna insattningar', 'Övriga egna insättningar'),
|
||||
('2099', 'Arets resultat', 'Årets resultat'),
|
||||
('2440', 'Leverantorsskulder', 'Leverantörsskulder'),
|
||||
('2610', 'Utgaende moms 25%', 'Utgående moms 25%'),
|
||||
('2611', 'Utgaende moms 12%', 'Utgående moms 12%'),
|
||||
('2612', 'Utgaende moms 6%', 'Utgående moms 6%'),
|
||||
('2641', 'Debiterad ingaende moms', 'Debiterad ingående moms'),
|
||||
('2650', 'Redovisningskonto for moms', 'Redovisningskonto för moms'),
|
||||
('2731', 'Avrakning socialavgifter', 'Avräkning socialavgifter'),
|
||||
('2893', 'Skuld till aktieagare', 'Skuld till aktieägare'),
|
||||
('3001', 'Forsaljning tjanster 25%', 'Försäljning tjänster 25%'),
|
||||
('3002', 'Forsaljning varor 25%', 'Försäljning varor 25%'),
|
||||
('3100', 'Momsfri forsaljning', 'Momsfri försäljning'),
|
||||
('3900', 'Ovriga rorelseintakter', 'Övriga rörelseintäkter'),
|
||||
('4000', 'Varuinkop', 'Varuinköp'),
|
||||
('5410', 'Forbrukningsinventarier', 'Förbrukningsinventarier'),
|
||||
('5460', 'Forbrukningsmaterial', 'Förbrukningsmaterial'),
|
||||
('6530', 'Redovisningstjanster', 'Redovisningstjänster'),
|
||||
('6991', 'Ovriga avdragsgilla kostnader', 'Övriga avdragsgilla kostnader'),
|
||||
('7010', 'Loner', 'Löner'),
|
||||
('7210', 'Semesterloner', 'Semesterlöner'),
|
||||
('7960', 'Valutakursforluster', 'Valutakursförluster'),
|
||||
('8310', 'Ranteintakter', 'Ränteintäkter'),
|
||||
('8410', 'Rantekostnader', 'Räntekostnader')
|
||||
) AS fix(account_number, corrupted_name, correct_name)
|
||||
WHERE coa.account_number = fix.account_number
|
||||
AND coa.account_name = fix.corrupted_name;
|
||||
Reference in New Issue
Block a user