feat: add transaction booking dialog with manual journal entry
Replace CategoryExpandedDialog with TransactionBookingDialog that embeds the JournalEntryForm for full manual booking. Add /api/transactions/[id]/book endpoint. Add VAT treatment selector to CategoryExpandedDialog. Add booking buttons to TransactionHistoryList and improve TransactionInboxCard actions. Fix animation timing in transactions page state updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
makeTransaction,
|
||||
makeJournalEntry,
|
||||
} from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockCreateJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/transactions/[id]/book', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
const validBody = {
|
||||
fiscal_period_id: 'period-1',
|
||||
entry_date: '2025-01-15',
|
||||
description: 'Test booking',
|
||||
lines: [
|
||||
{ account_number: '6200', debit_amount: 500, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 500 },
|
||||
],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/book', {
|
||||
method: 'POST',
|
||||
body: validBody,
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 400 when missing required fields', async () => {
|
||||
const request = createMockRequest('/api/transactions/tx-1/book', {
|
||||
method: 'POST',
|
||||
body: { fiscal_period_id: 'period-1' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toContain('Missing required fields')
|
||||
})
|
||||
|
||||
it('returns 404 when transaction not found', async () => {
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-999/book', {
|
||||
method: 'POST',
|
||||
body: validBody,
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-999' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Transaction not found')
|
||||
})
|
||||
|
||||
it('returns 409 when transaction already has a journal entry', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
journal_entry_id: 'je-existing',
|
||||
})
|
||||
enqueue({ data: tx, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/book', {
|
||||
method: 'POST',
|
||||
body: validBody,
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error).toBe('Transaction already has a journal entry')
|
||||
})
|
||||
|
||||
it('returns 400 when journal entry creation fails (engine error)', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
|
||||
enqueue({ data: tx, error: null })
|
||||
|
||||
mockCreateJournalEntry.mockRejectedValue(new Error('Entry is not balanced'))
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/book', {
|
||||
method: 'POST',
|
||||
body: validBody,
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Entry is not balanced')
|
||||
})
|
||||
|
||||
it('creates journal entry and links to transaction (happy path)', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -500,
|
||||
journal_entry_id: null,
|
||||
})
|
||||
const je = makeJournalEntry({ id: 'je-new' })
|
||||
|
||||
// Fetch transaction
|
||||
enqueue({ data: tx, error: null })
|
||||
|
||||
mockCreateJournalEntry.mockResolvedValue(je)
|
||||
|
||||
// Update transaction
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/book', {
|
||||
method: 'POST',
|
||||
body: validBody,
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_id: string
|
||||
data: { id: string }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_id).toBe('je-new')
|
||||
expect(body.data.id).toBe('je-new')
|
||||
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith('user-1', {
|
||||
fiscal_period_id: 'period-1',
|
||||
entry_date: '2025-01-15',
|
||||
description: 'Test booking',
|
||||
source_type: 'bank_transaction',
|
||||
source_id: 'tx-1',
|
||||
lines: validBody.lines,
|
||||
})
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'transaction.categorized' })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 500 when transaction update fails', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
|
||||
const je = makeJournalEntry({ id: 'je-new' })
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
mockCreateJournalEntry.mockResolvedValue(je)
|
||||
// Update fails
|
||||
enqueue({ data: null, error: { message: 'Update failed' } })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/book', {
|
||||
method: 'POST',
|
||||
body: validBody,
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toBe('Failed to update transaction')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import type { CreateJournalEntryLineInput, Transaction } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
interface BookRequest {
|
||||
fiscal_period_id: string
|
||||
entry_date: string
|
||||
description: string
|
||||
lines: CreateJournalEntryLineInput[]
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: BookRequest = await request.json()
|
||||
const { fiscal_period_id, entry_date, description, lines } = body
|
||||
|
||||
if (!fiscal_period_id || !entry_date || !description || !lines?.length) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: fiscal_period_id, entry_date, description, lines' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch transaction (validates ownership)
|
||||
const { data: transaction, error: fetchError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError || !transaction) {
|
||||
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Reject if already booked
|
||||
if (transaction.journal_entry_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Transaction already has a journal entry' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create journal entry via the engine
|
||||
let journalEntry
|
||||
try {
|
||||
journalEntry = await createJournalEntry(user.id, {
|
||||
fiscal_period_id,
|
||||
entry_date,
|
||||
description,
|
||||
source_type: 'bank_transaction',
|
||||
source_id: id,
|
||||
lines,
|
||||
})
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to create journal entry' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Link transaction to the journal entry
|
||||
const { error: updateError } = await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
journal_entry_id: journalEntry.id,
|
||||
is_business: true,
|
||||
category: 'uncategorized',
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update transaction' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Emit event (non-blocking)
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'transaction.categorized',
|
||||
payload: {
|
||||
transaction: transaction as Transaction,
|
||||
account: lines[0]?.account_number || '',
|
||||
taxCode: '',
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: journalEntry,
|
||||
journal_entry_id: journalEntry.id,
|
||||
success: true,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user