Files
accounted/lib/reconciliation/__tests__/bank-reconciliation.test.ts
T
MattssonandClaude Fable 5 08440fed94 feat(reconciliation): match migrated bank history against imported SIE verifikat (#1598)
* feat(reconciliation): match migrated bank history against imported SIE verifikat

A first-class Fortnox/SIE migrator path: after SIE import plus bank connect
or bank CSV upload, historical bank rows are auto-matched (>= 0.9) or
suggestion-matched (0.75-0.89, persisted for review) against the imported
verifikat, with a guided review surface, instead of landing as anonymous
"Att bokfora" rows.

Phase 0: per-cash-account unattended sweep (fixes #1298 cross-account
pooling); widen payment_match_log action CHECK with
linked_to_existing_voucher (silently unlogged since March).
Phase 1: potential_journal_entry_id/method/confidence on transactions with
CHECK + invalidation triggers; persistSuggestions in runReconciliation;
sweep after bank CSV import with SIE overlap (suppressing
auto-categorization); sweep summaries stamped on bank_connections and
bank_file_imports; POST /api/reconciliation/bank/confirm-suggestions with
per-pair server-side revalidation (voucher consumption + bank-leg amount
and direction).
Phase 2: "Granska forslag" review tab on Transactions with chunked bulk
confirm, per-row fallbacks, "Kor matchning igen" (all_accounts sweep mode,
mutually exclusive with dry_run), attn line, pre-migration row marker.
Phase 3: ImportResultStep dual CTA (bank connect + CSV), migrator variant
of the account-picker #917 nudge, sweep outcome on the onboarding
checklist bank step.

Non-selection apply runs on /api/reconciliation/bank/run now floor at 0.9
and persist the review band instead of auto-committing fuzzy matches.
Migrations already applied to staging under the same versions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): resolve PR review findings in one pass

Swedish accounting review (both previously-deferred holes closed):
- runReconciliation's >= 0.9 auto-apply now writes 'matched' to
  payment_match_log (behandlingshistorik, BFNAR 2013:2 kap 8); the bus
  event alone lands in the 30-day event_log and is not an audit record.
- The three match-route storno-conflict branches detach reconciliation
  links via unlinkReconciliation instead of storno-reversing the linked
  verifikat: a reconciliation link points at an independent verifikat
  that may evidence other affarshandelser, and a wholesale reversal is
  an over-broad rattelse (BFL 5 kap 5 §).
- Historical gap quantified on prod (read-only, recorded in DECISIONS):
  762 unlogged manual links across 52 companies since 2026-03-23.

CodeRabbit:
- confirm-suggestions route: maxDuration 300 for full 500-item batches.
- AccountPickerDialog: migrator-nudge buttons set lookbackTouched so the
  async gap-fill probe cannot override an explicit choice.
- enable-banking post-backfill sweep: persistSuggestions so the review
  band is not dropped.
- bank-file execute: sie_sweep stamp errors are logged, not swallowed.
- ImportResultStep: sandbox keeps the CSV CTA (file import works there).
- payment_match_log CHECK swap: NOT VALID + VALIDATE, no table scan
  under ACCESS EXCLUSIVE.
- logMatchEvent calls awaited (serverless can freeze unawaited work).
- DECISIONS.md stale version reference annotated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): defer reconciliation-link detach until the match commits

Round-2 review findings:
- CodeRabbit: the eager unlinkReconciliation call could orphan a
  transaction if the match flow failed after it. All three match routes
  now persist NOTHING up front: the final transaction update overwrites
  journal_entry_id and clears reconciliation_method in the same write,
  so any failure in between leaves the existing link intact. The release
  is logged as 'unmatched' after the commit.
- Swedish review: the auto_suggested logMatchEvent in runReconciliation
  is now awaited like every other audit write.
- DECISIONS entry split into compliance/CodeRabbit lines and updated to
  describe the deferred detach.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): literal reconciliation_method payloads for the phantom-column scanner

The conditional spreads introduced with the deferred detach pushed the
scanner's unresolvable-expression count past its ceiling (380 > 378).
reconciliation_method: null is correct unconditionally on a confirmed
invoice/supplier match (null is already the value on every row that was
not reconciliation-linked), so the payloads become plain literals the
guard can verify. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 23:12:27 +02:00

1999 lines
76 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('persists the below-threshold band as suggestions when persistSuggestions is set', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
// Fuzzy match: amount off by 1 öre on the exact date → 0.75 confidence,
// below the 0.9 unattended floor.
const tx = makeTransaction({ id: 'tx-1', amount: 1000.01, 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',
})
enqueue({ data: [glLine] }) // RPC: GL lines
enqueue({ data: [tx] }) // transactions
enqueue({ data: [{ id: 'tx-1' }] }) // suggestion update .select('id')
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', {
confidenceThreshold: 0.9,
persistSuggestions: true,
})
expect(result.applied).toBe(0)
expect(result.skippedBelowThreshold).toBe(1)
expect(result.suggested).toBe(1)
expect(result.candidates).toBe(1)
})
it('does not count a suggestion whose optimistic-lock update matched zero rows', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const tx = makeTransaction({ id: 'tx-1', amount: 1000.01, 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',
})
enqueue({ data: [glLine] })
enqueue({ data: [tx] })
// A concurrent writer booked the row: .is('journal_entry_id', null) → 0 rows.
enqueue({ data: [] })
const result = await runReconciliation(supabase as never, 'company-1', 'user-1', {
confidenceThreshold: 0.9,
persistSuggestions: true,
})
expect(result.suggested).toBe(0)
expect(result.skippedBelowThreshold).toBe(1)
})
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()
})
})