From 44231d0000212900949e20ccef1815532510f8a7 Mon Sep 17 00:00:00 2001 From: Emil Date: Mon, 23 Feb 2026 12:59:47 +0100 Subject: [PATCH] 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 --- app/(dashboard)/transactions/page.tsx | 89 +++++---- .../[id]/book/__tests__/route.test.ts | 189 ++++++++++++++++++ app/api/transactions/[id]/book/route.ts | 115 +++++++++++ .../transactions/CategoryExpandedDialog.tsx | 45 ++++- .../transactions/SwipeCategorizationView.tsx | 9 +- .../transactions/TransactionBookingDialog.tsx | 94 +++++++++ .../transactions/TransactionHistoryList.tsx | 46 +++-- .../transactions/TransactionInboxCard.tsx | 10 +- 8 files changed, 533 insertions(+), 64 deletions(-) create mode 100644 app/api/transactions/[id]/book/__tests__/route.test.ts create mode 100644 app/api/transactions/[id]/book/route.ts create mode 100644 components/transactions/TransactionBookingDialog.tsx diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 231a3302..c60c0052 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -17,7 +17,7 @@ import TransactionInboxCard from '@/components/transactions/TransactionInboxCard import TransactionHistoryList from '@/components/transactions/TransactionHistoryList' import InboxZeroState from '@/components/transactions/InboxZeroState' import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog' -import CategoryExpandedDialog from '@/components/transactions/CategoryExpandedDialog' +import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog' import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types' import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment } from '@/types' import type { SuggestedCategory } from '@/lib/transactions/category-suggestions' @@ -44,10 +44,9 @@ export default function TransactionsPage() { const [selectedTransaction, setSelectedTransaction] = useState(null) const [isConfirmingMatch, setIsConfirmingMatch] = useState(false) - // Category expanded dialog - const [categoryDialogOpen, setCategoryDialogOpen] = useState(false) - const [categoryDialogTransaction, setCategoryDialogTransaction] = useState(null) - const [categoryDialogProcessing, setCategoryDialogProcessing] = useState(false) + // Booking dialog (journal entry form) + const [bookingDialogOpen, setBookingDialogOpen] = useState(false) + const [bookingDialogTransaction, setBookingDialogTransaction] = useState(null) // Set of transaction IDs that are animating out (just categorized) const [exitingIds, setExitingIds] = useState>(new Set()) @@ -164,7 +163,16 @@ export default function TransactionsPage() { // Mark as exiting for animation, then update state setExitingIds((prev) => new Set(prev).add(id)) + if (result.journal_entry_created) { + toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' }) + } else if (result.journal_entry_error) { + toast({ title: 'Delvis bokförd', description: `Verifikation kunde inte skapas: ${result.journal_entry_error}`, variant: 'destructive' }) + } else { + toast({ title: 'Delvis bokförd', description: 'Transaktion uppdaterad men verifikation kunde inte skapas' }) + } + // Update transaction in state after a brief delay for animation + setExitingIds((prev) => new Set(prev).add(id)) setTimeout(() => { setTransactions((prev) => prev.map((t) => @@ -178,17 +186,9 @@ export default function TransactionsPage() { next.delete(id) return next }) + setProcessingId(null) }, 350) - if (result.journal_entry_created) { - toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' }) - } else if (result.journal_entry_error) { - toast({ title: 'Delvis bokförd', description: `Verifikation kunde inte skapas: ${result.journal_entry_error}`, variant: 'destructive' }) - } else { - toast({ title: 'Delvis bokförd', description: 'Transaktion uppdaterad men verifikation kunde inte skapas' }) - } - - setProcessingId(null) return true } catch { toast({ title: 'Fel', description: 'Något gick fel vid bokföring', variant: 'destructive' }) @@ -218,6 +218,12 @@ export default function TransactionsPage() { return } + toast({ + title: 'Faktura matchad', + description: `Faktura ${selectedTransaction.potential_invoice.invoice_number} markerad som betald`, + }) + setMatchDialogOpen(false) + // Mark as exiting for animation setExitingIds((prev) => new Set(prev).add(selectedTransaction.id)) setTimeout(() => { @@ -241,18 +247,13 @@ export default function TransactionsPage() { next.delete(selectedTransaction.id) return next }) + setSelectedTransaction(null) + setIsConfirmingMatch(false) }, 350) - - toast({ - title: 'Faktura matchad', - description: `Faktura ${selectedTransaction.potential_invoice.invoice_number} markerad som betald`, - }) - setMatchDialogOpen(false) - setSelectedTransaction(null) } catch { toast({ title: 'Fel', description: 'Något gick fel vid matchning', variant: 'destructive' }) + setIsConfirmingMatch(false) } - setIsConfirmingMatch(false) } async function handleMatchInvoice(transactionId: string, invoiceId: string): Promise { @@ -329,15 +330,25 @@ export default function TransactionsPage() { setIsCreating(false) } - async function handleCategoryDialogSelect(category: TransactionCategory) { - if (!categoryDialogTransaction) return - setCategoryDialogProcessing(true) - const success = await handleCategorize(categoryDialogTransaction.id, true, category) - setCategoryDialogProcessing(false) - if (success) { - setCategoryDialogOpen(false) - setCategoryDialogTransaction(null) - } + function handleTransactionBooked(transactionId: string, journalEntryId: string) { + setExitingIds((prev) => new Set(prev).add(transactionId)) + setTimeout(() => { + setTransactions((prev) => + prev.map((t) => + t.id === transactionId + ? { ...t, is_business: true, journal_entry_id: journalEntryId } + : t + ) + ) + setExitingIds((prev) => { + const next = new Set(prev) + next.delete(transactionId) + return next + }) + }, 350) + setBookingDialogOpen(false) + setBookingDialogTransaction(null) + toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' }) } // Batch mode handlers @@ -401,8 +412,8 @@ export default function TransactionsPage() { } function openCategoryDialog(transaction: TransactionWithInvoice) { - setCategoryDialogTransaction(transaction) - setCategoryDialogOpen(true) + setBookingDialogTransaction(transaction) + setBookingDialogOpen(true) } // Swipe view @@ -481,6 +492,7 @@ export default function TransactionsPage() { )} @@ -518,12 +530,11 @@ export default function TransactionsPage() { onConfirm={handleConfirmInvoiceMatch} /> - diff --git a/app/api/transactions/[id]/book/__tests__/route.test.ts b/app/api/transactions/[id]/book/__tests__/route.test.ts new file mode 100644 index 00000000..c68ac8cc --- /dev/null +++ b/app/api/transactions/[id]/book/__tests__/route.test.ts @@ -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') + }) +}) diff --git a/app/api/transactions/[id]/book/route.ts b/app/api/transactions/[id]/book/route.ts new file mode 100644 index 00000000..9b0b33a2 --- /dev/null +++ b/app/api/transactions/[id]/book/route.ts @@ -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, + }) +} diff --git a/components/transactions/CategoryExpandedDialog.tsx b/components/transactions/CategoryExpandedDialog.tsx index 089ef01d..6890c74f 100644 --- a/components/transactions/CategoryExpandedDialog.tsx +++ b/components/transactions/CategoryExpandedDialog.tsx @@ -1,18 +1,20 @@ 'use client' +import { useState, useEffect } from 'react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { formatCurrency, formatDate } from '@/lib/utils' import { ArrowUpRight, ArrowDownRight } from 'lucide-react' -import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types' +import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types' import type { TransactionWithInvoice } from './transaction-types' -import type { TransactionCategory } from '@/types' +import type { TransactionCategory, VatTreatment } from '@/types' interface CategoryExpandedDialogProps { open: boolean onOpenChange: (open: boolean) => void transaction: TransactionWithInvoice | null - onSelectCategory: (category: TransactionCategory) => void + onSelectCategory: (category: TransactionCategory, vatTreatment?: VatTreatment) => void isProcessing: boolean } @@ -23,10 +25,23 @@ export default function CategoryExpandedDialog({ onSelectCategory, isProcessing, }: CategoryExpandedDialogProps) { + const [vatTreatment, setVatTreatment] = useState('standard_25') + + useEffect(() => { + if (open) { + setVatTreatment('standard_25') + } + }, [open, transaction?.id]) + if (!transaction) return null const isIncome = transaction.amount > 0 + const handleSelectCategory = (category: TransactionCategory) => { + const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment + onSelectCategory(category, resolvedVat) + } + return ( @@ -62,6 +77,26 @@ export default function CategoryExpandedDialog({

+ {/* VAT treatment selector */} +
+

Momsbehandling

+ +
+ {/* Category grid */}
@@ -73,7 +108,7 @@ export default function CategoryExpandedDialog({ variant="outline" size="sm" className="justify-start text-xs" - onClick={() => onSelectCategory(cat.value)} + onClick={() => handleSelectCategory(cat.value)} disabled={isProcessing} > {cat.label} @@ -90,7 +125,7 @@ export default function CategoryExpandedDialog({ variant="outline" size="sm" className="justify-start text-xs" - onClick={() => onSelectCategory(cat.value)} + onClick={() => handleSelectCategory(cat.value)} disabled={isProcessing} > {cat.label} diff --git a/components/transactions/SwipeCategorizationView.tsx b/components/transactions/SwipeCategorizationView.tsx index 8d9f634a..494521f7 100644 --- a/components/transactions/SwipeCategorizationView.tsx +++ b/components/transactions/SwipeCategorizationView.tsx @@ -48,6 +48,13 @@ export default function SwipeCategorizationView({ const [vatTreatment, setVatTreatment] = useState('standard_25') const [accounts, setAccounts] = useState([]) + // Clear VAT treatment when switching to a liability/equity account (class 2) + useEffect(() => { + if (accountOverride.startsWith('2') && vatTreatment !== 'none') { + setVatTreatment('none') + } + }, [accountOverride]) // eslint-disable-line react-hooks/exhaustive-deps + // Fetch accounts on mount useEffect(() => { async function fetchAccounts() { @@ -375,7 +382,7 @@ export default function SwipeCategorizationView({
-
-

0 ? 'text-success' : '' - }`} - > - {transaction.amount > 0 ? '+' : ''} - {formatCurrency(transaction.amount, transaction.currency)} -

- {transaction.currency !== 'SEK' && transaction.amount_sek && ( -

- {formatCurrency(transaction.amount_sek)} -

+
+ {transaction.is_business === null && !transaction.journal_entry_id && ( + )} +
+

0 ? 'text-success' : '' + }`} + > + {transaction.amount > 0 ? '+' : ''} + {formatCurrency(transaction.amount, transaction.currency)} +

+ {transaction.currency !== 'SEK' && transaction.amount_sek && ( +

+ {formatCurrency(transaction.amount_sek)} +

+ )} +
diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx index ad013efa..beb17335 100644 --- a/components/transactions/TransactionInboxCard.tsx +++ b/components/transactions/TransactionInboxCard.tsx @@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Checkbox } from '@/components/ui/checkbox' import { formatCurrency, formatDate } from '@/lib/utils' -import { ArrowUpRight, ArrowDownRight, FileText, MoreHorizontal, Loader2 } from 'lucide-react' +import { ArrowUpRight, ArrowDownRight, FileText, Loader2 } from 'lucide-react' import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types' import type { SuggestedCategory } from '@/lib/transactions/category-suggestions' @@ -178,15 +178,15 @@ export default function TransactionInboxCard({ Privat - {/* More options */} + {/* Open category dialog */} )}