Files
accounted/lib/reconciliation/__tests__/auto-reconcile-linked-voucher.test.ts
T
Jakob WennbergandClaude Opus 4.8 c74b19df1b Accounted rebrand + swarm-skill cleanup + bank-reconciliation fixes (#643)
* feat(reconciliation): close the bank-feed loop on voucher links and re-tag mis-typed opening balances

Two related fixes to bank reconciliation correctness:

1. Auto-reconcile on voucher link. Linking an invoice or supplier invoice to
   an existing voucher previously advanced only the invoice — the bank
   transaction that paid it kept sitting in the Transactions inbox with a null
   journal_entry_id. linkInvoiceToVoucher / linkSupplierInvoiceToVoucher now
   call autoReconcileTransactionForLinkedVoucher (lib/reconciliation), which
   links the bank transaction to the same verifikat when exactly one unbooked
   line matches it. Best-effort and post-commit: a failure here never fails the
   link. The result surfaces reconciledTransactionId; the inbox row leaves the
   list and the UI shows link_success_tx_reconciled.

2. Re-tag mis-typed opening balances. getReconciliationStatus and the GL-line
   matching RPCs identify a cash account's ingående balans solely by
   journal_entries.source_type='opening_balance'. Companies migrated from other
   systems often booked the bank IB as an ordinary voucher (source_type
   'import' or 'manual'), so it was never excluded and surfaced as a phantom
   reconciliation difference equal to the opening balance. Adds:
   - migration mark_entry_as_opening_balance: a GUC-gated carve-out in the
     immutability trigger plus a SECURITY DEFINER RPC that validates the entry
     (balance-sheet lines only, dated on a fiscal-period boundary), flips the
     source_type, and writes an audit row — no blanket data sweep.
   - POST /api/reconciliation/bank/mark-opening-balance + MarkOpeningBalanceSchema.
   - BankReconciliationView action to trigger it from the IB diff.

The gnubok_create_voucher executor now accepts a typed is_opening_balance flag
and derives source_type='opening_balance' only after validating class 1/2 lines
on the period start, so new IBs land correctly typed.

Covered by lib/reconciliation auto-reconcile tests, voucher-executors tests,
and a mark-entry-as-opening-balance pg-real test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: rebrand gnubok → Accounted and prune swarm agent skills

Product rebrand and skills housekeeping. No runtime behaviour change.

Rebrand: replace user-visible "gnubok" with "Accounted" across docs, READMEs,
in-code comments, doc-site content, MCP skill/resource prose, and the
gnubok-mcp package description. The MCP resource URI scheme is moved gnubok://
→ Accounted:// consistently across resource registrations, the event-type
comment, and the resource/skill tests. Deliberately preserved as stable
identifiers (NOT rebranded): the gnubok-company-id cookie, gnubok_sk_ / gnubok_inv_
token prefixes, the gnubok-mcp npm bridge name, and the AGI <gem:Programnamn>
value (kept 'gnubok' per its source comment — it is the software identifier sent
to Skatteverket and must not churn across visual rebrands).

Skills: remove the 27 swarm-* agent SKILL.md atoms (no longer used; already
absent from the agent_atom_registry in prod), refresh the remaining skill docs,
add the .claude/rules/ path-scoped rule set, and regenerate the
seed_agent_atom_bodies migration + .skill-body-manifest.json via
`npm run skills:generate` so the DB-backed skill bodies match the trimmed set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:52:01 +02:00

198 lines
7.8 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from 'vitest'
import { autoReconcileTransactionForLinkedVoucher } from '@/lib/reconciliation/bank-reconciliation'
import { eventBus } from '@/lib/events/bus'
import { makeTransaction } from '@/tests/helpers'
// Queue-based Supabase mock: every awaited chain consumes the next enqueued
// result regardless of the builder methods called, so we enqueue results in the
// exact order the function under test awaits them. Mirrors the manualLink suite
// in bank-reconciliation.test.ts.
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 }
}
const POSTED_VOUCHER = { id: 'je-1', entry_date: '2026-05-10', status: 'posted' }
// cash_accounts.id is a UUID — scopeTransactionsToAccount asserts that shape.
const CA_1930 = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
const CA_1932 = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
const SEK_PRIMARY = { id: CA_1930, ledger_account: '1930', currency: 'SEK', is_primary: true }
/**
* Enqueue the manualLink sub-call results (tx fetch → entry fetch → 1930 line
* → update). cash_account_id is null on the fetched tx so manualLink skips the
* cross-account check (one fewer query).
*/
function enqueueManualLinkSuccess(
enqueue: ReturnType<typeof createQueueMockSupabase>['enqueue'],
txAmount: number,
) {
enqueue({ data: makeTransaction({ id: 'tx-1', journal_entry_id: null, cash_account_id: null, amount: txAmount, currency: 'SEK' }) })
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
enqueue({ data: [{ debit_amount: Math.max(txAmount, 0), credit_amount: Math.max(-txAmount, 0), account_number: '1930' }] })
enqueue({ data: null, error: null }) // update
}
describe('autoReconcileTransactionForLinkedVoucher', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
it('links the single matching unbooked transaction for a customer payment (income)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: [] }) // 1. no tx already reconciled to the voucher
enqueue({ data: POSTED_VOUCHER }) // 2. voucher
enqueue({ data: [ // 3. lines: one 1930 debit + the 1510 credit
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1000 },
] })
enqueue({ data: [SEK_PRIMARY] }) // 4. cash accounts
enqueue({ data: [{ id: 'tx-1', amount: 1000 }] }) // 5. exactly one candidate
enqueueManualLinkSuccess(enqueue, 1000)
const result = await autoReconcileTransactionForLinkedVoucher(
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
)
expect(result).toEqual({ linkedTransactionId: 'tx-1' })
})
it('links the single matching unbooked transaction for a supplier payment (expense)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: [] })
enqueue({ data: POSTED_VOUCHER })
enqueue({ data: [ // 2440 debit + 1930 credit (money out)
{ account_number: '2440', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
] })
enqueue({ data: [SEK_PRIMARY] })
enqueue({ data: [{ id: 'tx-1', amount: -1000 }] }) // expense tx
enqueueManualLinkSuccess(enqueue, -1000)
const result = await autoReconcileTransactionForLinkedVoucher(
supabase as never, 'company-1', 'user-1', 'je-1', { supplierInvoiceId: 'si-1' },
)
expect(result).toEqual({ linkedTransactionId: 'tx-1' })
})
it('does nothing when a transaction is already reconciled to the voucher', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: [{ id: 'tx-existing' }] }) // voucher already has a bank line
const result = await autoReconcileTransactionForLinkedVoucher(
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
)
expect(result).toBeNull()
})
it('does nothing when the voucher has no cash-account line (AR/AP reclass)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: [] })
enqueue({ data: POSTED_VOUCHER })
enqueue({ data: [ // no 19xx line at all
{ account_number: '1510', debit_amount: 1000, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
] })
enqueue({ data: [SEK_PRIMARY] })
const result = await autoReconcileTransactionForLinkedVoucher(
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
)
expect(result).toBeNull()
})
it('does nothing when the voucher touches two cash accounts (a transfer)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: [] })
enqueue({ data: POSTED_VOUCHER })
enqueue({ data: [
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
{ account_number: '1932', debit_amount: 1000, credit_amount: 0 },
] })
enqueue({ data: [SEK_PRIMARY, { id: CA_1932, ledger_account: '1932', currency: 'EUR', is_primary: false }] })
const result = await autoReconcileTransactionForLinkedVoucher(
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
)
expect(result).toBeNull()
})
it('does nothing when two unbooked transactions match the amount (ambiguous)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: [] })
enqueue({ data: POSTED_VOUCHER })
enqueue({ data: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1000 },
] })
enqueue({ data: [SEK_PRIMARY] })
enqueue({ data: [{ id: 'tx-1', amount: 1000 }, { id: 'tx-2', amount: 1000 }] }) // two hits
const result = await autoReconcileTransactionForLinkedVoucher(
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
)
expect(result).toBeNull()
})
it('does nothing when no candidate matches the bank movement amount', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: [] })
enqueue({ data: POSTED_VOUCHER })
enqueue({ data: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1000 },
] })
enqueue({ data: [SEK_PRIMARY] })
enqueue({ data: [{ id: 'tx-9', amount: 5000 }] }) // wrong amount
const result = await autoReconcileTransactionForLinkedVoucher(
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
)
expect(result).toBeNull()
})
it('does nothing when the voucher is not posted', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: [] })
enqueue({ data: { id: 'je-1', entry_date: '2026-05-10', status: 'draft' } })
const result = await autoReconcileTransactionForLinkedVoucher(
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
)
expect(result).toBeNull()
})
})