adf58a51c0
* 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>
356 lines
12 KiB
TypeScript
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)
|
|
})
|
|
})
|