Files
accounted/lib/bookkeeping/__tests__/engine.test.ts
T
Jakob WennbergandClaude Fable 5 e978136210 fix(supplier-invoices): payment-match integrity — no more paid-without-voucher half-states (#711)
* fix(transactions): abort supplier-invoice match when payment voucher fails

The match route caught a payment-JE creation failure and proceeded anyway:
invoice marked paid with payment_journal_entry_id NULL, a payments row with
no voucher, and the bank line linked but unbooked. That half-state is
unrecoverable from the UI — mark-paid rejects 'paid' invoices and the match
route rejects already-linked transactions (the "user can re-book" comment
was wrong). The v1 route was already strict; this aligns the cookie route.

A failed voucher now fails the whole match before any state mutation, with
bookkeeping errors mapped to their structured codes and a new
MATCH_SI_JE_FAILED fallback.

Incident: Arcim 2026-06-11 — invoice 20250928 marked paid with no payment
voucher because account 3740 was missing from the chart.

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

* fix(transactions): bank-sync supplier-invoice match is a suggestion, not a hard link

A high-confidence (>=0.85, unambiguous) supplier-invoice hit at sync time
set transactions.supplier_invoice_id directly — without booking a payment
or touching the invoice. The half-link then BLOCKED the match route
(MATCH_SI_TX_ALREADY_LINKED), stranding the bank line with no path to a
payment voucher and the invoice stuck on 'registered'.

Sync now always writes potential_supplier_invoice_id; the hard link is
reserved for completed matches where the payment voucher is booked.
High-confidence hits still drain the matching pool and skip the mapping
engine.

Incident: Arcim 2026-06-11 — RosholmDell 18299 (29 890 kr) auto-linked at
sync, unmatchable afterwards.

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

* feat(bookkeeping): seed standard BAS accounts on demand in the engine

A minimal company chart routinely lacks accounts that legitimate engine
flows reach — 3740 (öres- och kronutjämning) the first time a Bankgiro
payment lands a sub-krona off the invoice, 6580 on a first legal invoice.
createDraftEntry threw AccountsNotInChartError and turned a standard
account into a dead end.

The engine now backfills missing accounts from BAS_REFERENCE (full
metadata incl. SRU code) before failing. Conservative by design: unknown
numbers still throw, and deactivated accounts are never resurrected —
deactivation is a deliberate user choice. Concurrent seeding (23505) counts
as success.

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

* fix(supplier-invoices): require explicit expense account, drop the 5010 seed

Every new line item (and every AI-prefilled line) was silently seeded with
account 5010 Lokalhyra. AI extraction deliberately never suggests accounts,
so any invoice saved without touching the field was misbooked as premises
rent — legally wrong verifikat that need rättelse to fix.

Lines now start with an empty account: the supplier's
default_expense_account fills empty rows when set, and submit blocks with a
clear toast until every row has an account.

Incident: Arcim 2026-06-11 — a legal-services invoice (should be 6580) and
a SaaS subscription (should be 5420) both posted to 5010.

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

* refactor(bookkeeping): clarify voucher description suffix to (ankomstnr N)

"(ankomst 2)" read as "arrived twice" / a duplicate marker; it is the
company-internal sequential arrival counter for supplier invoices.
"(ankomstnr 2)" says what the number is. Existing posted vouchers keep
their old description (immutable).

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

* fix(transactions): cancel orphaned payment voucher when match loses the CAS race

When the payment JE posts but the invoice CAS update matches 0 rows (a
concurrent request settled it first), both match routes returned
MATCH_SI_NOT_OPEN and left the voucher orphaned in the ledger. mark-paid
has always compensated for exactly this case; the compensation is now a
shared helper (cancelOrphanedPaymentEntry: cancel + voucher-gap
explanation per BFNAR 2013:2) used by all three routes.

Flagged by the compliance swarm and the Swedish compliance review on
PR #711 — the one finding both converged on.

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

* fix(bookkeeping): next_voucher_number user_id fallback for service-role contexts

Mirrors 20260421170500 (commit_journal_entry got this fix; its twin did
not). Under a service-role client auth.uid() is NULL and the
voucher_sequences upsert fails its user_id NOT NULL check before
ON CONFLICT can arbitrate — even when the sequence row exists. Every
non-interactive caller of the storno/correction path
(getNextVoucherNumber → correctEntry) was broken.

Fallback: companies.created_by (same source seed_chart_of_accounts uses).
Interactive flows still record auth.uid(); DO UPDATE never touches
user_id on existing rows. Also restores SET search_path = public, lost
when 20260330 recreated the function after the 20260304 hardening.

pg-real: new test exercises the RPC on the superuser connection
(auth.uid() IS NULL) and asserts sequential numbers + owner attribution.

Found live: the Arcim repair script booked payment vouchers fine
(commit_journal_entry) but failed on corrections (next_voucher_number).

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

* fix(bookkeeping): harden cancelOrphanedPaymentEntry — never throw, breadcrumb before mutating

Two hardenings from the PR #711 review round:
- Whole body wrapped in try/catch: the caller is returning the correct
  CAS-conflict response, so an unexpected client rejection must not
  replace it with a 500 (best-effort is now a hard guarantee).
- The gap-recovery data (series, number, period, explanation) is logged
  BEFORE the cancel: the cancel and gap insert are separate statements,
  and a crash between them would otherwise leave a cancelled voucher
  with no BFNAR 2013:2 gap explanation and no way to reconstruct it.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:44:15 +02:00

486 lines
16 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { validateBalance, getSwedishLocalDate, createDraftEntry, reverseEntry } from '../engine'
import { BookkeepingDatabaseError, AccountsNotInChartError } from '../errors'
import type { CreateJournalEntryLineInput, JournalEntryStatus } from '@/types'
// Mock Supabase client for createDraftEntry/reverseEntry tests
function createMockChain(overrides: Record<string, unknown> = {}) {
const chain: Record<string, unknown> = {
select: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: overrides.singleData ?? null, error: overrides.singleError ?? null }),
eq: vi.fn().mockReturnThis(),
insert: vi.fn().mockReturnThis(),
update: vi.fn().mockReturnThis(),
delete: vi.fn().mockReturnThis(),
in: vi.fn().mockReturnThis(),
lte: vi.fn().mockReturnThis(),
gte: vi.fn().mockReturnThis(),
order: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
}
return chain
}
// Mock event bus
vi.mock('@/lib/events', () => ({
eventBus: { emit: vi.fn().mockResolvedValue([]) },
}))
// Mock the on-demand BAS backfill — default: nothing seedable. Individual
// tests override per scenario.
const mockBackfill = vi.fn().mockResolvedValue([])
vi.mock('@/lib/bookkeeping/account-backfill', () => ({
backfillStandardBASAccounts: (...args: unknown[]) => mockBackfill(...args),
}))
describe('validateBalance', () => {
it('balanced entry (debit == credit) → valid: true', () => {
const lines: CreateJournalEntryLineInput[] = [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
]
const result = validateBalance(lines)
expect(result.valid).toBe(true)
expect(result.totalDebit).toBe(1000)
expect(result.totalCredit).toBe(1000)
})
it('unbalanced entry → valid: false', () => {
const lines: CreateJournalEntryLineInput[] = [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
]
const result = validateBalance(lines)
expect(result.valid).toBe(false)
expect(result.totalDebit).toBe(1000)
expect(result.totalCredit).toBe(500)
})
it('zero amounts → valid: false (roundedDebit must be > 0)', () => {
const lines: CreateJournalEntryLineInput[] = [
{ account_number: '1930', debit_amount: 0, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 0 },
]
const result = validateBalance(lines)
expect(result.valid).toBe(false)
expect(result.totalDebit).toBe(0)
expect(result.totalCredit).toBe(0)
})
it('floating point edge case (33.33 + 33.33 + 33.34) → valid: true', () => {
const lines: CreateJournalEntryLineInput[] = [
{ account_number: '1930', debit_amount: 33.33, credit_amount: 0 },
{ account_number: '1930', debit_amount: 33.33, credit_amount: 0 },
{ account_number: '1930', debit_amount: 33.34, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 100 },
]
const result = validateBalance(lines)
expect(result.valid).toBe(true)
expect(result.totalDebit).toBe(100)
expect(result.totalCredit).toBe(100)
})
it('single line (only debit, no credit) → valid: false', () => {
const lines: CreateJournalEntryLineInput[] = [
{ account_number: '1930', debit_amount: 500, credit_amount: 0 },
]
const result = validateBalance(lines)
expect(result.valid).toBe(false)
})
})
describe('getSwedishLocalDate', () => {
it('returns a date string in YYYY-MM-DD format', () => {
const date = getSwedishLocalDate()
expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/)
})
it('returns a valid date', () => {
const date = getSwedishLocalDate()
const parsed = new Date(date)
expect(parsed.toString()).not.toBe('Invalid Date')
})
})
describe('createDraftEntry — cancelled status on line-insert failure', () => {
it('sets status to cancelled (not delete) when line insert fails', async () => {
const updateMock = vi.fn().mockReturnValue({ eq: vi.fn().mockResolvedValue({ error: null }) })
const supabase = {
from: vi.fn().mockImplementation((table: string) => {
if (table === 'fiscal_periods') {
return {
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: { name: 'FY 2024', period_start: '2024-01-01', period_end: '2024-12-31' },
error: null,
}),
}),
}),
}),
}
}
if (table === 'journal_entries') {
return {
insert: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: { id: 'entry-1', user_id: 'user-1', status: 'draft' as JournalEntryStatus },
error: null,
}),
}),
}),
update: updateMock,
delete: vi.fn().mockReturnValue({ eq: vi.fn().mockResolvedValue({ error: null }) }),
}
}
if (table === 'journal_entry_lines') {
return {
insert: vi.fn().mockResolvedValue({ error: { message: 'Line insert failed' } }),
}
}
if (table === 'chart_of_accounts') {
return {
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
in: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({
data: [{ account_number: '1930', id: 'acc-1' }, { account_number: '3001', id: 'acc-2' }],
error: null,
}),
}),
}),
}),
}
}
return createMockChain()
}),
}
await expect(
createDraftEntry(supabase as never, 'company-1', 'user-1', {
fiscal_period_id: 'period-1',
entry_date: '2024-01-01',
description: 'Test',
source_type: 'manual',
lines: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
],
})
).rejects.toThrow(BookkeepingDatabaseError)
// Should call update with cancelled status, NOT delete
expect(updateMock).toHaveBeenCalledWith({ status: 'cancelled' })
})
})
describe('createDraftEntry — date/period cross-validation', () => {
function buildSupabase(periodData: { name: string; period_start: string; period_end: string } | null) {
return {
from: vi.fn().mockImplementation((table: string) => {
if (table === 'fiscal_periods') {
return {
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: periodData,
error: periodData ? null : { message: 'Not found' },
}),
}),
}),
}),
}
}
if (table === 'chart_of_accounts') {
return {
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
in: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({
data: [{ account_number: '1930', id: 'acc-1' }, { account_number: '3001', id: 'acc-2' }],
error: null,
}),
}),
}),
}),
}
}
if (table === 'journal_entries') {
return {
insert: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: { id: 'entry-1', status: 'draft' },
error: null,
}),
}),
}),
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: { id: 'entry-1', status: 'draft', lines: [] },
error: null,
}),
}),
}),
}
}
if (table === 'journal_entry_lines') {
return {
insert: vi.fn().mockResolvedValue({ error: null }),
}
}
return createMockChain()
}),
}
}
const validLines = [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
]
it('rejects entry date before period start', async () => {
const supabase = buildSupabase({
name: 'FY 2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
})
await expect(
createDraftEntry(supabase as never, 'company-1', 'user-1', {
fiscal_period_id: 'period-1',
entry_date: '2024-12-15',
description: 'Test',
source_type: 'manual',
lines: validLines,
})
).rejects.toThrow('Entry date 2024-12-15 is outside fiscal period "FY 2025"')
})
it('rejects entry date after period end', async () => {
const supabase = buildSupabase({
name: 'FY 2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
})
await expect(
createDraftEntry(supabase as never, 'company-1', 'user-1', {
fiscal_period_id: 'period-1',
entry_date: '2026-01-15',
description: 'Test',
source_type: 'manual',
lines: validLines,
})
).rejects.toThrow('Entry date 2026-01-15 is outside fiscal period "FY 2025"')
})
it('accepts entry date within period', async () => {
const supabase = buildSupabase({
name: 'FY 2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
})
const result = await createDraftEntry(supabase as never, 'company-1', 'user-1', {
fiscal_period_id: 'period-1',
entry_date: '2025-06-15',
description: 'Test',
source_type: 'manual',
lines: validLines,
})
expect(result).toBeDefined()
expect(result.id).toBe('entry-1')
})
it('accepts entry date on period start boundary', async () => {
const supabase = buildSupabase({
name: 'FY 2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
})
const result = await createDraftEntry(supabase as never, 'company-1', 'user-1', {
fiscal_period_id: 'period-1',
entry_date: '2025-01-01',
description: 'Test',
source_type: 'manual',
lines: validLines,
})
expect(result).toBeDefined()
})
it('accepts entry date on period end boundary', async () => {
const supabase = buildSupabase({
name: 'FY 2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
})
const result = await createDraftEntry(supabase as never, 'company-1', 'user-1', {
fiscal_period_id: 'period-1',
entry_date: '2025-12-31',
description: 'Test',
source_type: 'manual',
lines: validLines,
})
expect(result).toBeDefined()
})
it('throws when fiscal period not found', async () => {
const supabase = buildSupabase(null)
await expect(
createDraftEntry(supabase as never, 'company-1', 'user-1', {
fiscal_period_id: 'nonexistent',
entry_date: '2025-06-15',
description: 'Test',
source_type: 'manual',
lines: validLines,
})
).rejects.toThrow('Fiscal period not found')
})
})
describe('JournalEntryStatus type includes cancelled', () => {
it('cancelled is a valid JournalEntryStatus value', () => {
const status: JournalEntryStatus = 'cancelled'
expect(['draft', 'posted', 'reversed', 'cancelled']).toContain(status)
})
})
describe('createDraftEntry — on-demand BAS account backfill', () => {
// Engine seeds standard BAS accounts missing from the chart instead of
// failing (June 2026 incident: 3740 öresavrundning missing → payment
// voucher dead end). Non-seedable numbers still throw.
beforeEach(() => {
mockBackfill.mockClear()
})
function buildSupabase(opts: { chartByCall: { account_number: string; id: string }[][] }) {
let chartCall = 0
return {
from: vi.fn().mockImplementation((table: string) => {
if (table === 'fiscal_periods') {
return {
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: { name: 'FY 2026', period_start: '2026-01-01', period_end: '2026-12-31' },
error: null,
}),
}),
}),
}),
}
}
if (table === 'chart_of_accounts') {
const result = opts.chartByCall[Math.min(chartCall++, opts.chartByCall.length - 1)]
return {
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
in: vi.fn().mockReturnValue({
eq: vi.fn().mockResolvedValue({ data: result, error: null }),
}),
}),
}),
}
}
if (table === 'journal_entries') {
return {
insert: vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: { id: 'entry-1', status: 'draft' },
error: null,
}),
}),
}),
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
single: vi.fn().mockResolvedValue({
data: { id: 'entry-1', status: 'draft', lines: [] },
error: null,
}),
}),
}),
}
}
if (table === 'journal_entry_lines') {
return { insert: vi.fn().mockResolvedValue({ error: null }) }
}
return createMockChain()
}),
}
}
const LINES: CreateJournalEntryLineInput[] = [
{ account_number: '2440', debit_amount: 11231.25, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 11231 },
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
]
it('seeds a missing standard BAS account and proceeds', async () => {
mockBackfill.mockResolvedValue(['3740'])
const supabase = buildSupabase({
chartByCall: [
// First resolution: 3740 missing
[{ account_number: '2440', id: 'acc-1' }, { account_number: '1930', id: 'acc-2' }],
// Re-resolution after backfill: all present
[
{ account_number: '2440', id: 'acc-1' },
{ account_number: '1930', id: 'acc-2' },
{ account_number: '3740', id: 'acc-3' },
],
],
})
const entry = await createDraftEntry(supabase as never, 'company-1', 'user-1', {
fiscal_period_id: 'period-1',
entry_date: '2026-06-08',
description: 'Utbetalning leverantörsfaktura',
source_type: 'supplier_invoice_paid',
lines: LINES,
})
expect(entry.id).toBe('entry-1')
expect(mockBackfill).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', ['3740'])
})
it('still throws AccountsNotInChartError when the account is not seedable', async () => {
mockBackfill.mockResolvedValue([])
const supabase = buildSupabase({
chartByCall: [
[{ account_number: '2440', id: 'acc-1' }, { account_number: '1930', id: 'acc-2' }],
],
})
await expect(
createDraftEntry(supabase as never, 'company-1', 'user-1', {
fiscal_period_id: 'period-1',
entry_date: '2026-06-08',
description: 'Utbetalning leverantörsfaktura',
source_type: 'supplier_invoice_paid',
lines: LINES,
})
).rejects.toThrow(AccountsNotInChartError)
expect(mockBackfill).toHaveBeenCalledTimes(1)
})
})