diff --git a/app/api/bookkeeping/journal-entries/[id]/commit/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/commit/__tests__/route.test.ts index 97dd8b2d..194a4419 100644 --- a/app/api/bookkeeping/journal-entries/[id]/commit/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/[id]/commit/__tests__/route.test.ts @@ -29,6 +29,15 @@ vi.mock('@/lib/bookkeeping/engine', () => ({ commitEntry: (...args: unknown[]) => mockCommitEntry(...args), })) +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), + }), +})) + import { POST } from '../route' describe('POST /api/bookkeeping/journal-entries/[id]/commit', () => { @@ -78,7 +87,7 @@ describe('POST /api/bookkeeping/journal-entries/[id]/commit', () => { 'company-1', 'user-1', 'entry-1', - 'manual' + 'user_accept' ) }) diff --git a/app/api/bookkeeping/journal-entries/[id]/commit/route.ts b/app/api/bookkeeping/journal-entries/[id]/commit/route.ts index 1d478c16..c063cc02 100644 --- a/app/api/bookkeeping/journal-entries/[id]/commit/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/commit/route.ts @@ -5,9 +5,12 @@ import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' import { ensureInitialized } from '@/lib/init' import { requireCompanyId } from '@/lib/company/context' import { requireWritePermission } from '@/lib/auth/require-write' +import { createLogger } from '@/lib/logger' ensureInitialized() +const log = createLogger('api.bookkeeping.commit') + export async function POST( request: Request, { params }: { params: Promise<{ id: string }> } @@ -26,11 +29,18 @@ export async function POST( const companyId = await requireCompanyId(supabase, user.id) try { - const posted = await commitEntry(supabase, companyId, user.id, id, 'manual') + const posted = await commitEntry(supabase, companyId, user.id, id, 'user_accept') return NextResponse.json({ data: posted }) } catch (err) { const typed = bookkeepingErrorResponse(err) if (typed) return typed + // Untyped error path: engine logging didn't fire, so log here. + log.error('commit endpoint failed (untyped)', err as Error, { + companyId, + userId: user.id, + entityType: 'journal_entry', + entityId: id, + }) return NextResponse.json( { error: err instanceof Error ? err.message : 'Failed to commit entry' }, { status: 400 } diff --git a/lib/bookkeeping/__tests__/voucher-atomicity.test.ts b/lib/bookkeeping/__tests__/voucher-atomicity.test.ts index 561904f6..1960cf24 100644 --- a/lib/bookkeeping/__tests__/voucher-atomicity.test.ts +++ b/lib/bookkeeping/__tests__/voucher-atomicity.test.ts @@ -6,6 +6,15 @@ vi.mock('@/lib/events', () => ({ eventBus: { emit: vi.fn().mockResolvedValue([]) }, })) +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), + }), +})) + import { commitEntry, getNextVoucherNumber, createJournalEntry } from '../engine' import { BookkeepingDatabaseError } from '../errors' diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index 4151df85..048a17cf 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { eventBus } from '@/lib/events' +import { createLogger } from '@/lib/logger' import { AccountsNotInChartError, BookkeepingDatabaseError, @@ -17,6 +18,8 @@ import type { JournalEntryLine, } from '@/types' +const log = createLogger('bookkeeping.engine') + /** * Validate that a set of journal entry lines is balanced (debits = credits) */ @@ -228,6 +231,17 @@ export async function createDraftEntry( .single() if (entryError || !entry) { + log.error('insert journal_entries draft failed', entryError ?? new Error('no row returned'), { + operation: 'create_draft_entry', + companyId, + userId, + entityType: 'journal_entry', + fiscalPeriodId: input.fiscal_period_id, + sourceType: input.source_type, + pgCode: (entryError as { code?: string } | null)?.code, + pgDetails: (entryError as { details?: string } | null)?.details, + pgHint: (entryError as { hint?: string } | null)?.hint, + }) throw new BookkeepingDatabaseError('create_draft_entry', entryError?.message) } @@ -239,7 +253,30 @@ export async function createDraftEntry( .insert(lineInserts) if (linesError) { - await supabase.from('journal_entries').update({ status: 'cancelled' }).eq('id', entry.id) + log.error('insert journal_entry_lines failed', linesError, { + operation: 'create_entry_lines', + companyId, + userId, + entityType: 'journal_entry', + entityId: entry.id, + lineCount: lineInserts.length, + pgCode: (linesError as { code?: string }).code, + pgDetails: (linesError as { details?: string }).details, + pgHint: (linesError as { hint?: string }).hint, + }) + const { error: cancelError } = await supabase + .from('journal_entries') + .update({ status: 'cancelled' }) + .eq('id', entry.id) + if (cancelError) { + log.error('orphan draft cleanup failed (phantom draft remains)', cancelError, { + operation: 'create_entry_lines.cleanup', + companyId, + entityType: 'journal_entry', + entityId: entry.id, + pgCode: (cancelError as { code?: string }).code, + }) + } throw new BookkeepingDatabaseError('create_entry_lines', linesError.message) } @@ -285,6 +322,17 @@ export async function commitEntry( }) if (commitError) { + log.error('commit_journal_entry RPC failed', commitError, { + operation: 'commit_entry', + companyId, + userId, + entityType: 'journal_entry', + entityId: entryId, + commitMethod: commitMethod ?? null, + pgCode: (commitError as { code?: string }).code, + pgDetails: (commitError as { details?: string }).details, + pgHint: (commitError as { hint?: string }).hint, + }) throw new BookkeepingDatabaseError('commit_entry', commitError.message) } @@ -331,13 +379,28 @@ export async function createJournalEntry( // before failing downstream, immutability trigger blocks draft→cancelled // on a posted row anyway — the filter just avoids firing the trigger. try { - await supabase + const { error: cancelError } = await supabase .from('journal_entries') .update({ status: 'cancelled' }) .eq('id', draft.id) .eq('status', 'draft') - } catch { - // Swallow rollback failure — surface the original commit error + if (cancelError) { + log.error('orphan draft cleanup failed (phantom draft remains)', cancelError, { + operation: 'create_journal_entry.cleanup', + companyId, + entityType: 'journal_entry', + entityId: draft.id, + pgCode: (cancelError as { code?: string }).code, + }) + } + } catch (cleanupErr) { + // Surface the original commit error, but don't lose the cleanup signal. + log.error('orphan draft cleanup threw (phantom draft remains)', cleanupErr as Error, { + operation: 'create_journal_entry.cleanup', + companyId, + entityType: 'journal_entry', + entityId: draft.id, + }) } throw commitError }