PR #1295 (144cc514) fixed computeVatCloseCheck on main while this branch was
fixing it a second, different way. This rebuilds the branch on top of that
merge instead of re-landing the duplicate: main's local
getScopedReconciliationStatus stays as the MCP entry point, its lookup body
moves into lib/reconciliation/cash-account-scope.ts, and the pieces main does
not have are added on top.
Why the lookup has to live in lib/: the bokslut readiness aggregator has the
same defect and is core code, which must never import from @/extensions/. It
called getReconciliationStatus with 4 positional args, so cashAccountId stayed
undefined and scopeTransactionsToAccount fell through to its currency-only
filter: the bank side summed every SEK cash account while the GL side stayed on
1930, and the wizard reported "Bankavstamningen visar en differens" with zero
unmatched transactions and zero unmatched GL lines to point at. Same shape as
the MCP blocker in #1290, different surface.
Decisions taken deliberately, not by taking 'ours':
1. Lookup errors fail CLOSED, everywhere. resolveCashAccountScope throws
"Kunde inte hamta kassakonto <n>" instead of returning the unscoped
fallback. The earlier version on this branch logged and fell back, which
turned a transient DB error or an RLS denial straight back into the #1290
pooling path. Main's contract wins; its merged test asserting exactly that
still passes untouched.
2. The default resolution no longer hard-codes 1930. With no account_number
argument the resolver tries 1930 and, only if the company has no such row,
falls back to its primary cash account. Measured read-only on prod
2026-07-30: 2 companies have no 1930 cash_accounts row while running two SEK
cash accounts each and zero journal_entry_lines on 1930, so the check
compared their entire SEK bank volume against an empty GL side, i.e. a
high-severity bank_unreconciled blocker with count 0 that no user action
could clear. Roughly 20x the difference the issue reported. A caller that
NAMES an account gets no fallback, so gnubok_get_reconciliation_status still
rejects "Okant kassakonto 9999" rather than silently answering about a
different account. 1367 companies have a 1930 row and are unaffected,
including the 5 whose 1930 row is not the primary one.
3. The blocker message names the resolved account instead of a literal 1930:
pointing a user at 1930 when the reconciliation ran on 1935 sends them to an
account with no lines on it.
4. warnIfUnscopedAcrossCashAccounts logs a warning when a run left
cashAccountId undefined AND the rows it fetched really do span more than one
cash account. Kept on the write path too: an unscoped runReconciliation can
persist a wrong journal_entry_id, which does not clear itself later.
Duplicate regression suite collapsed: the branch's
vat-close-check-bank-scope.test.ts overlapped main's
vat-close-check-reconciliation-scope.test.ts case for case, so only the cases
main lacked were merged into main's file (the primary-account fallback, the
message naming the resolved account, the blocker still firing on a genuine
scoped difference, and the tool handler's own scope resolution).
Residuals are now tracked issues, not code comments:
- #1298: the post-sync runReconciliation sweeps in
app/api/extensions/enable-banking/sync/cron/route.ts and
extensions/general/enable-banking/index.ts still run unscoped. They are write
paths.
- #1299: booked transactions with a NULL cash_account_id whose verifikat has no
line on the primary account still inflate the bank total. Measured all-time
on prod: 294 booked NULL rows, 22 of them on verifikat with no 1930 line,
4 companies, net -4170.31 kr with monthly swings from -18055.82 kr to
+38086.00 kr. Fixing it means finishing the cash_account_id backfill.
Also: the file-global logger mock in bank-reconciliation.test.ts now wraps the
real module and swaps only warn, instead of substituting a four-method stub
whose child() returned undefined for the entire module graph of that suite.
Verified: npx vitest run over lib/reconciliation, lib/bokslut,
extensions/general/mcp-server, app/api/reconciliation, app/api/extensions,
app/api/v1, app/api/bookkeeping, app/api/transactions, lib/pending-operations,
lib/bookkeeping, lib/invoices, lib/transactions, lib/reports: all green.
eslint on the 8 changed files: 0 errors (18 pre-existing unused-import
warnings in server.ts). tsc --noEmit: 405 errors, byte-identical to the
origin/main baseline. check:guards passes. No migration, so no pg-real test.
Fixes #1290
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1946 lines
75 KiB
TypeScript
1946 lines
75 KiB
TypeScript
/**
|
|
* Tests for the bank reconciliation engine.
|
|
*
|
|
* Covers: matching algorithm (4 passes), direction compatibility,
|
|
* greedy assignment, dry run, manual link/unlink, status calculation.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
// The #1290 diagnostic is a log line, so the logger is the assertion surface.
|
|
// Hoisted spy (the module is imported at the top of bank-reconciliation.ts).
|
|
//
|
|
// The REAL logger module is kept and only `warn` is swapped. A file-global stub
|
|
// exposing just the handful of methods this suite happens to use would be a trap
|
|
// for whoever extends it: vi.mock replaces @/lib/logger for the whole module
|
|
// graph of this file, so any module reached from here that calls a level the
|
|
// stub omitted, or chains `log.child(...).info(...)`, would throw inside an
|
|
// unrelated test. Here child() and every other export stay real.
|
|
const { logWarn } = vi.hoisted(() => ({ logWarn: vi.fn() }))
|
|
vi.mock('@/lib/logger', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/logger')>()
|
|
return {
|
|
...actual,
|
|
createLogger: (module: string, base?: Parameters<typeof actual.createLogger>[1]) => ({
|
|
...actual.createLogger(module, base),
|
|
warn: logWarn,
|
|
}),
|
|
}
|
|
})
|
|
|
|
import {
|
|
tryReconcileTransaction,
|
|
runReconciliation,
|
|
manualLink,
|
|
unlinkReconciliation,
|
|
getReconciliationStatus,
|
|
scopeTransactionsToAccount,
|
|
ledgerLineAmountIn,
|
|
} from '../bank-reconciliation'
|
|
import type { UnlinkedGLLine } from '../bank-reconciliation'
|
|
import { makeTransaction } from '@/tests/helpers'
|
|
import { eventBus } from '@/lib/events/bus'
|
|
|
|
vi.mock('@/lib/supabase/server')
|
|
|
|
// ============================================================
|
|
// Helpers
|
|
// ============================================================
|
|
|
|
function makeGLLine(overrides: Partial<UnlinkedGLLine> = {}): UnlinkedGLLine {
|
|
return {
|
|
line_id: `line-${Math.random().toString(36).slice(2, 8)}`,
|
|
journal_entry_id: `je-${Math.random().toString(36).slice(2, 8)}`,
|
|
debit_amount: 0,
|
|
credit_amount: 0,
|
|
line_description: null,
|
|
entry_date: '2024-06-15',
|
|
voucher_number: 1,
|
|
voucher_series: 'A',
|
|
entry_description: 'Test entry',
|
|
source_type: 'import',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// ledgerLineAmountIn: the single definition of "this ledger line's amount,
|
|
// expressed in the currency the account is being reconciled in"
|
|
// ============================================================
|
|
|
|
describe('ledgerLineAmountIn', () => {
|
|
it('nets debit against credit on SEK, ignoring the currency LABEL', () => {
|
|
// The headline trap: currency-utils stamps `currency: 'EUR'` +
|
|
// amount_in_currency onto a line whose debit_amount is SEK (a EUR supplier
|
|
// invoice paid from a SEK account). Reconciling that account in SEK must
|
|
// use the SEK columns and ignore the label entirely.
|
|
expect(ledgerLineAmountIn({ debit_amount: 1150, credit_amount: 0 }, 'SEK')).toBe(1150)
|
|
expect(ledgerLineAmountIn({ debit_amount: 0, credit_amount: 1150 }, 'SEK')).toBe(-1150)
|
|
expect(
|
|
ledgerLineAmountIn(
|
|
{ debit_amount: 1150, credit_amount: 0, currency: 'EUR', amount_in_currency: 100 },
|
|
'SEK',
|
|
),
|
|
).toBe(1150)
|
|
})
|
|
|
|
it('never returns null on SEK: the 95% path always has an answer', () => {
|
|
expect(ledgerLineAmountIn({ debit_amount: null, credit_amount: null }, 'SEK')).toBe(0)
|
|
expect(ledgerLineAmountIn({ debit_amount: '250.50', credit_amount: '0' }, 'SEK')).toBe(250.5)
|
|
})
|
|
|
|
it('returns the FOREIGN amount, not the SEK figure, on a foreign account', () => {
|
|
// 100 EUR booked at 11.50: debit_amount is the 1150 SEK conversion.
|
|
// Reconciling the EUR account must yield 100, never 1150.
|
|
expect(
|
|
ledgerLineAmountIn(
|
|
{ debit_amount: 1150, credit_amount: 0, currency: 'EUR', amount_in_currency: 100 },
|
|
'EUR',
|
|
),
|
|
).toBe(100)
|
|
expect(
|
|
ledgerLineAmountIn(
|
|
{ debit_amount: 0, credit_amount: 1150, currency: 'EUR', amount_in_currency: 100 },
|
|
'EUR',
|
|
),
|
|
).toBe(-100)
|
|
})
|
|
|
|
it('takes direction from the debit/credit side, magnitude from amount_in_currency', () => {
|
|
// Some rows carry a negatively-signed amount_in_currency; the ledger side is
|
|
// authoritative for direction, so the sign must not be applied twice.
|
|
expect(
|
|
ledgerLineAmountIn(
|
|
{ debit_amount: 0, credit_amount: 1150, currency: 'EUR', amount_in_currency: -100 },
|
|
'EUR',
|
|
),
|
|
).toBe(-100)
|
|
})
|
|
|
|
it('returns null (never the SEK figure) when the line carries no amount in that currency', () => {
|
|
// No rate on the row: a SIE-imported or pre-FX line on a EUR account. There
|
|
// is nothing to convert with, so the caller must report it, not guess.
|
|
expect(ledgerLineAmountIn({ debit_amount: 1150, credit_amount: 0 }, 'EUR')).toBeNull()
|
|
expect(
|
|
ledgerLineAmountIn({ debit_amount: 1150, credit_amount: 0, currency: 'SEK' }, 'EUR'),
|
|
).toBeNull()
|
|
expect(
|
|
ledgerLineAmountIn(
|
|
{ debit_amount: 1150, credit_amount: 0, currency: 'EUR', amount_in_currency: null },
|
|
'EUR',
|
|
),
|
|
).toBeNull()
|
|
// Wrong label: a USD line on a EUR account carries no EUR amount.
|
|
expect(
|
|
ledgerLineAmountIn(
|
|
{ debit_amount: 1150, credit_amount: 0, currency: 'USD', amount_in_currency: 110 },
|
|
'EUR',
|
|
),
|
|
).toBeNull()
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// scopeTransactionsToAccount: the per-account query filter
|
|
// ============================================================
|
|
|
|
describe('scopeTransactionsToAccount', () => {
|
|
// Records every filter call and returns itself so the chain can continue.
|
|
function makeQueryStub() {
|
|
const calls: { method: string; args: unknown[] }[] = []
|
|
const self = {
|
|
eq: (...args: unknown[]) => {
|
|
calls.push({ method: 'eq', args })
|
|
return self
|
|
},
|
|
or: (...args: unknown[]) => {
|
|
calls.push({ method: 'or', args })
|
|
return self
|
|
},
|
|
}
|
|
return { self, calls }
|
|
}
|
|
|
|
it('scopes by currency AND (this account OR legacy NULL) using a flat two-term or', () => {
|
|
const { self, calls } = makeQueryStub()
|
|
const id = '11111111-1111-1111-1111-111111111111'
|
|
|
|
scopeTransactionsToAccount(self as never, id, 'SEK')
|
|
|
|
// currency is constrained even on the bound branch (a cash account has one
|
|
// currency), which lets us avoid the fragile nested and() form.
|
|
expect(calls).toContainEqual({ method: 'eq', args: ['currency', 'SEK'] })
|
|
expect(calls).toContainEqual({
|
|
method: 'or',
|
|
args: [`cash_account_id.eq.${id},cash_account_id.is.null`],
|
|
})
|
|
// Regression guard: the old nested `and(cash_account_id.is.null,currency.eq.X)`
|
|
// silently returned ZERO rows mid-backfill: it must never come back.
|
|
const orCall = calls.find((c) => c.method === 'or')
|
|
expect(String(orCall?.args[0])).not.toContain('and(')
|
|
})
|
|
|
|
it('scopes strictly to the account (no NULL fallback) when includeUnassigned is false', () => {
|
|
const { self, calls } = makeQueryStub()
|
|
const id = '22222222-2222-2222-2222-222222222222'
|
|
|
|
// includeUnassigned=false is the non-primary account case: a secondary
|
|
// same-currency account (e.g. a 1931 savings account) must NOT pull in the
|
|
// company's unassigned NULL rows: those belong to the primary account.
|
|
// Double-counting them inflated the secondary account's bank total and
|
|
// showed a large bogus difference ("1930 works, the other accounts go wonky").
|
|
scopeTransactionsToAccount(self as never, id, 'SEK', false)
|
|
|
|
expect(calls).toEqual([
|
|
{ method: 'eq', args: ['currency', 'SEK'] },
|
|
{ method: 'eq', args: ['cash_account_id', id] },
|
|
])
|
|
// No OR: the IS NULL fallback must not appear for a non-primary account.
|
|
expect(calls.find((c) => c.method === 'or')).toBeUndefined()
|
|
})
|
|
|
|
it('falls back to a pure currency filter when no cash account id is given', () => {
|
|
const { self, calls } = makeQueryStub()
|
|
|
|
scopeTransactionsToAccount(self as never, undefined, 'EUR')
|
|
|
|
expect(calls).toEqual([{ method: 'eq', args: ['currency', 'EUR'] }])
|
|
})
|
|
|
|
it('rejects a non-ISO currency (PostgREST filter-injection guard)', () => {
|
|
const { self } = makeQueryStub()
|
|
expect(() =>
|
|
scopeTransactionsToAccount(self as never, undefined, 'SEK; drop' as never),
|
|
).toThrow()
|
|
})
|
|
|
|
it('rejects a non-uuid cash account id', () => {
|
|
const { self } = makeQueryStub()
|
|
expect(() => scopeTransactionsToAccount(self as never, 'not-a-uuid', 'SEK')).toThrow()
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// tryReconcileTransaction: in-memory matching
|
|
// ============================================================
|
|
|
|
describe('tryReconcileTransaction', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
eventBus.clear()
|
|
})
|
|
|
|
// ------------------------------------------------------------------
|
|
// Pass 1: Exact amount + exact date
|
|
// ------------------------------------------------------------------
|
|
it('matches income transaction with exact amount and date (debit on 1930)', () => {
|
|
const tx = makeTransaction({ amount: 5000, date: '2024-06-15', currency: 'SEK' })
|
|
const line = makeGLLine({ debit_amount: 5000, entry_date: '2024-06-15' })
|
|
|
|
const result = tryReconcileTransaction(tx, [line])
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.method).toBe('auto_exact')
|
|
expect(result!.confidence).toBe(0.95)
|
|
})
|
|
|
|
it('matches expense transaction with exact amount and date (credit on 1930)', () => {
|
|
const tx = makeTransaction({ amount: -1200, date: '2024-06-15', currency: 'SEK' })
|
|
const line = makeGLLine({ credit_amount: 1200, entry_date: '2024-06-15' })
|
|
|
|
const result = tryReconcileTransaction(tx, [line])
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.method).toBe('auto_exact')
|
|
expect(result!.confidence).toBe(0.95)
|
|
})
|
|
|
|
// ------------------------------------------------------------------
|
|
// Pass 2: Exact amount + OCR/reference match (within ±90 days)
|
|
// ------------------------------------------------------------------
|
|
it('matches on exact amount with OCR reference match within 90 days', () => {
|
|
const tx = makeTransaction({
|
|
amount: 3500,
|
|
date: '2024-06-20',
|
|
currency: 'SEK',
|
|
reference: '12345678',
|
|
})
|
|
const line = makeGLLine({
|
|
debit_amount: 3500,
|
|
entry_date: '2024-06-10',
|
|
entry_description: 'Payment ref 12345678',
|
|
})
|
|
|
|
const result = tryReconcileTransaction(tx, [line])
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.method).toBe('auto_reference')
|
|
expect(result!.confidence).toBe(0.90)
|
|
})
|
|
|
|
// Regression: viktor@frnzn.com: recurring monthly bank fee from 2026 was
|
|
// wrongly reconciled to a 2024 SIE-imported voucher because description +
|
|
// amount collided. auto_reference must require a real OCR token AND a
|
|
// bounded date window: description alone, no date check, is not enough.
|
|
it('does NOT match recurring charge across years on description alone', () => {
|
|
const tx = makeTransaction({
|
|
amount: -149,
|
|
date: '2026-01-31',
|
|
currency: 'SEK',
|
|
description: 'Månadsavgift Baspaket',
|
|
reference: null,
|
|
})
|
|
const line = makeGLLine({
|
|
credit_amount: 149,
|
|
entry_date: '2024-03-31',
|
|
entry_description: 'Bankavgifter Månadsavgift Baspaket',
|
|
})
|
|
|
|
const result = tryReconcileTransaction(tx, [line])
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('does NOT match on OCR reference when dates are >90 days apart', () => {
|
|
const tx = makeTransaction({
|
|
amount: 3500,
|
|
date: '2026-06-20',
|
|
currency: 'SEK',
|
|
reference: '12345678',
|
|
})
|
|
const line = makeGLLine({
|
|
debit_amount: 3500,
|
|
entry_date: '2024-06-10',
|
|
entry_description: 'Payment ref 12345678',
|
|
})
|
|
|
|
const result = tryReconcileTransaction(tx, [line])
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
// ------------------------------------------------------------------
|
|
// Pass 3: Exact amount + date within ±3 days
|
|
// ------------------------------------------------------------------
|
|
it('matches on exact amount within 3 day date range', () => {
|
|
const tx = makeTransaction({ amount: 750, date: '2024-06-17', currency: 'SEK' })
|
|
const line = makeGLLine({ debit_amount: 750, entry_date: '2024-06-15' })
|
|
|
|
const result = tryReconcileTransaction(tx, [line])
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.method).toBe('auto_date_range')
|
|
expect(result!.confidence).toBe(0.85)
|
|
})
|
|
|
|
it('does not match when date difference exceeds 3 days', () => {
|
|
const tx = makeTransaction({ amount: 750, date: '2024-06-20', currency: 'SEK' })
|
|
const line = makeGLLine({ debit_amount: 750, entry_date: '2024-06-15' })
|
|
|
|
const result = tryReconcileTransaction(tx, [line])
|
|
|
|
// 5 days apart, no reference, different dates: no match
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
// ------------------------------------------------------------------
|
|
// Pass 4: Fuzzy amount (±0.01) + exact date
|
|
// ------------------------------------------------------------------
|
|
it('matches on fuzzy amount with exact date', () => {
|
|
const tx = makeTransaction({ amount: -999.99, date: '2024-06-15', currency: 'SEK' })
|
|
const line = makeGLLine({ credit_amount: 1000, entry_date: '2024-06-15' })
|
|
|
|
const result = tryReconcileTransaction(tx, [line])
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.method).toBe('auto_fuzzy')
|
|
expect(result!.confidence).toBe(0.75)
|
|
})
|
|
|
|
it('does not match when fuzzy amount exceeds 0.01 tolerance', () => {
|
|
const tx = makeTransaction({ amount: -999.98, date: '2024-06-15', currency: 'SEK' })
|
|
const line = makeGLLine({ credit_amount: 1000, entry_date: '2024-06-15' })
|
|
|
|
const result = tryReconcileTransaction(tx, [line])
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
// ------------------------------------------------------------------
|
|
// Direction mismatch rejection
|
|
// ------------------------------------------------------------------
|
|
it('rejects income transaction against credit line (direction mismatch)', () => {
|
|
const tx = makeTransaction({ amount: 1000, date: '2024-06-15', currency: 'SEK' })
|
|
const line = makeGLLine({ credit_amount: 1000, entry_date: '2024-06-15' })
|
|
|
|
const result = tryReconcileTransaction(tx, [line])
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('rejects expense transaction against debit line (direction mismatch)', () => {
|
|
const tx = makeTransaction({ amount: -500, date: '2024-06-15', currency: 'SEK' })
|
|
const line = makeGLLine({ debit_amount: 500, entry_date: '2024-06-15' })
|
|
|
|
const result = tryReconcileTransaction(tx, [line])
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
// ------------------------------------------------------------------
|
|
// Non-SEK transactions
|
|
// ------------------------------------------------------------------
|
|
it('skips non-SEK transactions', () => {
|
|
const tx = makeTransaction({ amount: 100, date: '2024-06-15', currency: 'EUR' })
|
|
const line = makeGLLine({ debit_amount: 100, entry_date: '2024-06-15' })
|
|
|
|
const result = tryReconcileTransaction(tx, [line])
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
// The currency check must gate BOTH sides. Gating only the transaction left a
|
|
// foreign bank amount being compared against a ledger figure that is always
|
|
// SEK, so a same-magnitude coincidence in the wrong unit scored auto_exact.
|
|
it('does NOT match a foreign bank row to a same-magnitude SEK ledger leg', () => {
|
|
const tx = makeTransaction({ amount: 100, date: '2024-06-15', currency: 'EUR' })
|
|
// A plain SEK booking: 100 kr on the same date. Same number, wrong unit.
|
|
const sekLine = makeGLLine({
|
|
debit_amount: 100,
|
|
entry_date: '2024-06-15',
|
|
currency: 'SEK',
|
|
})
|
|
|
|
const result = tryReconcileTransaction(tx, [sekLine], 'EUR')
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('matches a EUR bank row against the EUR amount recorded on the ledger line', () => {
|
|
const tx = makeTransaction({ amount: 100, date: '2024-06-15', currency: 'EUR' })
|
|
// 100 EUR at 11.50: the ledger columns hold the 1150 SEK conversion.
|
|
const eurLine = makeGLLine({
|
|
debit_amount: 1150,
|
|
entry_date: '2024-06-15',
|
|
currency: 'EUR',
|
|
amount_in_currency: 100,
|
|
})
|
|
|
|
const result = tryReconcileTransaction(tx, [eurLine], 'EUR')
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.method).toBe('auto_exact')
|
|
})
|
|
|
|
it('still matches a SEK payment of a EUR invoice on the SEK account (label is not the unit)', () => {
|
|
// Regression guard for the 95% path: currency-utils labels this line 'EUR'
|
|
// while debit_amount is SEK. Reconciling 1930 in SEK must be unaffected.
|
|
const tx = makeTransaction({ amount: 1150, date: '2024-06-15', currency: 'SEK' })
|
|
const line = makeGLLine({
|
|
debit_amount: 1150,
|
|
entry_date: '2024-06-15',
|
|
currency: 'EUR',
|
|
amount_in_currency: 100,
|
|
})
|
|
|
|
const result = tryReconcileTransaction(tx, [line])
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.method).toBe('auto_exact')
|
|
})
|
|
|
|
// ------------------------------------------------------------------
|
|
// Empty pool
|
|
// ------------------------------------------------------------------
|
|
it('returns null for empty GL line pool', () => {
|
|
const tx = makeTransaction({ amount: 100, date: '2024-06-15', currency: 'SEK' })
|
|
|
|
const result = tryReconcileTransaction(tx, [])
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
// ------------------------------------------------------------------
|
|
// Priority: highest confidence wins
|
|
// ------------------------------------------------------------------
|
|
it('prefers exact match over date range match', () => {
|
|
const tx = makeTransaction({ amount: 1000, date: '2024-06-15', currency: 'SEK' })
|
|
const exactLine = makeGLLine({
|
|
line_id: 'exact',
|
|
debit_amount: 1000,
|
|
entry_date: '2024-06-15',
|
|
})
|
|
const rangeLine = makeGLLine({
|
|
line_id: 'range',
|
|
debit_amount: 1000,
|
|
entry_date: '2024-06-14',
|
|
})
|
|
|
|
const result = tryReconcileTransaction(tx, [rangeLine, exactLine])
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.glLine.line_id).toBe('exact')
|
|
expect(result!.method).toBe('auto_exact')
|
|
})
|
|
|
|
// ------------------------------------------------------------------
|
|
// No double-matching when using greedy algorithm
|
|
// ------------------------------------------------------------------
|
|
it('each GL line can only match once in a pool', () => {
|
|
const tx1 = makeTransaction({ id: 'tx-1', amount: 1000, date: '2024-06-15', currency: 'SEK' })
|
|
const tx2 = makeTransaction({ id: 'tx-2', amount: 1000, date: '2024-06-15', currency: 'SEK' })
|
|
const line = makeGLLine({ debit_amount: 1000, entry_date: '2024-06-15' })
|
|
|
|
// First transaction matches
|
|
const result1 = tryReconcileTransaction(tx1, [line])
|
|
expect(result1).not.toBeNull()
|
|
|
|
// Second transaction against the same single line also matches individually
|
|
const result2 = tryReconcileTransaction(tx2, [line])
|
|
expect(result2).not.toBeNull()
|
|
|
|
// But in the batch reconciliation (greedyMatch), only one would be assigned
|
|
// This is tested in runReconciliation tests
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// runReconciliation: batch matching with DB calls
|
|
// ============================================================
|
|
|
|
describe('runReconciliation', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
eventBus.clear()
|
|
})
|
|
|
|
function createQueueMockSupabase() {
|
|
const resultQueue: { data: unknown; error: unknown }[] = []
|
|
|
|
const enqueue = (...results: { data?: unknown; error?: unknown }[]) => {
|
|
for (const r of results) {
|
|
resultQueue.push({ data: r.data ?? null, error: r.error ?? null })
|
|
}
|
|
}
|
|
|
|
const buildChain = (): unknown => {
|
|
const handler: ProxyHandler<object> = {
|
|
get(_target, prop) {
|
|
if (prop === 'then') {
|
|
const next = resultQueue.shift() ?? { data: null, error: null }
|
|
return (resolve: (v: unknown) => void) => resolve(next)
|
|
}
|
|
return (..._args: unknown[]) => buildChain()
|
|
},
|
|
}
|
|
return new Proxy({}, handler)
|
|
}
|
|
|
|
const supabase = {
|
|
from: vi.fn().mockImplementation(() => buildChain()),
|
|
rpc: vi.fn().mockImplementation(() => buildChain()),
|
|
}
|
|
|
|
return { supabase, enqueue }
|
|
}
|
|
|
|
it('returns empty matches when no unmatched transactions exist', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
// RPC: get_unlinked_1930_lines returns empty
|
|
enqueue({ data: [] })
|
|
// from('transactions').select: unmatched
|
|
enqueue({ data: [] })
|
|
|
|
const result = await runReconciliation(supabase as never, 'company-1', 'user-1')
|
|
|
|
expect(result.matches).toEqual([])
|
|
expect(result.applied).toBe(0)
|
|
})
|
|
|
|
it('dry run returns matches without applying', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
const tx = makeTransaction({ id: 'tx-1', amount: 1000, date: '2024-06-15', currency: 'SEK' })
|
|
const glLine: UnlinkedGLLine = makeGLLine({
|
|
line_id: 'line-1',
|
|
journal_entry_id: 'je-1',
|
|
debit_amount: 1000,
|
|
entry_date: '2024-06-15',
|
|
})
|
|
|
|
// RPC returns GL lines
|
|
enqueue({ data: [glLine] })
|
|
// from('transactions') returns unmatched transactions
|
|
enqueue({ data: [tx] })
|
|
|
|
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', { dryRun: true })
|
|
|
|
expect(result.matches).toHaveLength(1)
|
|
expect(result.matches[0].method).toBe('auto_exact')
|
|
expect(result.applied).toBe(0)
|
|
})
|
|
|
|
it('applies matches when not dry run', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
const tx = makeTransaction({ id: 'tx-1', amount: -500, date: '2024-06-15', currency: 'SEK' })
|
|
const glLine: UnlinkedGLLine = makeGLLine({
|
|
line_id: 'line-1',
|
|
journal_entry_id: 'je-1',
|
|
credit_amount: 500,
|
|
entry_date: '2024-06-15',
|
|
})
|
|
|
|
// RPC returns GL lines
|
|
enqueue({ data: [glLine] })
|
|
// from('transactions') returns unmatched transactions
|
|
enqueue({ data: [tx] })
|
|
// Update transaction with link: .select('id') returns the updated row
|
|
enqueue({ data: [{ id: 'tx-1' }] })
|
|
|
|
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', { dryRun: false })
|
|
|
|
expect(result.matches).toHaveLength(1)
|
|
expect(result.applied).toBe(1)
|
|
expect(result.errors).toBe(0)
|
|
})
|
|
|
|
it('counts a conflicted apply (0 rows updated) as an error, not applied', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
const tx = makeTransaction({ id: 'tx-1', amount: -500, date: '2024-06-15', currency: 'SEK' })
|
|
const glLine: UnlinkedGLLine = makeGLLine({
|
|
line_id: 'line-1',
|
|
journal_entry_id: 'je-1',
|
|
credit_amount: 500,
|
|
entry_date: '2024-06-15',
|
|
})
|
|
|
|
enqueue({ data: [glLine] })
|
|
enqueue({ data: [tx] })
|
|
// Optimistic-lock guard: a concurrent linker got there first: the
|
|
// .is('journal_entry_id', null) filter matches zero rows.
|
|
enqueue({ data: [] })
|
|
|
|
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', { dryRun: false })
|
|
|
|
expect(result.applied).toBe(0)
|
|
expect(result.errors).toBe(1)
|
|
})
|
|
|
|
it('applies only the pairs in applyOnly, intersected with the fresh match run', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
const tx1 = makeTransaction({ id: 'tx-1', amount: 1000, date: '2024-06-15', currency: 'SEK' })
|
|
const tx2 = makeTransaction({ id: 'tx-2', amount: -500, date: '2024-06-15', currency: 'SEK' })
|
|
const line1 = makeGLLine({
|
|
line_id: 'line-1',
|
|
journal_entry_id: 'je-1',
|
|
debit_amount: 1000,
|
|
entry_date: '2024-06-15',
|
|
})
|
|
const line2 = makeGLLine({
|
|
line_id: 'line-2',
|
|
journal_entry_id: 'je-2',
|
|
credit_amount: 500,
|
|
entry_date: '2024-06-15',
|
|
})
|
|
|
|
enqueue({ data: [line1, line2] })
|
|
enqueue({ data: [tx1, tx2] })
|
|
// Only ONE update should run: for the single selected pair.
|
|
enqueue({ data: [{ id: 'tx-2' }] })
|
|
|
|
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', {
|
|
dryRun: false,
|
|
applyOnly: [
|
|
{ transactionId: 'tx-2', journalEntryId: 'je-2' },
|
|
// A pair the matcher never proposed must be ignored, not applied.
|
|
{ transactionId: 'tx-99', journalEntryId: 'je-99' },
|
|
],
|
|
})
|
|
|
|
expect(result.matches).toHaveLength(1)
|
|
expect(result.matches[0].transaction.id).toBe('tx-2')
|
|
expect(result.applied).toBe(1)
|
|
expect(result.errors).toBe(0)
|
|
})
|
|
|
|
it('skips matches below confidenceThreshold in apply mode but still reports them', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
// auto_exact (0.95): above the 0.9 floor, must apply.
|
|
const txExact = makeTransaction({ id: 'tx-exact', amount: 1000, date: '2024-06-15', currency: 'SEK' })
|
|
const lineExact = makeGLLine({
|
|
line_id: 'line-exact',
|
|
journal_entry_id: 'je-exact',
|
|
debit_amount: 1000,
|
|
entry_date: '2024-06-15',
|
|
})
|
|
// auto_fuzzy (0.75): below the 0.9 floor, must be skipped, not applied.
|
|
const txFuzzy = makeTransaction({ id: 'tx-fuzzy', amount: -999.99, date: '2024-06-15', currency: 'SEK' })
|
|
const lineFuzzy = makeGLLine({
|
|
line_id: 'line-fuzzy',
|
|
journal_entry_id: 'je-fuzzy',
|
|
credit_amount: 1000,
|
|
entry_date: '2024-06-15',
|
|
})
|
|
|
|
enqueue({ data: [lineExact, lineFuzzy] })
|
|
enqueue({ data: [txExact, txFuzzy] })
|
|
// Exactly ONE update runs: the exact match. If the fuzzy match were
|
|
// applied too, the queue would be short and this enqueue insufficient.
|
|
enqueue({ data: [{ id: 'tx-exact' }] })
|
|
|
|
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', {
|
|
dryRun: false,
|
|
confidenceThreshold: 0.9,
|
|
})
|
|
|
|
// Both matches are REPORTED (skipped is not silently dropped) ...
|
|
expect(result.matches).toHaveLength(2)
|
|
// ... but only the high-confidence one is applied.
|
|
expect(result.applied).toBe(1)
|
|
expect(result.errors).toBe(0)
|
|
expect(result.skippedBelowThreshold).toBe(1)
|
|
const skipped = result.matches.find((m) => m.transaction.id === 'tx-fuzzy')
|
|
expect(skipped?.method).toBe('auto_fuzzy')
|
|
})
|
|
|
|
it('applies a match exactly at the threshold (floor is inclusive)', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
// auto_date_range scores exactly 0.85: with threshold 0.85 it must apply.
|
|
const tx = makeTransaction({ id: 'tx-1', amount: 750, date: '2024-06-17', currency: 'SEK' })
|
|
const line = makeGLLine({
|
|
line_id: 'line-1',
|
|
journal_entry_id: 'je-1',
|
|
debit_amount: 750,
|
|
entry_date: '2024-06-15',
|
|
})
|
|
|
|
enqueue({ data: [line] })
|
|
enqueue({ data: [tx] })
|
|
enqueue({ data: [{ id: 'tx-1' }] })
|
|
|
|
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', {
|
|
dryRun: false,
|
|
confidenceThreshold: 0.85,
|
|
})
|
|
|
|
expect(result.applied).toBe(1)
|
|
expect(result.skippedBelowThreshold).toBe(0)
|
|
})
|
|
|
|
it('dry run is unaffected by confidenceThreshold: every proposal is returned', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
const txFuzzy = makeTransaction({ id: 'tx-fuzzy', amount: -999.99, date: '2024-06-15', currency: 'SEK' })
|
|
const lineFuzzy = makeGLLine({
|
|
line_id: 'line-fuzzy',
|
|
journal_entry_id: 'je-fuzzy',
|
|
credit_amount: 1000,
|
|
entry_date: '2024-06-15',
|
|
})
|
|
|
|
enqueue({ data: [lineFuzzy] })
|
|
enqueue({ data: [txFuzzy] })
|
|
|
|
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', {
|
|
dryRun: true,
|
|
confidenceThreshold: 0.9,
|
|
})
|
|
|
|
// The preview always shows the full proposal set: filtering happens only
|
|
// on apply, so the user can still review + tick fuzzy matches manually.
|
|
expect(result.matches).toHaveLength(1)
|
|
expect(result.matches[0].method).toBe('auto_fuzzy')
|
|
expect(result.applied).toBe(0)
|
|
expect(result.skippedBelowThreshold).toBe(0)
|
|
})
|
|
|
|
it('applies every match, including fuzzy, when no threshold is given (legacy behavior)', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
const txFuzzy = makeTransaction({ id: 'tx-fuzzy', amount: -999.99, date: '2024-06-15', currency: 'SEK' })
|
|
const lineFuzzy = makeGLLine({
|
|
line_id: 'line-fuzzy',
|
|
journal_entry_id: 'je-fuzzy',
|
|
credit_amount: 1000,
|
|
entry_date: '2024-06-15',
|
|
})
|
|
|
|
enqueue({ data: [lineFuzzy] })
|
|
enqueue({ data: [txFuzzy] })
|
|
enqueue({ data: [{ id: 'tx-fuzzy' }] })
|
|
|
|
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', {
|
|
dryRun: false,
|
|
})
|
|
|
|
expect(result.applied).toBe(1)
|
|
expect(result.errors).toBe(0)
|
|
expect(result.skippedBelowThreshold).toBe(0)
|
|
})
|
|
|
|
it('ignores applyOnly on dry runs and returns the full match set', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
const tx1 = makeTransaction({ id: 'tx-1', amount: 1000, date: '2024-06-15', currency: 'SEK' })
|
|
const line1 = makeGLLine({
|
|
line_id: 'line-1',
|
|
journal_entry_id: 'je-1',
|
|
debit_amount: 1000,
|
|
entry_date: '2024-06-15',
|
|
})
|
|
|
|
enqueue({ data: [line1] })
|
|
enqueue({ data: [tx1] })
|
|
|
|
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', {
|
|
dryRun: true,
|
|
applyOnly: [],
|
|
})
|
|
|
|
expect(result.matches).toHaveLength(1)
|
|
expect(result.applied).toBe(0)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// manualLink
|
|
// ============================================================
|
|
|
|
describe('manualLink', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
eventBus.clear()
|
|
})
|
|
|
|
function createQueueMockSupabase() {
|
|
const resultQueue: { data: unknown; error: unknown }[] = []
|
|
|
|
const enqueue = (...results: { data?: unknown; error?: unknown }[]) => {
|
|
for (const r of results) {
|
|
resultQueue.push({ data: r.data ?? null, error: r.error ?? null })
|
|
}
|
|
}
|
|
|
|
const buildChain = (): unknown => {
|
|
const handler: ProxyHandler<object> = {
|
|
get(_target, prop) {
|
|
if (prop === 'then') {
|
|
const next = resultQueue.shift() ?? { data: null, error: null }
|
|
return (resolve: (v: unknown) => void) => resolve(next)
|
|
}
|
|
return (..._args: unknown[]) => buildChain()
|
|
},
|
|
}
|
|
return new Proxy({}, handler)
|
|
}
|
|
|
|
const supabase = {
|
|
from: vi.fn().mockImplementation(() => buildChain()),
|
|
rpc: vi.fn().mockImplementation(() => buildChain()),
|
|
}
|
|
|
|
return { supabase, enqueue }
|
|
}
|
|
|
|
it('rejects when transaction not found', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
// Transaction query returns null
|
|
enqueue({ data: null, error: { message: 'Not found' } })
|
|
|
|
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1')
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toBe('Transaktionen kunde inte hittas.')
|
|
})
|
|
|
|
it('rejects when transaction is already linked to a LIVE (posted) entry', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: 'je-existing' })
|
|
|
|
// Transaction found, still pointing at a live posted verifikat
|
|
enqueue({ data: tx })
|
|
enqueue({ data: { status: 'posted' } }) // hasLiveJournalEntryLink: prior link is live
|
|
|
|
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1')
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toBe('Transaktionen är redan kopplad till en verifikation.')
|
|
})
|
|
|
|
it('re-links a transaction stranded on a reversed entry (#988)', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
// Still carries a pointer at a status='reversed' entry (storno/correction
|
|
// left it behind). The UI shows it as "utan koppling", so manualLink must
|
|
// treat it as free and overwrite the stale pointer.
|
|
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: 'je-reversed' })
|
|
|
|
enqueue({ data: tx }) // tx fetch
|
|
enqueue({ data: { status: 'reversed' } }) // hasLiveJournalEntryLink: stale link
|
|
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } }) // target JE fetch
|
|
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] }) // line on 1930
|
|
enqueue({ data: [{ id: 'tx-1' }] }) // UPDATE .eq(stale id) → 1 row overwritten
|
|
|
|
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
|
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects when journal entry has no line on the selected account', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
|
|
|
|
// Transaction found (cash_account_id null → cross-check skipped)
|
|
enqueue({ data: tx })
|
|
// Journal entry found
|
|
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
|
// No line on the selected account
|
|
enqueue({ data: [] })
|
|
|
|
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toBe('Verifikationen saknar rad på 1930')
|
|
})
|
|
|
|
it('rejects when the transaction belongs to a different cash account', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
const tx = makeTransaction({
|
|
id: 'tx-1',
|
|
journal_entry_id: null,
|
|
cash_account_id: 'ca-1931',
|
|
})
|
|
|
|
// Transaction found (bound to a cash account)
|
|
enqueue({ data: tx })
|
|
// Journal entry found + posted
|
|
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
|
// Cross-check: this cash account maps to 1931, but we're reconciling 1930
|
|
enqueue({ data: { ledger_account: '1931' } })
|
|
|
|
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toBe('Transaktionen hör till 1931, inte 1930')
|
|
})
|
|
|
|
it('succeeds when all validations pass (line on selected account)', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
|
|
|
|
// Transaction found (cash_account_id null → cross-check skipped)
|
|
enqueue({ data: tx })
|
|
// Journal entry found
|
|
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
|
// Line exists on the selected account
|
|
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
|
|
// Update succeeds: .select('id') returns the updated row
|
|
enqueue({ data: [{ id: 'tx-1' }] })
|
|
|
|
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
|
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('rejects when a concurrent linker won the race (0 rows updated)', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
|
|
|
|
enqueue({ data: tx })
|
|
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
|
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
|
|
// The .is('journal_entry_id', null) optimistic-lock filter matched nothing:
|
|
// another session linked the transaction between our read and this write.
|
|
enqueue({ data: [] })
|
|
|
|
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toBe('Transaktionen är redan kopplad till en verifikation.')
|
|
})
|
|
|
|
it('succeeds for a bound transaction when the account matches', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
const tx = makeTransaction({
|
|
id: 'tx-1',
|
|
journal_entry_id: null,
|
|
cash_account_id: 'ca-1930',
|
|
})
|
|
|
|
enqueue({ data: tx })
|
|
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
|
// Cross-check: cash account maps to the account being reconciled
|
|
enqueue({ data: { ledger_account: '1930' } })
|
|
// Line exists on 1930
|
|
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
|
|
// Update succeeds: .select('id') returns the updated row
|
|
enqueue({ data: [{ id: 'tx-1' }] })
|
|
|
|
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1930')
|
|
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('allows N:1, does not reject when the verifikat already has a linked transaction', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
// This transaction is itself unlinked; the TARGET entry already has another
|
|
// transaction pointing at it. manualLink no longer queries for / rejects
|
|
// that: several bank transactions may settle one verifikat (a salary run
|
|
// paid in multiple transfers). The only per-transaction guard is that THIS
|
|
// transaction isn't already linked (tx.journal_entry_id), still enforced.
|
|
const tx = makeTransaction({ id: 'tx-2', journal_entry_id: null })
|
|
|
|
enqueue({ data: tx })
|
|
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
|
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, account_number: '1930' }] })
|
|
// Update succeeds: note there is NO existing-link lookup in the sequence.
|
|
enqueue({ data: [{ id: 'tx-2' }] })
|
|
|
|
const result = await manualLink(supabase as never, 'company-1', 'tx-2', 'je-1', 'user-1', '1930')
|
|
|
|
expect(result.success).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// unlinkReconciliation
|
|
// ============================================================
|
|
|
|
describe('unlinkReconciliation', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
eventBus.clear()
|
|
})
|
|
|
|
function createQueueMockSupabase() {
|
|
const resultQueue: { data: unknown; error: unknown }[] = []
|
|
|
|
const enqueue = (...results: { data?: unknown; error?: unknown }[]) => {
|
|
for (const r of results) {
|
|
resultQueue.push({ data: r.data ?? null, error: r.error ?? null })
|
|
}
|
|
}
|
|
|
|
const buildChain = (): unknown => {
|
|
const handler: ProxyHandler<object> = {
|
|
get(_target, prop) {
|
|
if (prop === 'then') {
|
|
const next = resultQueue.shift() ?? { data: null, error: null }
|
|
return (resolve: (v: unknown) => void) => resolve(next)
|
|
}
|
|
return (..._args: unknown[]) => buildChain()
|
|
},
|
|
}
|
|
return new Proxy({}, handler)
|
|
}
|
|
|
|
const supabase = {
|
|
from: vi.fn().mockImplementation(() => buildChain()),
|
|
rpc: vi.fn().mockImplementation(() => buildChain()),
|
|
}
|
|
|
|
return { supabase, enqueue }
|
|
}
|
|
|
|
it('rejects when transaction has no reconciliation_method (categorization entry)', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
// Transaction found with journal_entry_id but no reconciliation_method
|
|
enqueue({
|
|
data: {
|
|
id: 'tx-1',
|
|
journal_entry_id: 'je-1',
|
|
reconciliation_method: null,
|
|
},
|
|
})
|
|
|
|
const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1', 'user-1')
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toContain('Cannot unlink')
|
|
})
|
|
|
|
it('succeeds when reconciliation_method is set', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
// Transaction found with reconciliation_method
|
|
enqueue({
|
|
data: {
|
|
id: 'tx-1',
|
|
journal_entry_id: 'je-1',
|
|
reconciliation_method: 'auto_exact',
|
|
},
|
|
})
|
|
// Update succeeds
|
|
enqueue({ data: null, error: null })
|
|
|
|
const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1', 'user-1')
|
|
|
|
expect(result.success).toBe(true)
|
|
})
|
|
|
|
it('attributes the audit log row to the acting user, not the company', async () => {
|
|
// Regression: unlinkReconciliation used to pass companyId where
|
|
// logMatchEvent expects userId, so payment_match_log.user_id recorded the
|
|
// company UUID (or the insert failed its FK silently).
|
|
const inserts: Record<string, unknown>[] = []
|
|
const resultQueue: { data: unknown; error: unknown }[] = [
|
|
{
|
|
data: { id: 'tx-1', journal_entry_id: 'je-1', reconciliation_method: 'manual' },
|
|
error: null,
|
|
},
|
|
{ data: null, error: null }, // update
|
|
]
|
|
const buildChain = (table?: string): unknown => {
|
|
const handler: ProxyHandler<object> = {
|
|
get(_target, prop) {
|
|
if (prop === 'then') {
|
|
const next = resultQueue.shift() ?? { data: null, error: null }
|
|
return (resolve: (v: unknown) => void) => resolve(next)
|
|
}
|
|
if (prop === 'insert') {
|
|
return (row: Record<string, unknown>) => {
|
|
if (table === 'payment_match_log') inserts.push(row)
|
|
return buildChain(table)
|
|
}
|
|
}
|
|
return (..._args: unknown[]) => buildChain(table)
|
|
},
|
|
}
|
|
return new Proxy({}, handler)
|
|
}
|
|
const supabase = {
|
|
from: vi.fn().mockImplementation((table: string) => buildChain(table)),
|
|
rpc: vi.fn().mockImplementation(() => buildChain()),
|
|
}
|
|
|
|
const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1', 'user-1')
|
|
|
|
expect(result.success).toBe(true)
|
|
expect(inserts).toHaveLength(1)
|
|
expect(inserts[0].user_id).toBe('user-1')
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// getReconciliationStatus: IB exclusion (PR 3 of #443)
|
|
// ============================================================
|
|
|
|
describe('getReconciliationStatus', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
eventBus.clear()
|
|
})
|
|
|
|
function createQueueMockSupabase() {
|
|
const resultQueue: { data: unknown; error: unknown }[] = []
|
|
const enqueue = (...results: { data?: unknown; error?: unknown }[]) => {
|
|
for (const r of results) resultQueue.push({ data: r.data ?? null, error: r.error ?? null })
|
|
}
|
|
const buildChain = (): unknown => {
|
|
const handler: ProxyHandler<object> = {
|
|
get(_target, prop) {
|
|
if (prop === 'then') {
|
|
const next = resultQueue.shift() ?? { data: null, error: null }
|
|
return (resolve: (v: unknown) => void) => resolve(next)
|
|
}
|
|
return (..._args: unknown[]) => buildChain()
|
|
},
|
|
}
|
|
return new Proxy({}, handler)
|
|
}
|
|
const supabase = {
|
|
from: vi.fn().mockImplementation(() => buildChain()),
|
|
rpc: vi.fn().mockImplementation(() => buildChain()),
|
|
}
|
|
return { supabase, enqueue }
|
|
}
|
|
|
|
it('reports is_reconciled=true when only the IB voucher is unmatched on 1930', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
// 1) transactions: 1000 SEK matched (journal_entry_id set)
|
|
enqueue({
|
|
data: [{ amount: 1000, journal_entry_id: 'je-tx', reconciliation_method: 'auto_exact' }],
|
|
})
|
|
// 2) journal_entry_lines: 50,000 IB debit + 1000 matched debit on 1930
|
|
// Two-step entry-lines fetch: entries page first, then lines by entry id
|
|
// (parents reattached under journal_entries by the helper).
|
|
enqueue({
|
|
data: [
|
|
{ id: 'je-gen1', status: 'posted', source_type: 'opening_balance' },
|
|
{ id: 'je-gen2', status: 'posted', source_type: 'bank_import' },
|
|
],
|
|
})
|
|
enqueue({
|
|
data: [
|
|
{ debit_amount: 50000, credit_amount: 0, journal_entry_id: 'je-gen1' },
|
|
{ debit_amount: 1000, credit_amount: 0, journal_entry_id: 'je-gen2' },
|
|
],
|
|
})
|
|
// 3) RPC get_unlinked_1930_lines: returns empty (RPC excludes IB after migration)
|
|
enqueue({ data: [] })
|
|
|
|
const status = await getReconciliationStatus(supabase as never, 'company-1')
|
|
|
|
expect(status.gl_1930_balance).toBe(51000) // includes IB
|
|
expect(status.gl_1930_period_movement).toBe(1000) // excludes IB
|
|
expect(status.gl_1930_opening_balance).toBe(50000)
|
|
expect(status.bank_transaction_total).toBe(1000)
|
|
expect(status.difference).toBe(0)
|
|
expect(status.is_reconciled).toBe(true)
|
|
expect(status.unmatched_gl_line_count).toBe(0)
|
|
})
|
|
|
|
it('reports is_reconciled=false and a non-zero difference when a real bank tx is unmatched', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
// 1) transactions: 1500 total, only 1000 matched
|
|
enqueue({
|
|
data: [
|
|
{ amount: 1000, journal_entry_id: 'je-1', reconciliation_method: null },
|
|
{ amount: 500, journal_entry_id: null, reconciliation_method: null },
|
|
],
|
|
})
|
|
// 2) GL lines: 50,000 IB + 1000 booked
|
|
// Two-step entry-lines fetch: entries page first, then lines by entry id
|
|
// (parents reattached under journal_entries by the helper).
|
|
enqueue({
|
|
data: [
|
|
{ id: 'je-gen3', status: 'posted', source_type: 'opening_balance' },
|
|
{ id: 'je-gen4', status: 'posted', source_type: 'bank_import' },
|
|
],
|
|
})
|
|
enqueue({
|
|
data: [
|
|
{ debit_amount: 50000, credit_amount: 0, journal_entry_id: 'je-gen3' },
|
|
{ debit_amount: 1000, credit_amount: 0, journal_entry_id: 'je-gen4' },
|
|
],
|
|
})
|
|
// 3) RPC: empty
|
|
enqueue({ data: [] })
|
|
|
|
const status = await getReconciliationStatus(supabase as never, 'company-1')
|
|
|
|
expect(status.bank_transaction_total).toBe(1500)
|
|
expect(status.gl_1930_period_movement).toBe(1000)
|
|
expect(status.gl_1930_opening_balance).toBe(50000)
|
|
expect(status.difference).toBe(500) // bank > GL period movement
|
|
expect(status.is_reconciled).toBe(false)
|
|
expect(status.unmatched_transaction_count).toBe(1)
|
|
})
|
|
|
|
it('handles companies with no IB on 1930 (period_movement === gl_balance)', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
enqueue({ data: [{ amount: 100, journal_entry_id: 'je-1', reconciliation_method: 'auto_exact' }] })
|
|
// Two-step entry-lines fetch: entries page first, then lines by entry id.
|
|
enqueue({ data: [{ id: 'je-1', status: 'posted', source_type: 'bank_import' }] })
|
|
enqueue({
|
|
data: [{ debit_amount: 100, credit_amount: 0, journal_entry_id: 'je-1' }],
|
|
})
|
|
enqueue({ data: [] })
|
|
|
|
const status = await getReconciliationStatus(supabase as never, 'company-1')
|
|
|
|
expect(status.gl_1930_opening_balance).toBe(0)
|
|
expect(status.gl_1930_period_movement).toBe(100)
|
|
expect(status.gl_1930_balance).toBe(100)
|
|
expect(status.difference).toBe(0)
|
|
expect(status.is_reconciled).toBe(true)
|
|
})
|
|
|
|
it('splits IB from period movement via the reattached parent entry', async () => {
|
|
// The two-step entry-lines fetch reattaches the parent entry object on
|
|
// each line under `journal_entries`; entryOf() reads source_type from it
|
|
// to split the IB summary out of the period movement.
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
enqueue({ data: [] })
|
|
// Two-step entry-lines fetch: entries page first, then lines by entry id.
|
|
enqueue({
|
|
data: [
|
|
{ id: 'je-ib', status: 'posted', source_type: 'opening_balance' },
|
|
{ id: 'je-mv', status: 'posted', source_type: 'bank_import' },
|
|
],
|
|
})
|
|
enqueue({
|
|
data: [
|
|
{ debit_amount: 1000, credit_amount: 0, journal_entry_id: 'je-ib' },
|
|
{ debit_amount: 200, credit_amount: 0, journal_entry_id: 'je-mv' },
|
|
],
|
|
})
|
|
enqueue({ data: [] })
|
|
|
|
const status = await getReconciliationStatus(supabase as never, 'company-1')
|
|
|
|
expect(status.gl_1930_opening_balance).toBe(1000)
|
|
expect(status.gl_1930_period_movement).toBe(200)
|
|
})
|
|
|
|
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: the +25000 deposit, re-pointed to the correction
|
|
enqueue({
|
|
data: [{ amount: 25000, journal_entry_id: 'je-corr', reconciliation_method: 'manual' }],
|
|
})
|
|
// 2) GL lines on 1930: reversed original (debit 25000), storno (credit
|
|
// 25000), correction (debit 25000). All three are summed. Served via
|
|
// the two-step entry-lines fetch: entries page, then lines by entry id.
|
|
enqueue({
|
|
data: [
|
|
{ id: 'je-orig', status: 'reversed', source_type: 'bank_transaction' },
|
|
{ id: 'je-storno', status: 'posted', source_type: 'storno' },
|
|
{ id: 'je-corr', status: 'posted', source_type: 'correction' },
|
|
],
|
|
})
|
|
const lines = [
|
|
{ debit_amount: 25000, credit_amount: 0, journal_entry_id: 'je-orig' },
|
|
{ debit_amount: 0, credit_amount: 25000, journal_entry_id: 'je-storno' },
|
|
{ debit_amount: 25000, credit_amount: 0, journal_entry_id: 'je-corr' },
|
|
]
|
|
enqueue({ data: lines })
|
|
// 3) RPC: empty
|
|
enqueue({ data: [] })
|
|
|
|
const status = await getReconciliationStatus(supabase as never, 'company-1')
|
|
|
|
// 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('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' }],
|
|
})
|
|
// Two-step entry-lines fetch: entries page first, then lines by entry id
|
|
// (parents reattached under journal_entries by the helper).
|
|
enqueue({
|
|
data: [
|
|
{ id: 'je-orig', status: 'reversed', source_type: 'bank_transaction' },
|
|
{ id: 'je-storno', status: 'posted', source_type: 'storno' },
|
|
{ id: 'je-corr', status: 'posted', source_type: 'correction' },
|
|
],
|
|
})
|
|
enqueue({
|
|
data: [
|
|
{ debit_amount: 24000, credit_amount: 0, journal_entry_id: 'je-orig' },
|
|
{ debit_amount: 0, credit_amount: 24000, journal_entry_id: 'je-storno' },
|
|
{ debit_amount: 25000, credit_amount: 0, journal_entry_id: 'je-corr' },
|
|
],
|
|
})
|
|
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()
|
|
|
|
enqueue({
|
|
data: [{ amount: 25000, journal_entry_id: 'je-orig', reconciliation_method: 'manual' }],
|
|
})
|
|
// Two-step entry-lines fetch: entries page first, then lines by entry id
|
|
// (parents reattached under journal_entries by the helper).
|
|
enqueue({
|
|
data: [
|
|
{ id: 'je-orig', status: 'reversed', source_type: 'bank_transaction' },
|
|
{ id: 'je-storno', status: 'posted', source_type: 'storno' },
|
|
{ id: 'je-corr', status: 'posted', source_type: 'correction' },
|
|
],
|
|
})
|
|
enqueue({
|
|
data: [
|
|
{ debit_amount: 25000, credit_amount: 0, journal_entry_id: 'je-orig' },
|
|
{ debit_amount: 0, credit_amount: 25000, journal_entry_id: 'je-storno' },
|
|
{ debit_amount: 25000, credit_amount: 0, journal_entry_id: 'je-corr' },
|
|
],
|
|
})
|
|
enqueue({ data: [] })
|
|
|
|
const status = await getReconciliationStatus(supabase as never, 'company-1')
|
|
|
|
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
|
|
// Two-step entry-lines fetch: entries page first, then lines by entry id
|
|
// (parents reattached under journal_entries by the helper).
|
|
enqueue({
|
|
data: [
|
|
{ id: 'je-manual', status: 'posted', source_type: 'manual' },
|
|
],
|
|
})
|
|
enqueue({
|
|
data: [
|
|
{ debit_amount: 500, credit_amount: 0, journal_entry_id: 'je-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)
|
|
})
|
|
|
|
it('floors a full-history window at the IB date so a prior period cannot count the IB (issue #751)', async () => {
|
|
// Real-world repro: the widget's date filter defaults to "full history"
|
|
// (no dateFrom). A company with two fiscal periods has prior-period (2025)
|
|
// movements on the account that net to EXACTLY the opening balance, plus the
|
|
// new period's IB entry dated on period start. Summing the whole history and
|
|
// only subtracting the IB *summary* leaves the prior-period *detail* in the
|
|
// movement while the bank feed only covers the current period: a phantom
|
|
// difference equal to the IB. The server now floors the window at the most
|
|
// recent IB date on the account, clamping BOTH sides to the current period.
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
// 1) transactions: a prior-period feed line (6000, 2025) + the current
|
|
// period's feed (5000, 2026). The floor must drop the 2025 one.
|
|
enqueue({
|
|
data: [
|
|
{ date: '2025-03-31', amount: 6000, journal_entry_id: 'je-p', reconciliation_method: 'manual' },
|
|
{ date: '2026-02-15', amount: 5000, journal_entry_id: 'je-c', reconciliation_method: 'manual' },
|
|
],
|
|
})
|
|
// 2) GL lines on the account: prior-period movements (6000 + 4000 = 10000,
|
|
// dated 2025) that net to the IB, the IB itself (10000, dated 2026-01-01),
|
|
// and the current period's movement (5000, dated 2026).
|
|
// Two-step entry-lines fetch: entries page first, then lines by entry id
|
|
// (parents reattached under journal_entries by the helper).
|
|
enqueue({
|
|
data: [
|
|
{ id: 'je-p1', status: 'posted', source_type: 'import', entry_date: '2025-03-31' },
|
|
{ id: 'je-p2', status: 'posted', source_type: 'import', entry_date: '2025-09-30' },
|
|
{ id: 'je-ib', status: 'posted', source_type: 'opening_balance', entry_date: '2026-01-01' },
|
|
{ id: 'je-c1', status: 'posted', source_type: 'bank_transaction', entry_date: '2026-02-15' },
|
|
],
|
|
})
|
|
enqueue({
|
|
data: [
|
|
{ debit_amount: 6000, credit_amount: 0, journal_entry_id: 'je-p1' },
|
|
{ debit_amount: 4000, credit_amount: 0, journal_entry_id: 'je-p2' },
|
|
{ debit_amount: 10000, credit_amount: 0, journal_entry_id: 'je-ib' },
|
|
{ debit_amount: 5000, credit_amount: 0, journal_entry_id: 'je-c1' },
|
|
],
|
|
})
|
|
// 3) RPC: empty
|
|
enqueue({ data: [] })
|
|
|
|
// No dateFrom: the "full history" default that triggers the bug.
|
|
const status = await getReconciliationStatus(supabase as never, 'company-1')
|
|
|
|
// Both sides clamped to >= 2026-01-01 (the IB date): only the IB + the
|
|
// current-period movement remain on the GL side; only the 2026 feed remains
|
|
// on the bank side. The prior period (which equalled the IB) is excluded.
|
|
expect(status.gl_1930_balance).toBe(15000) // IB 10000 + 5000 (NOT 25000)
|
|
expect(status.gl_1930_period_movement).toBe(5000) // IB excluded
|
|
expect(status.gl_1930_opening_balance).toBe(10000) // UI "räknas inte" note stays truthful
|
|
expect(status.bank_transaction_total).toBe(5000) // 2025 feed floored out
|
|
expect(status.difference).toBe(0)
|
|
expect(status.is_reconciled).toBe(true)
|
|
})
|
|
|
|
it('floors at the IB date even when the caller passes a dateFrom earlier than the IB', async () => {
|
|
// Safety net for a manual date override / direct API call: clearing the date
|
|
// filter (or setting it before the IB) must not resurrect the phantom diff.
|
|
// effectiveFrom = max(dateFrom, ibDate), so an earlier dateFrom is raised.
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
enqueue({
|
|
data: [
|
|
{ date: '2025-05-01', amount: 8000, journal_entry_id: 'je-p', reconciliation_method: 'manual' },
|
|
{ date: '2026-04-10', amount: 3000, journal_entry_id: 'je-c', reconciliation_method: 'manual' },
|
|
],
|
|
})
|
|
// Two-step entry-lines fetch: entries page first, then lines by entry id
|
|
// (parents reattached under journal_entries by the helper).
|
|
enqueue({
|
|
data: [
|
|
{ id: 'je-p1', status: 'posted', source_type: 'import', entry_date: '2025-05-01' },
|
|
{ id: 'je-ib', status: 'posted', source_type: 'opening_balance', entry_date: '2026-01-01' },
|
|
{ id: 'je-c1', status: 'posted', source_type: 'bank_transaction', entry_date: '2026-04-10' },
|
|
],
|
|
})
|
|
enqueue({
|
|
data: [
|
|
{ debit_amount: 8000, credit_amount: 0, journal_entry_id: 'je-p1' },
|
|
{ debit_amount: 8000, credit_amount: 0, journal_entry_id: 'je-ib' },
|
|
{ debit_amount: 3000, credit_amount: 0, journal_entry_id: 'je-c1' },
|
|
],
|
|
})
|
|
enqueue({ data: [] })
|
|
|
|
// dateFrom deliberately before the IB date.
|
|
const status = await getReconciliationStatus(supabase as never, 'company-1', '2025-01-01')
|
|
|
|
expect(status.gl_1930_period_movement).toBe(3000) // IB + prior detail both excluded
|
|
expect(status.gl_1930_opening_balance).toBe(8000)
|
|
expect(status.bank_transaction_total).toBe(3000) // 2025 feed floored out
|
|
expect(status.difference).toBe(0)
|
|
expect(status.is_reconciled).toBe(true)
|
|
})
|
|
|
|
it('reconciles a mid-period window (dateFrom after the IB date) on movements alone', async () => {
|
|
// Per-month reconciliation: the user scopes to March, after the fiscal-year
|
|
// IB (2026-01-01). effectiveFrom = max(dateFrom, ibDate) = the March dateFrom,
|
|
// so the IB and Jan/Feb movements are correctly excluded: they belong to the
|
|
// opening position of a March window, not its movements. gl_1930_opening_balance
|
|
// is 0 here BY DESIGN: a mid-period window contains no fiscal-year IB, so the
|
|
// "räknas inte" note is simply absent (not misleadingly zero). The window
|
|
// reconciles on March's movements vs March's feed.
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
enqueue({
|
|
data: [
|
|
{ date: '2026-02-10', amount: 2000, journal_entry_id: 'je-feb', reconciliation_method: 'manual' },
|
|
{ date: '2026-03-15', amount: 3000, journal_entry_id: 'je-mar', reconciliation_method: 'manual' },
|
|
],
|
|
})
|
|
// Two-step entry-lines fetch: entries page first, then lines by entry id
|
|
// (parents reattached under journal_entries by the helper).
|
|
enqueue({
|
|
data: [
|
|
{ id: 'je-ib', status: 'posted', source_type: 'opening_balance', entry_date: '2026-01-01' },
|
|
{ id: 'je-feb1', status: 'posted', source_type: 'import', entry_date: '2026-02-10' },
|
|
{ id: 'je-mar1', status: 'posted', source_type: 'import', entry_date: '2026-03-15' },
|
|
],
|
|
})
|
|
enqueue({
|
|
data: [
|
|
{ debit_amount: 9000, credit_amount: 0, journal_entry_id: 'je-ib' },
|
|
{ debit_amount: 2000, credit_amount: 0, journal_entry_id: 'je-feb1' },
|
|
{ debit_amount: 3000, credit_amount: 0, journal_entry_id: 'je-mar1' },
|
|
],
|
|
})
|
|
enqueue({ data: [] })
|
|
|
|
// dateFrom in March: after the 2026-01-01 IB.
|
|
const status = await getReconciliationStatus(supabase as never, 'company-1', '2026-03-01')
|
|
|
|
expect(status.gl_1930_opening_balance).toBe(0) // IB not part of a March window
|
|
expect(status.gl_1930_period_movement).toBe(3000) // only March movements
|
|
expect(status.bank_transaction_total).toBe(3000) // only March feed
|
|
expect(status.difference).toBe(0)
|
|
expect(status.is_reconciled).toBe(true)
|
|
})
|
|
|
|
// ----------------------------------------------------------------
|
|
// Avstämt requires BOTH a zero net difference AND nothing unidentified
|
|
// ----------------------------------------------------------------
|
|
|
|
it('is NOT reconciled when two unmatched transactions offset each other', async () => {
|
|
// +500 and -500 net to zero against a zero period movement, so the old
|
|
// net-difference test called this avstämt while BOTH rows were still
|
|
// unbooked affärshändelser, each owing its own verifikation identifying
|
|
// belopp and motpart (BFL 5 kap 1-2 §, 6-7 §). Offsetting two unknowns is
|
|
// not knowing either one (ÅRL 2 kap: individuell värdering,
|
|
// bruttoredovisning): the account is not avstämt until both are booked.
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
enqueue({
|
|
data: [
|
|
{ date: '2026-03-05', amount: 500, journal_entry_id: null, reconciliation_method: null },
|
|
{ date: '2026-03-06', amount: -500, journal_entry_id: null, reconciliation_method: null },
|
|
],
|
|
})
|
|
// Only the IB on the ledger: period movement is 0, matching the bank net.
|
|
enqueue({
|
|
data: [{ id: 'je-ib', status: 'posted', source_type: 'opening_balance', entry_date: '2026-01-01' }],
|
|
})
|
|
enqueue({ data: [{ debit_amount: 50000, credit_amount: 0, journal_entry_id: 'je-ib' }] })
|
|
enqueue({ data: [] })
|
|
|
|
const status = await getReconciliationStatus(supabase as never, 'company-1')
|
|
|
|
expect(status.difference).toBe(0)
|
|
expect(status.unmatched_transaction_count).toBe(2)
|
|
expect(status.is_reconciled).toBe(false)
|
|
})
|
|
|
|
// ----------------------------------------------------------------
|
|
// Foreign-currency accounts are reconciled in their OWN currency
|
|
// ----------------------------------------------------------------
|
|
|
|
it('reconciles a EUR cash account in EUR, not against its SEK ledger figures', async () => {
|
|
// 100 EUR booked at 11.50: debit_amount holds the 1150 SEK conversion while
|
|
// the EUR amount sits in amount_in_currency. Summing the SEK column against
|
|
// a EUR bank feed produced a difference roughly the size of the exchange
|
|
// rate, so a foreign cash account could never show is_reconciled.
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
enqueue({
|
|
data: [{ date: '2026-03-05', amount: 100, journal_entry_id: 'je-eur', reconciliation_method: 'manual' }],
|
|
})
|
|
enqueue({
|
|
data: [{ id: 'je-eur', status: 'posted', source_type: 'import', entry_date: '2026-03-05' }],
|
|
})
|
|
enqueue({
|
|
data: [
|
|
{
|
|
debit_amount: 1150,
|
|
credit_amount: 0,
|
|
currency: 'EUR',
|
|
amount_in_currency: 100,
|
|
journal_entry_id: 'je-eur',
|
|
},
|
|
],
|
|
})
|
|
enqueue({ data: [] })
|
|
|
|
const status = await getReconciliationStatus(
|
|
supabase as never,
|
|
'company-1',
|
|
undefined,
|
|
undefined,
|
|
'1932',
|
|
'EUR',
|
|
)
|
|
|
|
expect(status.currency).toBe('EUR')
|
|
expect(status.gl_1930_period_movement).toBe(100) // EUR, not 1150 SEK
|
|
expect(status.bank_transaction_total).toBe(100)
|
|
expect(status.difference).toBe(0)
|
|
expect(status.is_reconciled).toBe(true)
|
|
expect(status.not_reconcilable_reason).toBeNull()
|
|
expect(status.unconvertible_gl_line_count).toBe(0)
|
|
})
|
|
|
|
it('reports not-reconcilable-yet when a foreign account has lines with no amount in that currency', async () => {
|
|
// A SIE-imported / pre-FX line on the EUR account: only a SEK figure, no
|
|
// per-row rate. There is nothing honest to convert with, so the window must
|
|
// say so instead of quietly treating 1150 SEK as 1150 EUR.
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
enqueue({
|
|
data: [{ date: '2026-03-05', amount: 100, journal_entry_id: 'je-eur', reconciliation_method: 'manual' }],
|
|
})
|
|
enqueue({
|
|
data: [{ id: 'je-eur', status: 'posted', source_type: 'import', entry_date: '2026-03-05' }],
|
|
})
|
|
enqueue({
|
|
data: [{ debit_amount: 1150, credit_amount: 0, currency: 'SEK', journal_entry_id: 'je-eur' }],
|
|
})
|
|
enqueue({ data: [] })
|
|
|
|
const status = await getReconciliationStatus(
|
|
supabase as never,
|
|
'company-1',
|
|
undefined,
|
|
undefined,
|
|
'1932',
|
|
'EUR',
|
|
)
|
|
|
|
expect(status.unconvertible_gl_line_count).toBe(1)
|
|
expect(status.not_reconcilable_reason).toBe('gl_lines_missing_currency_amount')
|
|
expect(status.is_reconciled).toBe(false)
|
|
})
|
|
|
|
it('leaves a SEK account untouched when its lines carry a foreign LABEL', async () => {
|
|
// 95%-path regression guard: a SEK payment of a EUR supplier invoice is
|
|
// labelled 'EUR' on the line while debit/credit are SEK. Reconciling 1930
|
|
// in SEK must use the SEK columns and never flag it as unconvertible.
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
enqueue({
|
|
data: [{ date: '2026-03-05', amount: -1150, journal_entry_id: 'je-1', reconciliation_method: 'manual' }],
|
|
})
|
|
enqueue({
|
|
data: [{ id: 'je-1', status: 'posted', source_type: 'import', entry_date: '2026-03-05' }],
|
|
})
|
|
enqueue({
|
|
data: [
|
|
{
|
|
debit_amount: 0,
|
|
credit_amount: 1150,
|
|
currency: 'EUR',
|
|
amount_in_currency: 100,
|
|
journal_entry_id: 'je-1',
|
|
},
|
|
],
|
|
})
|
|
enqueue({ data: [] })
|
|
|
|
const status = await getReconciliationStatus(supabase as never, 'company-1')
|
|
|
|
expect(status.currency).toBe('SEK')
|
|
expect(status.gl_1930_period_movement).toBe(-1150)
|
|
expect(status.difference).toBe(0)
|
|
expect(status.is_reconciled).toBe(true)
|
|
expect(status.unconvertible_gl_line_count).toBe(0)
|
|
expect(status.not_reconcilable_reason).toBeNull()
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// Unscoped-run diagnostic (#1290)
|
|
// ============================================================
|
|
//
|
|
// Leaving cashAccountId undefined makes scopeTransactionsToAccount fall back to
|
|
// a currency-only filter: the transaction side pools EVERY same-currency cash
|
|
// account while the GL side stays on one accountNumber. On the read path that
|
|
// is the phantom difference the issue reported; on the write path it can
|
|
// auto-link a savings-account transaction to a 1930 voucher. The engine cannot
|
|
// refuse (single-account companies with no cash_accounts row legitimately take
|
|
// the same fallback), so it warns exactly when the fetched rows really do span
|
|
// more than one account.
|
|
|
|
describe('unscoped cash-account diagnostic', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
eventBus.clear()
|
|
})
|
|
|
|
// A real cash_accounts.id has to survive the UUID assertion inside
|
|
// scopeTransactionsToAccount, so these are shaped like real ones.
|
|
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
|
|
const ACCOUNT_B = '22222222-2222-4222-8222-222222222222'
|
|
|
|
/**
|
|
* The queue-driven proxy the suites above use, plus a record of every builder
|
|
* call so the transactions select list can be pinned.
|
|
*/
|
|
function createRecordingMockSupabase() {
|
|
const resultQueue: { data: unknown; error: unknown }[] = []
|
|
const calls: { method: string; args: unknown[] }[] = []
|
|
|
|
const enqueue = (...results: { data?: unknown; error?: unknown }[]) => {
|
|
for (const r of results) resultQueue.push({ data: r.data ?? null, error: r.error ?? null })
|
|
}
|
|
|
|
const buildChain = (): unknown => {
|
|
const handler: ProxyHandler<object> = {
|
|
get(_target, prop) {
|
|
if (prop === 'then') {
|
|
const next = resultQueue.shift() ?? { data: null, error: null }
|
|
return (resolve: (v: unknown) => void) => resolve(next)
|
|
}
|
|
return (...args: unknown[]) => {
|
|
calls.push({ method: String(prop), args })
|
|
return buildChain()
|
|
}
|
|
},
|
|
}
|
|
return new Proxy({}, handler)
|
|
}
|
|
|
|
const supabase = {
|
|
from: vi.fn().mockImplementation((table: string) => {
|
|
calls.push({ method: 'from', args: [table] })
|
|
return buildChain()
|
|
}),
|
|
rpc: vi.fn().mockImplementation((fn: string) => {
|
|
calls.push({ method: 'rpc', args: [fn] })
|
|
return buildChain()
|
|
}),
|
|
}
|
|
|
|
return { supabase, enqueue, calls }
|
|
}
|
|
|
|
/** The four reads getReconciliationStatus makes, in order. */
|
|
function enqueueStatusReads(
|
|
enqueue: (...results: { data?: unknown; error?: unknown }[]) => void,
|
|
transactions: unknown[],
|
|
) {
|
|
enqueue({ data: transactions }) // 1) transactions
|
|
enqueue({ data: [] }) // 2) journal_entries page
|
|
enqueue({ data: [] }) // 3) journal_entry_lines for those entries
|
|
enqueue({ data: [] }) // 4) RPC get_unlinked_1930_lines
|
|
}
|
|
|
|
function statusTx(cashAccountId: string | null, amount = 100) {
|
|
return {
|
|
date: '2026-03-05',
|
|
amount,
|
|
journal_entry_id: 'je-1',
|
|
reconciliation_method: 'manual',
|
|
is_ignored: false,
|
|
cash_account_id: cashAccountId,
|
|
}
|
|
}
|
|
|
|
it('selects cash_account_id on the transactions read (the diagnostic needs it)', async () => {
|
|
// Pins the column list: drop cash_account_id from the select and every row
|
|
// arrives with it undefined, so the warning below can never fire again.
|
|
const { supabase, enqueue, calls } = createRecordingMockSupabase()
|
|
enqueueStatusReads(enqueue, [])
|
|
|
|
await getReconciliationStatus(supabase as never, 'company-1')
|
|
|
|
const firstFrom = calls.find((c) => c.method === 'from')
|
|
expect(firstFrom?.args[0]).toBe('transactions')
|
|
const firstSelect = calls.find((c) => c.method === 'select')
|
|
expect(firstSelect?.args[0]).toBe(
|
|
'date, amount, journal_entry_id, reconciliation_method, is_ignored, cash_account_id',
|
|
)
|
|
})
|
|
|
|
it('warns when getReconciliationStatus runs unscoped over more than one cash account', async () => {
|
|
const { supabase, enqueue } = createRecordingMockSupabase()
|
|
enqueueStatusReads(enqueue, [statusTx(ACCOUNT_A), statusTx(ACCOUNT_B, 250)])
|
|
|
|
await getReconciliationStatus(supabase as never, 'company-1')
|
|
|
|
expect(logWarn).toHaveBeenCalledWith(
|
|
'getReconciliationStatus ran unscoped across several cash accounts',
|
|
expect.objectContaining({
|
|
companyId: 'company-1',
|
|
operation: 'getReconciliationStatus',
|
|
entityType: 'cash_account',
|
|
details: expect.objectContaining({
|
|
accountNumber: '1930',
|
|
currency: 'SEK',
|
|
distinctCashAccounts: 2,
|
|
}),
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('stays silent when getReconciliationStatus is scoped to a cash account', async () => {
|
|
// The fix the issue asked for: a scoped call is correct by construction, so
|
|
// it must not warn even when the mock hands back foreign rows.
|
|
const { supabase, enqueue } = createRecordingMockSupabase()
|
|
enqueueStatusReads(enqueue, [statusTx(ACCOUNT_A), statusTx(ACCOUNT_B, 250)])
|
|
|
|
await getReconciliationStatus(
|
|
supabase as never,
|
|
'company-1',
|
|
undefined,
|
|
undefined,
|
|
'1930',
|
|
'SEK',
|
|
ACCOUNT_A,
|
|
true,
|
|
)
|
|
|
|
expect(logWarn).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('stays silent when an unscoped run sees exactly one cash account', async () => {
|
|
const { supabase, enqueue } = createRecordingMockSupabase()
|
|
enqueueStatusReads(enqueue, [statusTx(ACCOUNT_A), statusTx(ACCOUNT_A, 250)])
|
|
|
|
await getReconciliationStatus(supabase as never, 'company-1')
|
|
|
|
expect(logWarn).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('stays silent when an unscoped run sees only unassigned rows', async () => {
|
|
// The legacy single-account company that has no cash_accounts row at all:
|
|
// the currency-only fallback is the intended behaviour there, not a bug.
|
|
const { supabase, enqueue } = createRecordingMockSupabase()
|
|
enqueueStatusReads(enqueue, [statusTx(null), statusTx(null, 250)])
|
|
|
|
await getReconciliationStatus(supabase as never, 'company-1')
|
|
|
|
expect(logWarn).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('warns when runReconciliation runs unscoped over more than one cash account', async () => {
|
|
// The WRITE path: this sweep applies matches, so pooling here can persist a
|
|
// wrong journal_entry_id, not merely display a wrong figure.
|
|
const { supabase, enqueue } = createRecordingMockSupabase()
|
|
enqueue({ data: [] }) // RPC: unlinked GL lines
|
|
enqueue({
|
|
data: [
|
|
makeTransaction({ id: 'tx-1', cash_account_id: ACCOUNT_A }),
|
|
makeTransaction({ id: 'tx-2', cash_account_id: ACCOUNT_B }),
|
|
],
|
|
})
|
|
|
|
await runReconciliation(supabase as never, 'company-1', 'user-1')
|
|
|
|
expect(logWarn).toHaveBeenCalledWith(
|
|
'runReconciliation ran unscoped across several cash accounts',
|
|
expect.objectContaining({
|
|
companyId: 'company-1',
|
|
operation: 'runReconciliation',
|
|
entityType: 'cash_account',
|
|
details: expect.objectContaining({
|
|
accountNumber: '1930',
|
|
currency: 'SEK',
|
|
distinctCashAccounts: 2,
|
|
}),
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('stays silent when runReconciliation is scoped to a cash account', async () => {
|
|
const { supabase, enqueue } = createRecordingMockSupabase()
|
|
enqueue({ data: [] })
|
|
enqueue({
|
|
data: [
|
|
makeTransaction({ id: 'tx-1', cash_account_id: ACCOUNT_A }),
|
|
makeTransaction({ id: 'tx-2', cash_account_id: ACCOUNT_B }),
|
|
],
|
|
})
|
|
|
|
await runReconciliation(supabase as never, 'company-1', 'user-1', {
|
|
cashAccountId: ACCOUNT_A,
|
|
includeUnassigned: true,
|
|
})
|
|
|
|
expect(logWarn).not.toHaveBeenCalled()
|
|
})
|
|
})
|