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 }
)
}
}