Files
accounted/lib/bookkeeping/__tests__/engine.test.ts
T
Jakob Wennberg adf58a51c0 Prompt to activate missing BAS accounts at commit (#308)
* feat: prompt to activate missing BAS accounts at commit

Booking to an account not in the active chart previously threw a
generic 400 "Account(s) not found: 5010" and the user had to leave
the form to enable the account via /bookkeeping > BAS-katalog.

- New AccountsNotInChartError thrown from resolveAccountIds in the
  engine (and the parallel resolver in core/storno-service). The
  query also now filters on is_active=true, so deactivated accounts
  are treated the same as never-added ones.
- API routes that call the engine (journal-entries, reverse, correct,
  transactions/book + match-invoice + match-supplier-invoice +
  uncategorize, invoices/mark-paid, supplier-invoices + mark-paid +
  credit, salary/runs/correct, import/opening-balance/execute,
  pending-operations/commit) catch the typed error and return a
  structured 400: { error: { code: ACCOUNTS_NOT_IN_CHART,
  account_numbers, message } }.
- /api/bookkeeping/accounts/activate now also reactivates rows that
  already exist but are is_active=false, not only INSERTs. Returns
  { activated, reactivated, skipped, unknown }.
- New GET /api/bookkeeping/accounts/bas-lookup?numbers=... resolves
  BAS names client-side so the dialog can show "5010 · Lokalhyra"
  without bundling the full 1,276-account catalog.
- ActivateAccountsDialog lists the missing accounts (BAS names + any
  unknown non-BAS numbers) and confirms with a single action.
- useSubmitWithAccountActivation wraps an async submit: on
  ACCOUNTS_NOT_IN_CHART it opens the dialog, activates on confirm,
  then retries the original submit so the user never re-enters data.
- AccountCombobox accepts any 4-digit numeric value, not just items
  from the active chart — the activation dialog handles the rest.
- JournalEntryForm wired to the hook + dialog. Other submit surfaces
  now surface a clear Swedish message ("Följande konton behöver
  aktiveras: …") via getErrorMessage; wiring the dialog into those
  is an additive follow-up.

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

* docs: sync CLAUDE.md with current codebase state

Catch-up on growth since the last CLAUDE.md revision:
- Integrations list now includes AWS Bedrock, Upstash Redis,
  Google Drive, Recharts, PDF.js, @react-pdf/renderer, xlsx,
  fuse.js, ics.
- Extension table reflects cloud-backup enabled; adds
  inbox-smart-match and example-logger; reorders to match current
  extensions.config.json.
- Updated counts: 36 event types (was 30+), 35 MCP tools (was 26),
  ~60 tables (was ~47), 118 migrations (was 93), 19 report
  endpoints (was 16), 20 report generators (was 17).
- lib/ directory table now covers salary, providers,
  company-lookup, processing-history, support.ts; removes the
  deleted settings/ subdir.
- App routes table adds /salary/*, /help, /settings/salary,
  /settings/backup.
- API endpoints table adds /api/salary/*, /api/support/contact,
  /api/account/delete, /api/audit-trail/*, /api/log,
  /api/currency/rate, top-level extension routes.
- Tables section adds Salary, Third-party providers, Inbox &
  Migration groups; removes salary_payments (replaced by
  salary_runs + salary_line_items).
- Skills list updated to enumerate the Swedish domain skills by
  name instead of the old single /swedish-bookkeeping.

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

* fix: address PR review feedback on account activation

Seven fixes based on Greptile + Swedish compliance review on #308.

- ActivateAccountsDialog: disable the confirm button when any
  entered number isn't a valid BAS account. Previously activation
  would succeed for the knowns and the retry would immediately
  fail again on the unknowns, giving a confusing double-toast UX.
- pending-operations/commit: revert commitSendInvoice and
  commitMarkInvoiceSent to swallow AccountsNotInChartError
  silently. The prior PR upgrade made these blocking, which
  regressed invoice delivery for users whose AR accounts are
  inactive — and since the activation dialog isn't wired into
  those flows yet, there's no one-click recovery. The silent
  catches now append an InvoiceJournalEntrySkipped event to
  processing_history so the missing verifikation is actionable
  in audit trails rather than silently understating the
  momsdeklaration (revenue / utgående moms unposted).
- engine.reverseEntry: resolve account IDs with includeInactive=true
  so storno of an already-committed entry goes through even when
  the user has since deactivated one of its accounts. Blocking
  the reversal would leave the original entry uncorrected in
  violation of BFL 5 kap 5§ (rättelse must be documented). The
  default (includeInactive=false) still applies to createDraftEntry
  so new bookings to inactive accounts continue to trigger the
  activation dialog.
- supplier-invoices POST + credit: roll back the just-inserted
  supplier_invoices row (items cascade-delete) on any JE failure,
  not only AccountsNotInChartError. An orphan supplier_invoices
  row without a registration / credit JE leaves leverantörsskuld
  (2440) and ingående moms (2641) unposted — a silent
  understatement / overstatement in the momsdeklaration (ML
  2023:200 / BFL 5 kap). The catch now returns a clear Swedish
  error message for non-activation failures (typically period
  lock or DB error) instead of silently logging.

Test mocks for chart_of_accounts updated for the new query chain
(eq.in.eq instead of eq.eq.in after the is_active conditional).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 09:58:54 +02:00

356 lines
12 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { validateBalance, getSwedishLocalDate, createDraftEntry, reverseEntry } from '../engine'
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([]) },
}))
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('Failed to create journal entry lines')
// 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)
})
})