One bank row can now settle several vouchers: journal_entry_id stays NULL and one transaction_voucher_links row per voucher carries a signed allocated_amount slice (sum must equal the row within the link tolerance, each slice bounded by the voucher's net line on the account). linkTransactionToVouchers does the locked transaction UPDATE first and rolls back on a failed junction insert; unlink and the re-booking guards understand junction-only rows; a storno of one of the N vouchers releases the row when the remaining slices no longer sum to its amount. The worksheet's right pane becomes multi-select when exactly one bank row is picked (Koppla only at difference 0); the v1/dashboard pair schemas accept allocations; the MCP reconcile resolver and executor carry 1:N pairs; skattekonto keeps single-pointer semantics. Closes #1553
3026 lines
120 KiB
TypeScript
3026 lines
120 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,
|
|
linkTransactionToVouchers,
|
|
unlinkReconciliation,
|
|
getReconciliationStatus,
|
|
scopeTransactionsToAccount,
|
|
ledgerLineAmountIn,
|
|
} from '../bank-reconciliation'
|
|
import type { UnlinkedGLLine } from '../bank-reconciliation'
|
|
import { createQueuedMockSupabase, 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 }[] = []
|
|
// Every chained method call, in order, so a test can assert on the
|
|
// payload handed to .update(...) (the #1643 re-point cases below).
|
|
const calls: Array<{ 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(() => buildChain()),
|
|
rpc: vi.fn().mockImplementation(() => buildChain()),
|
|
}
|
|
|
|
const updatePayloads = () =>
|
|
calls.filter((c) => c.method === 'update').map((c) => c.args[0] as Record<string, unknown>)
|
|
|
|
return { supabase, enqueue, updatePayloads }
|
|
}
|
|
|
|
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('refuses a row anchored through a bank_line junction row (bulk-book, 1:N split) even with a NULL pointer', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
const tx = {
|
|
...makeTransaction({ id: 'tx-1', journal_entry_id: null }),
|
|
transaction_voucher_links: [{ journal_entry_id: 'je-split-a', role: 'bank_line' }],
|
|
}
|
|
enqueue({ data: tx })
|
|
|
|
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.')
|
|
// Nothing past the transaction read: no verifikat lookup, no write.
|
|
expect(supabase.from).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('does not treat a residual booking\'s "other" junction row as a link (the row stays re-linkable after a storno)', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
const tx = {
|
|
...makeTransaction({ id: 'tx-1', journal_entry_id: null }),
|
|
transaction_voucher_links: [{ journal_entry_id: 'je-residual', role: 'other' }],
|
|
}
|
|
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' }] })
|
|
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' } })
|
|
// Sibling-ledger scan (#1643): a row without an IBAN has no siblings.
|
|
enqueue({ data: [{ id: 'ca-1930', iban: null, ledger_account: '1930', currency: 'SEK', enabled: true, bank_connection_id: null }] })
|
|
// 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('links a transaction stranded on an orphaned row to a voucher on the live sibling ledger and re-points it there (#1643)', async () => {
|
|
const { supabase, enqueue, updatePayloads } = createQueueMockSupabase()
|
|
const iban = 'SE4550000000058398257466'
|
|
// Transaction stamped on the orphaned reconnect row (ledger 1931); the
|
|
// verifikat it settles is booked on the live ledger 1940 of the SAME
|
|
// physical account (same IBAN).
|
|
const tx = makeTransaction({
|
|
id: 'tx-1',
|
|
journal_entry_id: null,
|
|
cash_account_id: 'ca-orphan',
|
|
currency: 'SEK',
|
|
})
|
|
|
|
enqueue({ data: tx })
|
|
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
|
// Cross-check: the transaction's own ledger IS the requested account.
|
|
enqueue({ data: { ledger_account: '1931' } })
|
|
// Sibling scan: the own (demoted) row carries the IBAN and the live row
|
|
// shares it on 1940.
|
|
enqueue({
|
|
data: [
|
|
{ id: 'ca-orphan', iban, ledger_account: '1931', currency: 'SEK', enabled: true, bank_connection_id: null },
|
|
{ id: 'ca-live', iban, ledger_account: '1940', currency: 'SEK', enabled: true, bank_connection_id: 'conn-live' },
|
|
],
|
|
})
|
|
enqueue({ data: [{ id: 'conn-live', status: 'active' }] })
|
|
// The voucher's bank leg sits on the sibling ledger, not on 1931.
|
|
enqueue({ data: [{ debit_amount: 217.04, credit_amount: 0, account_number: '1940' }] })
|
|
enqueue({ data: [{ id: 'tx-1' }] })
|
|
|
|
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1931')
|
|
|
|
expect(result.success).toBe(true)
|
|
// The same locked UPDATE that stamps the link moves the row to the live
|
|
// sibling row, so neither account's reconciliation counts a cross-account
|
|
// link as an imbalance.
|
|
expect(updatePayloads()).toEqual([
|
|
expect.objectContaining({ journal_entry_id: 'je-1', cash_account_id: 'ca-live' }),
|
|
])
|
|
})
|
|
|
|
it('does not re-point when the voucher line is on the transaction\'s own ledger (#1643)', async () => {
|
|
const { supabase, enqueue, updatePayloads } = createQueueMockSupabase()
|
|
const iban = 'SE4550000000058398257466'
|
|
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null, cash_account_id: 'ca-orphan' })
|
|
|
|
enqueue({ data: tx })
|
|
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
|
enqueue({ data: { ledger_account: '1931' } })
|
|
enqueue({
|
|
data: [
|
|
{ id: 'ca-orphan', iban, ledger_account: '1931', currency: 'SEK', enabled: true, bank_connection_id: null },
|
|
{ id: 'ca-live', iban, ledger_account: '1940', currency: 'SEK', enabled: true, bank_connection_id: 'conn-live' },
|
|
],
|
|
})
|
|
enqueue({ data: [{ id: 'conn-live', status: 'active' }] })
|
|
// Bank leg on 1931 itself: an ordinary same-ledger link.
|
|
enqueue({ data: [{ debit_amount: 217.04, credit_amount: 0, account_number: '1931' }] })
|
|
enqueue({ data: [{ id: 'tx-1' }] })
|
|
|
|
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1931')
|
|
|
|
expect(result.success).toBe(true)
|
|
expect(updatePayloads()[0]).not.toHaveProperty('cash_account_id')
|
|
})
|
|
|
|
it('rejects a voucher whose only bank line is on another-currency pocket of the same IBAN (#1643)', async () => {
|
|
const { supabase, enqueue, updatePayloads } = createQueueMockSupabase()
|
|
const iban = 'SE4550000000058398257466'
|
|
const tx = makeTransaction({
|
|
id: 'tx-1',
|
|
journal_entry_id: null,
|
|
cash_account_id: 'ca-sek',
|
|
currency: 'SEK',
|
|
})
|
|
|
|
enqueue({ data: tx })
|
|
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
|
enqueue({ data: { ledger_account: '1931' } })
|
|
// Multi-currency account: the EUR pocket shares the IBAN but is another
|
|
// account, so it is not a sibling and the line check stays on 1931.
|
|
enqueue({
|
|
data: [
|
|
{ id: 'ca-sek', iban, ledger_account: '1931', currency: 'SEK', enabled: true, bank_connection_id: 'conn-live' },
|
|
{ id: 'ca-eur', iban, ledger_account: '1932', currency: 'EUR', enabled: true, bank_connection_id: 'conn-live' },
|
|
],
|
|
})
|
|
enqueue({ data: [{ id: 'conn-live', status: 'active' }] })
|
|
enqueue({ data: [] }) // no line on 1931 (the voucher only has a 1932 leg)
|
|
|
|
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1931')
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toBe('Verifikationen saknar rad på 1931')
|
|
expect(updatePayloads()).toEqual([])
|
|
})
|
|
|
|
it('refuses a voucher that sits only on a sibling that is NOT the live row (#1643 round 4)', async () => {
|
|
const { supabase, enqueue, updatePayloads } = createQueueMockSupabase()
|
|
const iban = 'SE4550000000058398257466'
|
|
// Reverse direction: the transaction synced onto the live 1940 row, the
|
|
// voucher was booked on the now-revoked 1931 before the reconnect. The
|
|
// voucher is what is wrong; the row must not be parked on the dead row,
|
|
// and a cross-account link (money on 1940, voucher on 1931) would show
|
|
// as an imbalance on both ledgers, so the link is refused outright.
|
|
const tx = makeTransaction({
|
|
id: 'tx-1',
|
|
journal_entry_id: null,
|
|
cash_account_id: 'ca-live',
|
|
currency: 'SEK',
|
|
})
|
|
|
|
enqueue({ data: tx })
|
|
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
|
enqueue({ data: { ledger_account: '1940' } })
|
|
enqueue({
|
|
data: [
|
|
{ id: 'ca-live', iban, ledger_account: '1940', currency: 'SEK', enabled: true, bank_connection_id: 'conn-live' },
|
|
{ id: 'ca-orphan', iban, ledger_account: '1931', currency: 'SEK', enabled: true, bank_connection_id: 'conn-old' },
|
|
],
|
|
})
|
|
enqueue({
|
|
data: [
|
|
{ id: 'conn-live', status: 'active' },
|
|
{ id: 'conn-old', status: 'revoked' },
|
|
],
|
|
})
|
|
enqueue({ data: [{ debit_amount: 0, credit_amount: 1000, account_number: '1931' }] })
|
|
|
|
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1940')
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toBe(
|
|
'Verifikationen är bokförd på 1931, som inte är transaktionens konto (1940). Rätta verifikationen eller flytta transaktionen först.',
|
|
)
|
|
expect(updatePayloads()).toEqual([])
|
|
})
|
|
|
|
it('refuses to link an EXPIRED own row to a voucher only on a demoted twin (renewable consent, #1643 rounds 3-4)', async () => {
|
|
const { supabase, enqueue, updatePayloads } = createQueueMockSupabase()
|
|
const iban = 'SE4550000000058398257466'
|
|
// Prod shape: 1930 on an expired connection (still the syncing account,
|
|
// re-auth renews it in place) beside a demoted manual twin 1931. Moving
|
|
// the row onto 1931 would strand it on the orphan once consent is renewed.
|
|
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null, cash_account_id: 'ca-expired', currency: 'SEK' })
|
|
|
|
enqueue({ data: tx })
|
|
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
|
enqueue({ data: { ledger_account: '1930' } })
|
|
enqueue({
|
|
data: [
|
|
{ id: 'ca-expired', iban, ledger_account: '1930', currency: 'SEK', enabled: true, bank_connection_id: 'conn-expired' },
|
|
{ id: 'ca-demoted', iban, ledger_account: '1931', currency: 'SEK', enabled: true, bank_connection_id: null },
|
|
],
|
|
})
|
|
enqueue({ data: [{ id: 'conn-expired', status: 'expired' }] })
|
|
enqueue({ data: [{ debit_amount: 0, credit_amount: 1000, account_number: '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).toContain('bokförd på 1931')
|
|
expect(updatePayloads()).toEqual([])
|
|
})
|
|
|
|
it('re-points to the LIVE sibling when the voucher touches a dead twin and the live twin, whatever the line order (#1643 round 4)', async () => {
|
|
const { supabase, enqueue, updatePayloads } = createQueueMockSupabase()
|
|
const iban = 'SE4550000000058398257466'
|
|
// An old cross-ledger "transfer" between two rows of one physical
|
|
// account: lines on the revoked 1931 and the live 1940, none on the
|
|
// demoted 1930 the transaction sits on. The query has no ORDER BY, so
|
|
// the dead line comes first here; the destination must still be 1940.
|
|
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null, cash_account_id: 'ca-1930', currency: 'SEK' })
|
|
|
|
enqueue({ data: tx })
|
|
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
|
enqueue({ data: { ledger_account: '1930' } })
|
|
enqueue({
|
|
data: [
|
|
{ id: 'ca-1930', iban, ledger_account: '1930', currency: 'SEK', enabled: true, bank_connection_id: null },
|
|
{ id: 'ca-1931', iban, ledger_account: '1931', currency: 'SEK', enabled: true, bank_connection_id: 'conn-old' },
|
|
{ id: 'ca-1940', iban, ledger_account: '1940', currency: 'SEK', enabled: true, bank_connection_id: 'conn-live' },
|
|
],
|
|
})
|
|
enqueue({
|
|
data: [
|
|
{ id: 'conn-old', status: 'revoked' },
|
|
{ id: 'conn-live', status: 'active' },
|
|
],
|
|
})
|
|
enqueue({
|
|
data: [
|
|
{ debit_amount: 0, credit_amount: 1000, account_number: '1931' },
|
|
{ debit_amount: 1000, credit_amount: 0, account_number: '1940' },
|
|
],
|
|
})
|
|
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)
|
|
expect(updatePayloads()).toEqual([
|
|
expect.objectContaining({ journal_entry_id: 'je-1', cash_account_id: 'ca-1940' }),
|
|
])
|
|
})
|
|
|
|
it('re-points to the sibling the voucher was booked on when BOTH rows are live (#1643 round 2)', async () => {
|
|
const { supabase, enqueue, updatePayloads } = createQueueMockSupabase()
|
|
const iban = 'SE4550000000058398257466'
|
|
// 1930 and 1931 both enabled on one active connection (the common prod
|
|
// shape): the voucher settled on 1931, the transaction sits on 1930. A
|
|
// cross-account link would show a difference on both ledgers.
|
|
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null, cash_account_id: 'ca-1930', currency: 'SEK' })
|
|
|
|
enqueue({ data: tx })
|
|
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
|
enqueue({ data: { ledger_account: '1930' } })
|
|
enqueue({
|
|
data: [
|
|
{ id: 'ca-1930', iban, ledger_account: '1930', currency: 'SEK', enabled: true, bank_connection_id: 'conn-live' },
|
|
{ id: 'ca-1931', iban, ledger_account: '1931', currency: 'SEK', enabled: true, bank_connection_id: 'conn-live' },
|
|
],
|
|
})
|
|
enqueue({ data: [{ id: 'conn-live', status: 'active' }] })
|
|
enqueue({ data: [{ debit_amount: 0, credit_amount: 1000, account_number: '1931' }] })
|
|
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)
|
|
expect(updatePayloads()).toEqual([
|
|
expect.objectContaining({ journal_entry_id: 'je-1', cash_account_id: 'ca-1931' }),
|
|
])
|
|
})
|
|
|
|
it('re-points to the sibling the voucher was booked on when NEITHER row is live (#1643 round 2)', async () => {
|
|
const { supabase, enqueue, updatePayloads } = createQueueMockSupabase()
|
|
const iban = 'SE4550000000058398257466'
|
|
// Full disconnect: both rows demoted to manual, IBAN kept. The voucher is
|
|
// the source of truth for where the money was booked.
|
|
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null, cash_account_id: 'ca-1931', currency: 'SEK' })
|
|
|
|
enqueue({ data: tx })
|
|
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
|
enqueue({ data: { ledger_account: '1931' } })
|
|
enqueue({
|
|
data: [
|
|
{ id: 'ca-1931', iban, ledger_account: '1931', currency: 'SEK', enabled: true, bank_connection_id: null },
|
|
{ id: 'ca-1940', iban, ledger_account: '1940', currency: 'SEK', enabled: true, bank_connection_id: null },
|
|
],
|
|
})
|
|
// No bank_connection ids: the status lookup is skipped.
|
|
enqueue({ data: [{ debit_amount: 217.04, credit_amount: 0, account_number: '1940' }] })
|
|
enqueue({ data: [{ id: 'tx-1' }] })
|
|
|
|
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1931')
|
|
|
|
expect(result.success).toBe(true)
|
|
expect(updatePayloads()).toEqual([
|
|
expect.objectContaining({ journal_entry_id: 'je-1', cash_account_id: 'ca-1940' }),
|
|
])
|
|
})
|
|
|
|
it('still rejects a voucher with no line on the account or any sibling ledger', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
const iban = 'SE4550000000058398257466'
|
|
const tx = makeTransaction({
|
|
id: 'tx-1',
|
|
journal_entry_id: null,
|
|
cash_account_id: 'ca-orphan',
|
|
})
|
|
|
|
enqueue({ data: tx })
|
|
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
|
|
enqueue({ data: { ledger_account: '1931' } })
|
|
enqueue({
|
|
data: [
|
|
{ id: 'ca-orphan', iban, ledger_account: '1931', currency: 'SEK', enabled: true, bank_connection_id: null },
|
|
{ id: 'ca-live', iban, ledger_account: '1940', currency: 'SEK', enabled: true, bank_connection_id: 'conn-live' },
|
|
],
|
|
})
|
|
enqueue({ data: [{ id: 'conn-live', status: 'active' }] })
|
|
enqueue({ data: [] }) // no line on 1931 OR 1940
|
|
|
|
const result = await manualLink(supabase as never, 'company-1', 'tx-1', 'je-1', 'user-1', '1931')
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toBe('Verifikationen saknar rad på 1931 eller 1940')
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// linkTransactionToVouchers: ONE bank row over SEVERAL verifikat (1:N, #1553)
|
|
// ============================================================
|
|
|
|
describe('linkTransactionToVouchers', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
eventBus.clear()
|
|
})
|
|
|
|
const E1 = 'je-utlagg-1'
|
|
const E2 = 'je-utlagg-2'
|
|
const postedEntries = [
|
|
{ id: E1, status: 'posted', voucher_series: 'A', voucher_number: 11 },
|
|
{ id: E2, status: 'posted', voucher_series: 'A', voucher_number: 12 },
|
|
]
|
|
/** Two utlägg verifikat, each crediting 1930: -500 and -300 in bank terms. */
|
|
const bankLines = [
|
|
{ journal_entry_id: E1, debit_amount: 0, credit_amount: 500, currency: null, amount_in_currency: null },
|
|
{ journal_entry_id: E2, debit_amount: 0, credit_amount: 300, currency: null, amount_in_currency: null },
|
|
]
|
|
const freeTx = (over: Record<string, unknown> = {}) => ({
|
|
...makeTransaction({ id: 'tx-1', amount: -800, journal_entry_id: null, reconciliation_method: null }),
|
|
transaction_voucher_links: [],
|
|
...over,
|
|
})
|
|
|
|
/**
|
|
* Queue up to and including the ledger read, in the order the function
|
|
* consumes it: tx, invoice_payments, supplier_invoice_payments,
|
|
* journal_entries, journal_entry_lines. (cash_account_id is null on the
|
|
* fixture, so the cash-account cross-check is skipped.)
|
|
*/
|
|
function enqueueReads(
|
|
enqueue: (r: { data?: unknown; error?: unknown }) => void,
|
|
opts: { tx?: Record<string, unknown>; entries?: unknown[]; lines?: unknown[] } = {},
|
|
) {
|
|
enqueue({ data: opts.tx ?? freeTx() })
|
|
enqueue({ data: [] }) // invoice_payments
|
|
enqueue({ data: [] }) // supplier_invoice_payments
|
|
enqueue({ data: opts.entries ?? postedEntries })
|
|
enqueue({ data: opts.lines ?? bankLines })
|
|
}
|
|
|
|
it('links one row to two verifikat: pointer stays NULL, one signed bank_line slice per verifikat, one matched event each', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
const reconciled = vi.fn()
|
|
eventBus.on('transaction.reconciled', reconciled)
|
|
enqueueReads(enqueue)
|
|
enqueue({ data: [{ id: 'tx-1' }] }) // lock UPDATE
|
|
enqueue({ data: null }) // junction insert
|
|
enqueue({ data: null }) // payment_match_log
|
|
|
|
const result = await linkTransactionToVouchers(
|
|
supabase as never,
|
|
'company-1',
|
|
'tx-1',
|
|
[
|
|
{ journal_entry_id: E1, amount: -500 },
|
|
{ journal_entry_id: E2, amount: -300 },
|
|
],
|
|
'user-1',
|
|
'1930',
|
|
)
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
allocations: [
|
|
{ journal_entry_id: E1, amount: -500 },
|
|
{ journal_entry_id: E2, amount: -300 },
|
|
],
|
|
})
|
|
// The lock: pointer explicitly NULL, method manual, business, locked on
|
|
// the pointer being NULL (a concurrent linker makes it match zero rows).
|
|
const [lockPayload] = findCalls('transactions', 'update')[0]
|
|
expect(lockPayload).toEqual({
|
|
journal_entry_id: null,
|
|
reconciliation_method: 'manual',
|
|
is_business: true,
|
|
potential_journal_entry_id: null,
|
|
potential_match_method: null,
|
|
potential_match_confidence: null,
|
|
})
|
|
expect(findCalls('transactions', 'is')[0]).toEqual(['journal_entry_id', null])
|
|
// One insert carrying both slices.
|
|
const inserts = findCalls('transaction_voucher_links', 'insert')
|
|
expect(inserts).toHaveLength(1)
|
|
expect(inserts[0][0]).toEqual([
|
|
{ user_id: 'user-1', company_id: 'company-1', transaction_id: 'tx-1', journal_entry_id: E1, allocated_amount: -500, role: 'bank_line' },
|
|
{ user_id: 'user-1', company_id: 'company-1', transaction_id: 'tx-1', journal_entry_id: E2, allocated_amount: -300, role: 'bank_line' },
|
|
])
|
|
// Behandlingshistorik row with every slice.
|
|
const [logRow] = findCalls('payment_match_log', 'insert')[0] as [Record<string, unknown>]
|
|
expect(logRow).toMatchObject({ user_id: 'user-1', transaction_id: 'tx-1', action: 'matched', match_method: 'manual' })
|
|
expect((logRow.new_state as { journal_entry_ids: string[] }).journal_entry_ids).toEqual([E1, E2])
|
|
expect(reconciled).toHaveBeenCalledTimes(2)
|
|
// Handlers receive the payload itself (lib/events/bus.ts).
|
|
expect(reconciled.mock.calls.map((c) => c[0].journalEntryId)).toEqual([E1, E2])
|
|
})
|
|
|
|
it('defaults an omitted slice to the verifikat\'s net line on the account', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueueReads(enqueue)
|
|
enqueue({ data: [{ id: 'tx-1' }] })
|
|
enqueue({ data: null })
|
|
enqueue({ data: null })
|
|
|
|
const result = await linkTransactionToVouchers(
|
|
supabase as never,
|
|
'company-1',
|
|
'tx-1',
|
|
[{ journal_entry_id: E1 }, { journal_entry_id: E2 }],
|
|
'user-1',
|
|
'1930',
|
|
)
|
|
|
|
expect(result.success).toBe(true)
|
|
expect(result.allocations).toEqual([
|
|
{ journal_entry_id: E1, amount: -500 },
|
|
{ journal_entry_id: E2, amount: -300 },
|
|
])
|
|
expect(findCalls('transaction_voucher_links', 'insert')).toHaveLength(1)
|
|
})
|
|
|
|
it('dry run validates and resolves the slices without writing', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueueReads(enqueue)
|
|
|
|
const result = await linkTransactionToVouchers(
|
|
supabase as never,
|
|
'company-1',
|
|
'tx-1',
|
|
[{ journal_entry_id: E1 }, { journal_entry_id: E2 }],
|
|
'user-1',
|
|
'1930',
|
|
{ dryRun: true },
|
|
)
|
|
|
|
expect(result.success).toBe(true)
|
|
expect(result.allocations).toHaveLength(2)
|
|
expect(findCalls('transactions', 'update')).toEqual([])
|
|
expect(findCalls('transaction_voucher_links', 'insert')).toEqual([])
|
|
})
|
|
|
|
it('refuses when the slices do not sum to the transaction amount, writing nothing', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueueReads(enqueue, { tx: freeTx({ amount: -900 }) })
|
|
|
|
const result = await linkTransactionToVouchers(
|
|
supabase as never,
|
|
'company-1',
|
|
'tx-1',
|
|
[{ journal_entry_id: E1 }, { journal_entry_id: E2 }],
|
|
'user-1',
|
|
'1930',
|
|
)
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toBe('Fördelningen (-800) stämmer inte med transaktionens belopp (-900).')
|
|
expect(findCalls('transactions', 'update')).toEqual([])
|
|
expect(findCalls('transaction_voucher_links', 'insert')).toEqual([])
|
|
})
|
|
|
|
it('refuses a slice with the wrong sign for the verifikat\'s bank line', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueueReads(enqueue)
|
|
|
|
const result = await linkTransactionToVouchers(
|
|
supabase as never,
|
|
'company-1',
|
|
'tx-1',
|
|
[
|
|
{ journal_entry_id: E1, amount: 500 },
|
|
{ journal_entry_id: E2, amount: -1300 },
|
|
],
|
|
'user-1',
|
|
'1930',
|
|
)
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toBe('Beloppet för verifikat A-11 har fel riktning: verifikatet bokför -500 på 1930.')
|
|
})
|
|
|
|
it('refuses a slice larger than the verifikat\'s bank line', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueueReads(enqueue, { tx: freeTx({ amount: -1000 }) })
|
|
|
|
const result = await linkTransactionToVouchers(
|
|
supabase as never,
|
|
'company-1',
|
|
'tx-1',
|
|
[
|
|
{ journal_entry_id: E1, amount: -700 },
|
|
{ journal_entry_id: E2, amount: -300 },
|
|
],
|
|
'user-1',
|
|
'1930',
|
|
)
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toBe('Beloppet för verifikat A-11 (-700) är större än verifikatets rad på 1930 (-500).')
|
|
})
|
|
|
|
it('refuses a zero slice', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueueReads(enqueue)
|
|
|
|
const result = await linkTransactionToVouchers(
|
|
supabase as never,
|
|
'company-1',
|
|
'tx-1',
|
|
[
|
|
{ journal_entry_id: E1, amount: 0 },
|
|
{ journal_entry_id: E2, amount: -800 },
|
|
],
|
|
'user-1',
|
|
'1930',
|
|
)
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toBe('Beloppet för verifikat A-11 får inte vara 0.')
|
|
})
|
|
|
|
it('refuses fewer than two verifikat, and the same verifikat twice, before reading anything', async () => {
|
|
const { supabase } = createQueuedMockSupabase()
|
|
|
|
const one = await linkTransactionToVouchers(supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }], 'user-1')
|
|
expect(one).toEqual({ success: false, error: 'En delning kräver minst två verifikat.' })
|
|
|
|
const dup = await linkTransactionToVouchers(
|
|
supabase as never,
|
|
'company-1',
|
|
'tx-1',
|
|
[{ journal_entry_id: E1 }, { journal_entry_id: E1 }],
|
|
'user-1',
|
|
)
|
|
expect(dup).toEqual({ success: false, error: 'Samma verifikat förekommer flera gånger i fördelningen.' })
|
|
expect(supabase.from).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('refuses a transaction that is already booked: junction row, live pointer, payment row, or ignored', async () => {
|
|
// Junction row (bulk-book or an earlier split).
|
|
let m = createQueuedMockSupabase()
|
|
m.enqueue({ data: freeTx({ transaction_voucher_links: [{ journal_entry_id: 'je-x', role: 'bank_line' }] }) })
|
|
let r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1')
|
|
expect(r.error).toBe('Transaktionen är redan kopplad till en verifikation.')
|
|
|
|
// Live pointer.
|
|
m = createQueuedMockSupabase()
|
|
m.enqueue({ data: freeTx({ journal_entry_id: 'je-live' }) })
|
|
m.enqueue({ data: { status: 'posted' } }) // hasLiveJournalEntryLink
|
|
r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1')
|
|
expect(r.error).toBe('Transaktionen är redan kopplad till en verifikation.')
|
|
|
|
// Payment row (match-invoice / match-batch).
|
|
m = createQueuedMockSupabase()
|
|
m.enqueue({ data: freeTx() })
|
|
m.enqueue({ data: [{ id: 'ip-1' }] }) // invoice_payments
|
|
m.enqueue({ data: [] })
|
|
r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1')
|
|
expect(r.error).toBe('Transaktionen är redan matchad mot en faktura.')
|
|
|
|
// Ignored.
|
|
m = createQueuedMockSupabase()
|
|
m.enqueue({ data: freeTx({ is_ignored: true }) })
|
|
r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1')
|
|
expect(r.error).toBe('Transaktionen är ignorerad. Återställ den innan du kopplar.')
|
|
})
|
|
|
|
it('re-links a row stranded on a reversed entry (#988), locking on the stale pointer', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ data: freeTx({ journal_entry_id: 'je-reversed' }) })
|
|
enqueue({ data: { status: 'reversed' } }) // hasLiveJournalEntryLink: stale
|
|
enqueue({ data: [] })
|
|
enqueue({ data: [] })
|
|
enqueue({ data: postedEntries })
|
|
enqueue({ data: bankLines })
|
|
enqueue({ data: [{ id: 'tx-1' }] })
|
|
enqueue({ data: null })
|
|
enqueue({ data: null })
|
|
|
|
const result = await linkTransactionToVouchers(
|
|
supabase as never,
|
|
'company-1',
|
|
'tx-1',
|
|
[{ journal_entry_id: E1 }, { journal_entry_id: E2 }],
|
|
'user-1',
|
|
'1930',
|
|
)
|
|
|
|
expect(result.success).toBe(true)
|
|
expect(findCalls('transactions', 'eq').some((args) => args[0] === 'journal_entry_id' && args[1] === 'je-reversed')).toBe(true)
|
|
})
|
|
|
|
it('refuses a verifikat that is not posted, one outside the company, and one without a line on the account', async () => {
|
|
let m = createQueuedMockSupabase()
|
|
enqueueReads(m.enqueue, { entries: [postedEntries[0], { ...postedEntries[1], status: 'draft' }] })
|
|
let r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1')
|
|
expect(r.error).toBe('Verifikat A-12 är inte bokförd ännu.')
|
|
|
|
// Only one of the two ids comes back inside the company.
|
|
m = createQueuedMockSupabase()
|
|
enqueueReads(m.enqueue, { entries: [postedEntries[0]] })
|
|
r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1')
|
|
expect(r.error).toBe('Verifikationen kunde inte hittas.')
|
|
|
|
// E2 books nothing on 1930 (its bank line is on 1940).
|
|
m = createQueuedMockSupabase()
|
|
enqueueReads(m.enqueue, { lines: [bankLines[0]] })
|
|
r = await linkTransactionToVouchers(m.supabase as never, 'company-1', 'tx-1', [{ journal_entry_id: E1 }, { journal_entry_id: E2 }], 'user-1', '1930')
|
|
expect(r.error).toBe('Verifikat A-12 saknar rad på 1930')
|
|
})
|
|
|
|
it('refuses when the transaction belongs to another cash account', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: freeTx({ cash_account_id: 'ca-1940' }) })
|
|
enqueue({ data: [] })
|
|
enqueue({ data: [] })
|
|
enqueue({ data: { ledger_account: '1940' } }) // cash_accounts cross-check
|
|
|
|
const result = await linkTransactionToVouchers(
|
|
supabase as never,
|
|
'company-1',
|
|
'tx-1',
|
|
[{ journal_entry_id: E1 }, { journal_entry_id: E2 }],
|
|
'user-1',
|
|
'1930',
|
|
)
|
|
|
|
expect(result).toEqual({ success: false, error: 'Transaktionen hör till 1940, inte 1930' })
|
|
})
|
|
|
|
it('reports a lost race (0 rows locked) as already linked and inserts nothing', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueueReads(enqueue)
|
|
enqueue({ data: [] }) // lock UPDATE matched nothing
|
|
|
|
const result = await linkTransactionToVouchers(
|
|
supabase as never,
|
|
'company-1',
|
|
'tx-1',
|
|
[{ journal_entry_id: E1 }, { journal_entry_id: E2 }],
|
|
'user-1',
|
|
'1930',
|
|
)
|
|
|
|
expect(result).toEqual({ success: false, error: 'Transaktionen är redan kopplad till en verifikation.' })
|
|
expect(findCalls('transaction_voucher_links', 'insert')).toEqual([])
|
|
})
|
|
|
|
it('rolls the lock back when the junction insert fails', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueueReads(enqueue)
|
|
enqueue({ data: [{ id: 'tx-1' }] }) // lock UPDATE
|
|
enqueue({ data: null, error: { message: 'duplicate key' } }) // junction insert
|
|
enqueue({ data: null }) // compensating delete
|
|
enqueue({ data: null }) // restore UPDATE
|
|
|
|
const result = await linkTransactionToVouchers(
|
|
supabase as never,
|
|
'company-1',
|
|
'tx-1',
|
|
[{ journal_entry_id: E1 }, { journal_entry_id: E2 }],
|
|
'user-1',
|
|
'1930',
|
|
)
|
|
|
|
expect(result).toEqual({ success: false, error: 'Kunde inte koppla transaktionen. Försök igen.' })
|
|
expect(findCalls('transaction_voucher_links', 'delete')).toHaveLength(1)
|
|
const updates = findCalls('transactions', 'update')
|
|
expect(updates).toHaveLength(2)
|
|
expect(updates[1][0]).toEqual({ journal_entry_id: null, reconciliation_method: null, is_business: null })
|
|
expect(findCalls('payment_match_log', 'insert')).toEqual([])
|
|
})
|
|
})
|
|
|
|
// ============================================================
|
|
// 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('unlinks a split row (no pointer, junction rows only) and reports every verifikat it was anchored to (#1553)', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
enqueue({ data: { id: 'tx-1', journal_entry_id: null, reconciliation_method: 'manual' } })
|
|
enqueue({ data: [{ journal_entry_id: 'je-a' }, { journal_entry_id: 'je-b' }] }) // junction rows, read BEFORE the delete
|
|
enqueue({ data: null }) // transactions update
|
|
enqueue({ data: null }) // junction delete
|
|
|
|
const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1', 'user-1')
|
|
|
|
expect(result).toEqual({ success: true, previousJournalEntryIds: ['je-a', 'je-b'] })
|
|
})
|
|
|
|
it('still refuses a row with neither pointer nor junction rows', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
enqueue({ data: { id: 'tx-1', journal_entry_id: null, reconciliation_method: 'manual' } })
|
|
enqueue({ data: [] })
|
|
|
|
const result = await unlinkReconciliation(supabase as never, 'company-1', 'tx-1', 'user-1')
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toBe('Transaction is not linked to any journal entry')
|
|
})
|
|
|
|
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('excludes ignored transactions from the bank total and difference, surfacing them separately', async () => {
|
|
// The 2026-08-18 duplicate-cleanup shape: a PSD2 reconnect re-imported
|
|
// history, the user booked one twin and IGNORED the other. The ignored
|
|
// duplicate never gets a ledger counterpart, so counting it in the bank
|
|
// total manufactured a permanent difference no amount of booking could
|
|
// clear (observed live: a 78 867 kr differens over a fully booked account).
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
// 1) transactions: booked original + its ignored duplicate + one real
|
|
// unbooked deposit.
|
|
enqueue({
|
|
data: [
|
|
{ amount: 8730, journal_entry_id: 'je-1', reconciliation_method: 'manual', is_ignored: false },
|
|
{ amount: 8730, journal_entry_id: null, reconciliation_method: null, is_ignored: true },
|
|
{ amount: 500, journal_entry_id: null, reconciliation_method: null, is_ignored: false },
|
|
],
|
|
})
|
|
// 2) GL: only the booked original is on 1930.
|
|
enqueue({ data: [{ id: 'je-1', status: 'posted', source_type: 'bank_import' }] })
|
|
enqueue({ data: [{ debit_amount: 8730, credit_amount: 0, journal_entry_id: 'je-1' }] })
|
|
// 3) RPC: no unlinked lines
|
|
enqueue({ data: [] })
|
|
|
|
const status = await getReconciliationStatus(supabase as never, 'company-1')
|
|
|
|
expect(status.bank_transaction_total).toBe(9230) // 8730 + 500, NOT the ignored twin
|
|
expect(status.ignored_transaction_count).toBe(1)
|
|
expect(status.ignored_transaction_total).toBe(8730)
|
|
expect(status.gl_1930_period_movement).toBe(8730)
|
|
expect(status.difference).toBe(500) // only the real unbooked deposit remains
|
|
expect(status.matched_count).toBe(1)
|
|
expect(status.unmatched_transaction_count).toBe(1)
|
|
expect(status.is_reconciled).toBe(false)
|
|
})
|
|
|
|
it('reports is_reconciled=true once everything non-ignored is booked, despite ignored rows', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
enqueue({
|
|
data: [
|
|
{ amount: 1000, journal_entry_id: 'je-1', reconciliation_method: 'auto_exact', is_ignored: false },
|
|
{ amount: 1000, journal_entry_id: null, reconciliation_method: null, is_ignored: true },
|
|
],
|
|
})
|
|
enqueue({ data: [{ id: 'je-1', status: 'posted', source_type: 'bank_import' }] })
|
|
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0, journal_entry_id: 'je-1' }] })
|
|
enqueue({ data: [] })
|
|
|
|
const status = await getReconciliationStatus(supabase as never, 'company-1')
|
|
|
|
expect(status.bank_transaction_total).toBe(1000)
|
|
expect(status.ignored_transaction_total).toBe(1000)
|
|
expect(status.difference).toBe(0)
|
|
expect(status.unmatched_transaction_count).toBe(0)
|
|
// Before the fix this was unreachable for any company with an ignored row:
|
|
// the ignored amount sat in the bank total forever.
|
|
expect(status.is_reconciled).toBe(true)
|
|
})
|
|
|
|
it('does not count an ignored row that somehow retains a journal_entry_id as matched', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
enqueue({
|
|
data: [
|
|
{ amount: 700, journal_entry_id: 'je-x', reconciliation_method: 'manual', is_ignored: true },
|
|
],
|
|
})
|
|
enqueue({ data: [] })
|
|
enqueue({ data: [] })
|
|
enqueue({ data: [] })
|
|
|
|
const status = await getReconciliationStatus(supabase as never, 'company-1')
|
|
|
|
// matched + unmatched partition the reconcilable (non-ignored) set.
|
|
expect(status.matched_count).toBe(0)
|
|
expect(status.unmatched_transaction_count).toBe(0)
|
|
expect(status.ignored_transaction_count).toBe(1)
|
|
expect(status.bank_transaction_total).toBe(0)
|
|
})
|
|
|
|
it('decomposes the difference into the two work lists (unexplained residual 0)', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
// Bank side: one matched 1000 deposit + one unbooked 500 deposit.
|
|
enqueue({
|
|
data: [
|
|
{ amount: 1000, journal_entry_id: 'je-matched', reconciliation_method: 'auto_exact' },
|
|
{ amount: 500, journal_entry_id: null, reconciliation_method: null },
|
|
],
|
|
})
|
|
// GL side: the matched 1000 debit + a 300 credit voucher with no bank row.
|
|
enqueue({
|
|
data: [
|
|
{ id: 'je-matched', status: 'posted', source_type: 'bank_transaction' },
|
|
{ id: 'je-lonely', status: 'posted', source_type: 'manual' },
|
|
],
|
|
})
|
|
enqueue({
|
|
data: [
|
|
{ debit_amount: 1000, credit_amount: 0, journal_entry_id: 'je-matched' },
|
|
{ debit_amount: 0, credit_amount: 300, journal_entry_id: 'je-lonely' },
|
|
],
|
|
})
|
|
// Candidate RPC: the lonely voucher is the one unmatched GL line.
|
|
enqueue({
|
|
data: [
|
|
{
|
|
line_id: 'l-lonely',
|
|
journal_entry_id: 'je-lonely',
|
|
debit_amount: 0,
|
|
credit_amount: 300,
|
|
entry_date: '2026-03-01',
|
|
voucher_number: 7,
|
|
voucher_series: 'A',
|
|
entry_description: 'Avgift',
|
|
source_type: 'manual',
|
|
linked_transaction_count: 0,
|
|
},
|
|
],
|
|
})
|
|
|
|
const status = await getReconciliationStatus(supabase as never, 'company-1')
|
|
|
|
expect(status.bank_transaction_total).toBe(1500)
|
|
expect(status.gl_1930_period_movement).toBe(700)
|
|
expect(status.difference).toBe(800)
|
|
// The two lists the user can actually open...
|
|
expect(status.unmatched_transaction_total).toBe(500)
|
|
expect(status.unmatched_gl_line_total).toBe(-300)
|
|
// ...account for every krona of it: 800 - 500 + (-300) = 0.
|
|
expect(status.unexplained_difference).toBe(0)
|
|
})
|
|
|
|
it('surfaces a residual when a matched pair disagrees in amount', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
// The bank moved 1000 but the voucher it is matched to books only 900:
|
|
// nothing sits in either work list, yet the sides do not agree. This is the
|
|
// finding `difference` alone can never distinguish from ordinary backlog.
|
|
enqueue({
|
|
data: [{ amount: 1000, journal_entry_id: 'je-short', reconciliation_method: 'manual' }],
|
|
})
|
|
enqueue({ data: [{ id: 'je-short', status: 'posted', source_type: 'bank_transaction' }] })
|
|
enqueue({ data: [{ debit_amount: 900, credit_amount: 0, journal_entry_id: 'je-short' }] })
|
|
enqueue({ data: [] })
|
|
|
|
const status = await getReconciliationStatus(supabase as never, 'company-1')
|
|
|
|
expect(status.difference).toBe(100)
|
|
expect(status.unmatched_transaction_total).toBe(0)
|
|
expect(status.unmatched_gl_line_total).toBe(0)
|
|
expect(status.unexplained_difference).toBe(100)
|
|
})
|
|
|
|
it('reports no residual on a foreign account whose candidate lines carry no FX amount', async () => {
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
// EUR account. The candidate RPC projects neither currency nor
|
|
// amount_in_currency, so its lines cannot be expressed in EUR: summing the
|
|
// raw SEK columns would claim a EUR figure that is off by the rate.
|
|
enqueue({ data: [{ amount: 100, journal_entry_id: null, reconciliation_method: null }] })
|
|
enqueue({ data: [{ id: 'je-eur', status: 'posted', source_type: 'manual' }] })
|
|
enqueue({
|
|
data: [
|
|
{
|
|
debit_amount: 1150,
|
|
credit_amount: 0,
|
|
currency: 'EUR',
|
|
amount_in_currency: 100,
|
|
journal_entry_id: 'je-eur',
|
|
},
|
|
],
|
|
})
|
|
enqueue({
|
|
data: [
|
|
{
|
|
line_id: 'l-eur',
|
|
journal_entry_id: 'je-eur',
|
|
debit_amount: 1150,
|
|
credit_amount: 0,
|
|
entry_date: '2026-03-01',
|
|
voucher_number: 8,
|
|
voucher_series: 'A',
|
|
entry_description: 'EU-faktura',
|
|
source_type: 'manual',
|
|
linked_transaction_count: 0,
|
|
},
|
|
],
|
|
})
|
|
|
|
const status = await getReconciliationStatus(
|
|
supabase as never,
|
|
'company-1',
|
|
undefined,
|
|
undefined,
|
|
'1932',
|
|
'EUR',
|
|
)
|
|
|
|
// Never 0: that would assert the vouchers net to nothing.
|
|
expect(status.unmatched_gl_line_total).toBeNull()
|
|
expect(status.unexplained_difference).toBeNull()
|
|
// The count still works; only the sum is withheld.
|
|
expect(status.unmatched_gl_line_count).toBe(1)
|
|
})
|
|
|
|
it('reconciles a corrected bank receipt and keeps gl_1930_balance equal to the balance sheet', async () => {
|
|
// A +25000 deposit was booked to the wrong counter-account, then corrected
|
|
// via the storno flow: the original flips to 'reversed', a storno (credit
|
|
// 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)
|
|
})
|
|
|
|
// ----------------------------------------------------------------
|
|
// A stornerad opening balance is not an IB
|
|
// ----------------------------------------------------------------
|
|
|
|
it('does not count a reversed opening balance (or its storno) as the IB', async () => {
|
|
// gnubok_feedback 2026-08-16: aktiekapital was double-booked as an IB on
|
|
// A1 (is_opening_balance), then correctly stornerad and re-booked. The
|
|
// balansräkning and huvudbok were right, yet the widget kept showing a
|
|
// difference of exactly the reversed IB: source_type='opening_balance'
|
|
// was summed with no status filter, so the cancelled 25 000 was subtracted
|
|
// from the period movement while its storno (source_type 'storno') stayed
|
|
// in. difference = (bank - gl) + reversed_ib. Ledger here: reversed IB
|
|
// +25 000, storno -25 000, live IB +100 000, one 5 000 receipt.
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
enqueue({
|
|
data: [
|
|
{ date: '2026-01-15', amount: 5000, journal_entry_id: 'je-recv', reconciliation_method: 'manual' },
|
|
],
|
|
})
|
|
enqueue({
|
|
data: [
|
|
{ id: 'je-ib-wrong', status: 'reversed', source_type: 'opening_balance', entry_date: '2026-01-01' },
|
|
{ id: 'je-ib-storno', status: 'posted', source_type: 'storno', entry_date: '2026-01-01' },
|
|
{ id: 'je-ib', status: 'posted', source_type: 'opening_balance', entry_date: '2026-01-01' },
|
|
{ id: 'je-recv', status: 'posted', source_type: 'bank_transaction', entry_date: '2026-01-15' },
|
|
],
|
|
})
|
|
enqueue({
|
|
data: [
|
|
{ debit_amount: 25000, credit_amount: 0, journal_entry_id: 'je-ib-wrong' },
|
|
{ debit_amount: 0, credit_amount: 25000, journal_entry_id: 'je-ib-storno' },
|
|
{ debit_amount: 100000, credit_amount: 0, journal_entry_id: 'je-ib' },
|
|
{ debit_amount: 5000, credit_amount: 0, journal_entry_id: 'je-recv' },
|
|
],
|
|
})
|
|
enqueue({ data: [] })
|
|
|
|
const status = await getReconciliationStatus(supabase as never, 'company-1', '2026-01-01')
|
|
|
|
// The reversed pair nets to zero inside the ledger balance, as on the BR.
|
|
expect(status.gl_1930_balance).toBe(105000)
|
|
// Only the live IB counts as the opening balance.
|
|
expect(status.gl_1930_opening_balance).toBe(100000)
|
|
expect(status.gl_1930_period_movement).toBe(5000)
|
|
expect(status.difference).toBe(0)
|
|
expect(status.is_reconciled).toBe(true)
|
|
})
|
|
|
|
it('does not floor the window at a reversed opening balance dated after the live one', async () => {
|
|
// A stray reversed IB dated mid-period must not raise effectiveFrom past
|
|
// the real IB and silently drop the period's early movements.
|
|
const { supabase, enqueue } = createQueueMockSupabase()
|
|
|
|
enqueue({
|
|
data: [
|
|
{ date: '2026-01-10', amount: 1000, journal_entry_id: 'je-jan', reconciliation_method: 'manual' },
|
|
{ date: '2026-02-10', amount: 2000, journal_entry_id: 'je-feb', reconciliation_method: 'manual' },
|
|
],
|
|
})
|
|
enqueue({
|
|
data: [
|
|
{ id: 'je-ib', status: 'posted', source_type: 'opening_balance', entry_date: '2026-01-01' },
|
|
{ id: 'je-jan', status: 'posted', source_type: 'bank_transaction', entry_date: '2026-01-10' },
|
|
{ id: 'je-ib-wrong', status: 'reversed', source_type: 'opening_balance', entry_date: '2026-02-01' },
|
|
{ id: 'je-ib-storno', status: 'posted', source_type: 'storno', entry_date: '2026-02-01' },
|
|
{ id: 'je-feb', status: 'posted', source_type: 'bank_transaction', entry_date: '2026-02-10' },
|
|
],
|
|
})
|
|
enqueue({
|
|
data: [
|
|
{ debit_amount: 9000, credit_amount: 0, journal_entry_id: 'je-ib' },
|
|
{ debit_amount: 1000, credit_amount: 0, journal_entry_id: 'je-jan' },
|
|
{ debit_amount: 400, credit_amount: 0, journal_entry_id: 'je-ib-wrong' },
|
|
{ debit_amount: 0, credit_amount: 400, journal_entry_id: 'je-ib-storno' },
|
|
{ debit_amount: 2000, credit_amount: 0, journal_entry_id: 'je-feb' },
|
|
],
|
|
})
|
|
enqueue({ data: [] })
|
|
|
|
const status = await getReconciliationStatus(supabase as never, 'company-1')
|
|
|
|
expect(status.gl_1930_opening_balance).toBe(9000)
|
|
expect(status.gl_1930_period_movement).toBe(3000)
|
|
expect(status.bank_transaction_total).toBe(3000)
|
|
expect(status.difference).toBe(0)
|
|
})
|
|
|
|
// ----------------------------------------------------------------
|
|
// 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(
|
|
'id, 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()
|
|
})
|
|
})
|