* 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>
1999 lines
76 KiB
TypeScript
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()
|
|
})
|
|
})
|