From 263c2299030f3b237da11f57b7656fb95b6d923f Mon Sep 17 00:00:00 2001 From: Emil Date: Mon, 23 Feb 2026 13:00:03 +0100 Subject: [PATCH] feat: add journal entry correction flow with storno rollback Add rollback logic to storno-service correctEntry to restore ledger consistency if step 2 fails after reversal. Add correction API route at /api/bookkeeping/journal-entries/[id]/correct. Add CorrectionEntryDialog component and correction button to JournalEntryList. Co-Authored-By: Claude Opus 4.6 --- .../[id]/correct/__tests__/route.test.ts | 145 ++++++++++ .../journal-entries/[id]/correct/route.ts | 41 +++ .../bookkeeping/CorrectionEntryDialog.tsx | 266 ++++++++++++++++++ components/bookkeeping/JournalEntryList.tsx | 24 ++ lib/core/bookkeeping/storno-service.ts | 158 ++++++----- 5 files changed, 567 insertions(+), 67 deletions(-) create mode 100644 app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts create mode 100644 app/api/bookkeeping/journal-entries/[id]/correct/route.ts create mode 100644 components/bookkeeping/CorrectionEntryDialog.tsx diff --git a/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts new file mode 100644 index 00000000..d6fa64a0 --- /dev/null +++ b/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + makeJournalEntry, +} from '@/tests/helpers' + +const mockCreateClient = vi.fn() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => mockCreateClient(), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +const mockCorrectEntry = vi.fn() +vi.mock('@/lib/core/bookkeeping/storno-service', () => ({ + correctEntry: (...args: unknown[]) => mockCorrectEntry(...args), +})) + +import { POST } from '../route' + +describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + mockCreateClient.mockResolvedValue({ + auth: { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) }, + }) + }) + + it('returns 401 when not authenticated', async () => { + mockCreateClient.mockResolvedValue({ + auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) }, + }) + + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/correct', { + method: 'POST', + body: { lines: [] }, + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 400 when lines are missing', async () => { + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/correct', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Lines are required') + }) + + it('returns 400 when lines array is empty', async () => { + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/correct', { + method: 'POST', + body: { lines: [] }, + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Lines are required') + }) + + it('returns reversal and corrected entries on success', async () => { + const reversal = makeJournalEntry({ + id: 'reversal-1', + reverses_id: 'entry-1', + source_type: 'storno', + }) + const corrected = makeJournalEntry({ + id: 'corrected-1', + correction_of_id: 'entry-1', + source_type: 'correction', + }) + mockCorrectEntry.mockResolvedValue({ reversal, corrected }) + + const lines = [ + { account_number: '1930', debit_amount: 1000, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 1000 }, + ] + + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/correct', { + method: 'POST', + body: { lines }, + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status, body } = await parseJsonResponse<{ data: { reversal: unknown; corrected: unknown } }>(response) + + expect(status).toBe(200) + expect(body.data.reversal).toEqual(reversal) + expect(body.data.corrected).toEqual(corrected) + expect(mockCorrectEntry).toHaveBeenCalledWith('user-1', 'entry-1', lines) + }) + + it('returns 400 when correctEntry throws for unbalanced lines', async () => { + mockCorrectEntry.mockRejectedValue( + new Error('Corrected entry is not balanced: debits (1000) != credits (500)') + ) + + const lines = [ + { account_number: '1930', debit_amount: 1000, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 500 }, + ] + + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/correct', { + method: 'POST', + body: { lines }, + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toContain('not balanced') + }) + + it('returns 400 when entry is not found or not posted', async () => { + mockCorrectEntry.mockRejectedValue(new Error('Can only correct posted entries')) + + const lines = [ + { account_number: '1930', debit_amount: 1000, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 1000 }, + ] + + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/correct', { + method: 'POST', + body: { lines }, + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Can only correct posted entries') + }) +}) diff --git a/app/api/bookkeeping/journal-entries/[id]/correct/route.ts b/app/api/bookkeeping/journal-entries/[id]/correct/route.ts new file mode 100644 index 00000000..4f0e22a4 --- /dev/null +++ b/app/api/bookkeeping/journal-entries/[id]/correct/route.ts @@ -0,0 +1,41 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { correctEntry } from '@/lib/core/bookkeeping/storno-service' +import { ensureInitialized } from '@/lib/init' +import type { CreateJournalEntryLineInput } from '@/types' + +ensureInitialized() + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + let body: { lines: CreateJournalEntryLineInput[] } + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + if (!body.lines || !Array.isArray(body.lines) || body.lines.length === 0) { + return NextResponse.json({ error: 'Lines are required' }, { status: 400 }) + } + + try { + const result = await correctEntry(user.id, id, body.lines) + return NextResponse.json({ data: result }) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to correct entry' }, + { status: 400 } + ) + } +} diff --git a/components/bookkeeping/CorrectionEntryDialog.tsx b/components/bookkeeping/CorrectionEntryDialog.tsx new file mode 100644 index 00000000..20aae05e --- /dev/null +++ b/components/bookkeeping/CorrectionEntryDialog.tsx @@ -0,0 +1,266 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { AccountNumber } from '@/components/ui/account-number' +import AccountCombobox from '@/components/bookkeeping/AccountCombobox' +import { useToast } from '@/components/ui/use-toast' +import { Plus, Trash2 } from 'lucide-react' +import type { JournalEntry, JournalEntryLine, BASAccount } from '@/types' + +interface CorrectionLine { + account_number: string + debit_amount: string + credit_amount: string + line_description: string +} + +interface Props { + entry: JournalEntry + open: boolean + onOpenChange: (open: boolean) => void + onCorrected: () => void +} + +export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCorrected }: Props) { + const { toast } = useToast() + const [accounts, setAccounts] = useState([]) + const [lines, setLines] = useState([]) + const [isSubmitting, setIsSubmitting] = useState(false) + + const originalLines = ((entry.lines || []) as JournalEntryLine[]) + .slice() + .sort((a, b) => a.sort_order - b.sort_order) + + useEffect(() => { + if (open) { + // Pre-fill with original entry's lines + setLines( + originalLines.map((l) => ({ + account_number: l.account_number, + debit_amount: Number(l.debit_amount) > 0 ? String(Number(l.debit_amount)) : '', + credit_amount: Number(l.credit_amount) > 0 ? String(Number(l.credit_amount)) : '', + line_description: l.line_description || '', + })) + ) + fetchAccounts() + } + }, [open, entry.id]) // eslint-disable-line react-hooks/exhaustive-deps + + async function fetchAccounts() { + try { + const res = await fetch('/api/bookkeeping/accounts') + const { data } = await res.json() + setAccounts(data || []) + } catch { + // Accounts will be empty — user can still type account numbers manually + } + } + + const updateLine = (index: number, field: keyof CorrectionLine, value: string) => { + setLines((prev) => prev.map((l, i) => (i === index ? { ...l, [field]: value } : l))) + } + + const addLine = () => { + setLines((prev) => [...prev, { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }]) + } + + const removeLine = (index: number) => { + setLines((prev) => prev.filter((_, i) => i !== index)) + } + + const totalDebit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0) + const totalCredit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0) + const roundedDebit = Math.round(totalDebit * 100) / 100 + const roundedCredit = Math.round(totalCredit * 100) / 100 + const isBalanced = roundedDebit === roundedCredit && roundedDebit > 0 + + const hasValidLines = lines.length >= 2 && lines.every((l) => l.account_number.length === 4) + + async function handleSubmit() { + if (!isBalanced || !hasValidLines) return + + setIsSubmitting(true) + try { + const apiLines = lines.map((l) => ({ + account_number: l.account_number, + debit_amount: parseFloat(l.debit_amount) || 0, + credit_amount: parseFloat(l.credit_amount) || 0, + line_description: l.line_description || undefined, + })) + + const res = await fetch(`/api/bookkeeping/journal-entries/${entry.id}/correct`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lines: apiLines }), + }) + + if (!res.ok) { + const { error } = await res.json() + throw new Error(error || 'Failed to create correction') + } + + toast({ title: 'Ändringsverifikation skapad', description: 'Storno och rättelse har bokförts.' }) + onOpenChange(false) + onCorrected() + } catch (err) { + toast({ + title: 'Fel', + description: err instanceof Error ? err.message : 'Kunde inte skapa ändringsverifikation', + variant: 'destructive', + }) + } finally { + setIsSubmitting(false) + } + } + + return ( + + + + Skapa ändringsverifikation + + + {/* Original entry (read-only) */} +
+
+ {entry.voucher_series}{entry.voucher_number} + {entry.entry_date} + Original +
+

{entry.description}

+ + + + + + + + + + + + {originalLines.map((line) => ( + + + + + + + ))} + +
KontoBeskrivningDebetKredit
{line.line_description || ''} + {Number(line.debit_amount) > 0 + ? Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 }) + : ''} + + {Number(line.credit_amount) > 0 + ? Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 }) + : ''} +
+
+ + {/* Divider */} +
+ + {/* Corrected lines (editable) */} +
+

Rättade rader

+ +
+ {lines.map((line, index) => ( +
+ updateLine(index, 'account_number', v)} + /> + updateLine(index, 'line_description', e.target.value)} + placeholder="Beskrivning" + className="h-8" + /> + updateLine(index, 'debit_amount', e.target.value)} + placeholder="Debet" + className="h-8 text-right" + min={0} + step="0.01" + /> + updateLine(index, 'credit_amount', e.target.value)} + placeholder="Kredit" + className="h-8 text-right" + min={0} + step="0.01" + /> + +
+ ))} +
+ + + + {/* Balance summary */} +
+
+ Debet: + + {roundedDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} + +
+
+ Kredit: + + {roundedCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} + +
+
+ + {!isBalanced && roundedDebit + roundedCredit > 0 && ( +

+ Debet och kredit måste vara lika och större än 0. +

+ )} +
+ + + + + + +
+ ) +} diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index 76df8fac..de63de7d 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -9,6 +9,7 @@ import { Switch } from '@/components/ui/switch' import { ChevronDown, ChevronRight, Paperclip, AlertTriangle } from 'lucide-react' import { AccountNumber } from '@/components/ui/account-number' import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments' +import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog' import type { JournalEntry, JournalEntryLine } from '@/types' const NEEDS_ATTACHMENT = new Set([ @@ -32,6 +33,7 @@ export default function JournalEntryList({ periodId }: Props) { const [page, setPage] = useState(0) const [attachmentCounts, setAttachmentCounts] = useState>({}) const [showMissingOnly, setShowMissingOnly] = useState(false) + const [correctionEntry, setCorrectionEntry] = useState(null) const pageSize = 20 const fetchAttachmentCounts = useCallback(async (entryIds: string[]) => { @@ -264,6 +266,18 @@ export default function JournalEntryList({ periodId }: Props) { journalEntryId={entry.id} onCountChange={(c) => handleAttachmentCountChange(entry.id, c)} /> + + {entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && ( +
+ +
+ )} )} @@ -271,6 +285,16 @@ export default function JournalEntryList({ periodId }: Props) { })} + {/* Correction dialog */} + {correctionEntry && ( + { if (!open) setCorrectionEntry(null) }} + onCorrected={() => { setCorrectionEntry(null); fetchEntries() }} + /> + )} + {/* Pagination */} {count > pageSize && (
diff --git a/lib/core/bookkeeping/storno-service.ts b/lib/core/bookkeeping/storno-service.ts index 8ef4da27..15b20046 100644 --- a/lib/core/bookkeeping/storno-service.ts +++ b/lib/core/bookkeeping/storno-service.ts @@ -128,81 +128,105 @@ export async function correctEntry( .eq('id', originalEntryId) // ===== Step 2: Create corrected entry ===== - const correctedVoucherNumber = await getNextVoucherNumber( - userId, - original.fiscal_period_id, - original.voucher_series || 'A' - ) - - // Resolve account IDs for corrected lines - const accountNumbers = [...new Set(correctedLines.map((l) => l.account_number))] - const { data: accounts } = await supabase - .from('chart_of_accounts') - .select('id, account_number') - .eq('user_id', userId) - .in('account_number', accountNumbers) - - const accountIdMap = new Map() - for (const account of accounts || []) { - accountIdMap.set(account.account_number, account.id) + // If anything in this step fails, we must roll back the reversal from step 1 + // to avoid leaving the ledger in an inconsistent state. + async function rollbackReversal() { + // Restore original entry to 'posted' status + await supabase + .from('journal_entries') + .update({ status: 'posted', reversed_by_id: null }) + .eq('id', originalEntryId) + // Delete the reversal entry (it was just created, safe to remove since + // the DB trigger allows deleting draft entries and we need to clean up) + await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id) + await supabase.from('journal_entries').delete().eq('id', reversalEntry.id) } - const { data: correctedEntry, error: correctedError } = await supabase - .from('journal_entries') - .insert({ - user_id: userId, - fiscal_period_id: original.fiscal_period_id, - voucher_number: correctedVoucherNumber, - voucher_series: original.voucher_series || 'A', - entry_date: new Date().toISOString().split('T')[0], - description: `Rättelse: ${original.description}`, - source_type: 'correction', - correction_of_id: originalEntryId, - status: 'draft', - }) - .select() - .single() + let correctedEntry: typeof reversalEntry - if (correctedError || !correctedEntry) { - throw new Error(`Failed to create corrected entry: ${correctedError?.message}`) - } + try { + const correctedVoucherNumber = await getNextVoucherNumber( + userId, + original.fiscal_period_id, + original.voucher_series || 'A' + ) - // Insert corrected lines - const correctedLineInserts = correctedLines.map((line, index) => ({ - journal_entry_id: correctedEntry.id, - account_number: line.account_number, - account_id: accountIdMap.get(line.account_number) || null, - debit_amount: Math.round((line.debit_amount || 0) * 100) / 100, - credit_amount: Math.round((line.credit_amount || 0) * 100) / 100, - currency: line.currency || 'SEK', - amount_in_currency: line.amount_in_currency - ? Math.round(line.amount_in_currency * 100) / 100 - : null, - exchange_rate: line.exchange_rate || null, - line_description: line.line_description || null, - tax_code: line.tax_code || null, - cost_center: line.cost_center || null, - project: line.project || null, - sort_order: index, - })) + // Resolve account IDs for corrected lines + const accountNumbers = [...new Set(correctedLines.map((l) => l.account_number))] + const { data: accounts } = await supabase + .from('chart_of_accounts') + .select('id, account_number') + .eq('user_id', userId) + .in('account_number', accountNumbers) - const { error: correctedLinesError } = await supabase - .from('journal_entry_lines') - .insert(correctedLineInserts) + const accountIdMap = new Map() + for (const account of accounts || []) { + accountIdMap.set(account.account_number, account.id) + } - if (correctedLinesError) { - await supabase.from('journal_entries').delete().eq('id', correctedEntry.id) - throw new Error(`Failed to create corrected lines: ${correctedLinesError.message}`) - } + const { data: newEntry, error: correctedError } = await supabase + .from('journal_entries') + .insert({ + user_id: userId, + fiscal_period_id: original.fiscal_period_id, + voucher_number: correctedVoucherNumber, + voucher_series: original.voucher_series || 'A', + entry_date: new Date().toISOString().split('T')[0], + description: `Rättelse: ${original.description}`, + source_type: 'correction', + correction_of_id: originalEntryId, + status: 'draft', + }) + .select() + .single() - // Post the corrected entry - const { error: postCorrectedError } = await supabase - .from('journal_entries') - .update({ status: 'posted' }) - .eq('id', correctedEntry.id) + if (correctedError || !newEntry) { + throw new Error(`Failed to create corrected entry: ${correctedError?.message}`) + } - if (postCorrectedError) { - throw new Error(`Failed to post corrected entry: ${postCorrectedError.message}`) + correctedEntry = newEntry + + // Insert corrected lines + const correctedLineInserts = correctedLines.map((line, index) => ({ + journal_entry_id: correctedEntry.id, + account_number: line.account_number, + account_id: accountIdMap.get(line.account_number) || null, + debit_amount: Math.round((line.debit_amount || 0) * 100) / 100, + credit_amount: Math.round((line.credit_amount || 0) * 100) / 100, + currency: line.currency || 'SEK', + amount_in_currency: line.amount_in_currency + ? Math.round(line.amount_in_currency * 100) / 100 + : null, + exchange_rate: line.exchange_rate || null, + line_description: line.line_description || null, + tax_code: line.tax_code || null, + cost_center: line.cost_center || null, + project: line.project || null, + sort_order: index, + })) + + const { error: correctedLinesError } = await supabase + .from('journal_entry_lines') + .insert(correctedLineInserts) + + if (correctedLinesError) { + await supabase.from('journal_entries').delete().eq('id', correctedEntry.id) + throw new Error(`Failed to create corrected lines: ${correctedLinesError.message}`) + } + + // Post the corrected entry + const { error: postCorrectedError } = await supabase + .from('journal_entries') + .update({ status: 'posted' }) + .eq('id', correctedEntry.id) + + if (postCorrectedError) { + throw new Error(`Failed to post corrected entry: ${postCorrectedError.message}`) + } + } catch (err) { + // Roll back the reversal to restore ledger consistency + await rollbackReversal() + throw err } // ===== Step 3: Fetch complete entries =====