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 <noreply@anthropic.com>
This commit is contained in:
Emil
2026-02-23 13:00:03 +01:00
co-authored by Claude Opus 4.6
parent 44231d0000
commit 263c229903
5 changed files with 567 additions and 67 deletions
@@ -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')
})
})
@@ -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 }
)
}
}
@@ -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<BASAccount[]>([])
const [lines, setLines] = useState<CorrectionLine[]>([])
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Skapa ändringsverifikation</DialogTitle>
</DialogHeader>
{/* Original entry (read-only) */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="font-mono">{entry.voucher_series}{entry.voucher_number}</span>
<span>{entry.entry_date}</span>
<Badge variant="outline" className="text-xs">Original</Badge>
</div>
<p className="text-sm">{entry.description}</p>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-1.5 w-48">Konto</th>
<th className="py-1.5">Beskrivning</th>
<th className="py-1.5 w-28 text-right">Debet</th>
<th className="py-1.5 w-28 text-right">Kredit</th>
</tr>
</thead>
<tbody>
{originalLines.map((line) => (
<tr key={line.id} className="border-b last:border-0">
<td className="py-1.5"><AccountNumber number={line.account_number} showName /></td>
<td className="py-1.5 text-muted-foreground">{line.line_description || ''}</td>
<td className="py-1.5 text-right">
{Number(line.debit_amount) > 0
? Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
: ''}
</td>
<td className="py-1.5 text-right">
{Number(line.credit_amount) > 0
? Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })
: ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Divider */}
<div className="border-t my-2" />
{/* Corrected lines (editable) */}
<div className="space-y-2">
<p className="text-sm font-medium">Rättade rader</p>
<div className="space-y-2">
{lines.map((line, index) => (
<div key={index} className="grid grid-cols-[1fr_1fr_120px_120px_auto] gap-2 items-start">
<AccountCombobox
value={line.account_number}
accounts={accounts}
onChange={(v) => updateLine(index, 'account_number', v)}
/>
<Input
value={line.line_description}
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
placeholder="Beskrivning"
className="h-8"
/>
<Input
type="number"
value={line.debit_amount}
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
placeholder="Debet"
className="h-8 text-right"
min={0}
step="0.01"
/>
<Input
type="number"
value={line.credit_amount}
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
placeholder="Kredit"
className="h-8 text-right"
min={0}
step="0.01"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => removeLine(index)}
disabled={lines.length <= 2}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<Button variant="outline" size="sm" onClick={addLine}>
<Plus className="h-4 w-4 mr-1" />
Lägg till rad
</Button>
{/* Balance summary */}
<div className="flex justify-end gap-6 text-sm pt-2 border-t">
<div>
<span className="text-muted-foreground mr-2">Debet:</span>
<span className={!isBalanced ? 'text-destructive font-medium' : 'font-medium'}>
{roundedDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</span>
</div>
<div>
<span className="text-muted-foreground mr-2">Kredit:</span>
<span className={!isBalanced ? 'text-destructive font-medium' : 'font-medium'}>
{roundedCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</span>
</div>
</div>
{!isBalanced && roundedDebit + roundedCredit > 0 && (
<p className="text-sm text-destructive">
Debet och kredit måste vara lika och större än 0.
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Avbryt
</Button>
<Button
onClick={handleSubmit}
disabled={!isBalanced || !hasValidLines || isSubmitting}
>
{isSubmitting ? 'Skapar...' : 'Skapa ändringsverifikation'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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<Record<string, number>>({})
const [showMissingOnly, setShowMissingOnly] = useState(false)
const [correctionEntry, setCorrectionEntry] = useState<JournalEntry | null>(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' && (
<div className="mt-4 pt-3 border-t flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCorrectionEntry(entry)}
>
Skapa ändringsverifikation
</Button>
</div>
)}
</CardContent>
)}
</Card>
@@ -271,6 +285,16 @@ export default function JournalEntryList({ periodId }: Props) {
})}
</div>
{/* Correction dialog */}
{correctionEntry && (
<CorrectionEntryDialog
entry={correctionEntry}
open={!!correctionEntry}
onOpenChange={(open) => { if (!open) setCorrectionEntry(null) }}
onCorrected={() => { setCorrectionEntry(null); fetchEntries() }}
/>
)}
{/* Pagination */}
{count > pageSize && (
<div className="flex justify-center gap-2">
+91 -67
View File
@@ -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<string, string>()
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<string, string>()
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 =====