From 6bd85f94b6510aac28fb5114024fe77d681b9aef Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:14:02 +0200 Subject: [PATCH] fix(bookkeeping): editable verifikationstext on andringsverifikation (#1035) The correction header was always built server-side as "Rattelse: ". When the original entry was labelled after the wrong account, the correction kept echoing that stale label even after the user switched to the correct account (follow-up to the line-description fix in #1029). - CorrectJournalEntrySchema gains an optional trimmed description - correctEntry() accepts options.description; blank or absent falls back to the canonical "Rattelse: " auto text - both correct routes (dashboard + v1, which share the schema) thread the description through - CorrectionEntryDialog surfaces an editable verifikationstext field, pre-filled with the auto text; an untouched or cleared prefill is NOT sent, so the server-side fallback stays the source of truth (same only-overwrite-auto-filled principle as #1029) Forward-only: already-posted corrections are immutable per BFL. Fixes #1031 Co-authored-by: Claude Fable 5 --- .../[id]/correct/__tests__/route.test.ts | 45 ++++++++++++++++++- .../journal-entries/[id]/correct/route.ts | 4 +- .../journal-entries/[id]/correct/route.ts | 11 +++-- .../bookkeeping/CorrectionEntryDialog.tsx | 33 +++++++++++++- .../correction-entry-description.test.ts | 40 +++++++++++++++++ .../correction-entry-description.ts | 34 ++++++++++++++ lib/api/__tests__/schemas.test.ts | 25 +++++++++++ lib/api/schemas.ts | 4 ++ .../__tests__/storno-service.test.ts | 37 +++++++++++++++ lib/core/bookkeeping/storno-service.ts | 11 ++++- 10 files changed, 236 insertions(+), 8 deletions(-) create mode 100644 components/bookkeeping/__tests__/correction-entry-description.test.ts create mode 100644 components/bookkeeping/correction-entry-description.ts 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 index a7e14827..95ebc8b2 100644 --- a/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts @@ -113,7 +113,50 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => { expect(status).toBe(200) expect(body.data.reversal).toEqual(reversal) expect(body.data.corrected).toEqual(corrected) - expect(mockCorrectEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'entry-1', lines) + expect(mockCorrectEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'entry-1', lines, { + description: undefined, + }) + }) + + it('threads an optional description through to correctEntry (issue #1031)', 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: '2893', debit_amount: 1000, credit_amount: 0 }, + { account_number: '1930', debit_amount: 0, credit_amount: 1000 }, + ] + + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/correct', { + method: 'POST', + body: { lines, description: 'Rättelse: Skulder till närstående personer, kortfristig del' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(mockCorrectEntry).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'entry-1', lines, { + description: 'Rättelse: Skulder till närstående personer, kortfristig del', + }) + }) + + it('returns 400 when description is blank', async () => { + 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, description: ' ' }, + }) + 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('Validation failed') + expect(mockCorrectEntry).not.toHaveBeenCalled() }) it('maps an unbalanced-correction engine error to the canonical envelope (400)', async () => { diff --git a/app/api/bookkeeping/journal-entries/[id]/correct/route.ts b/app/api/bookkeeping/journal-entries/[id]/correct/route.ts index 47ca8873..b7280e27 100644 --- a/app/api/bookkeeping/journal-entries/[id]/correct/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/correct/route.ts @@ -13,7 +13,9 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( const { id } = await params const validation = await validateBody(request, CorrectJournalEntrySchema) if (!validation.success) return validation.response - const result = await correctEntry(supabase, companyId, user.id, id, validation.data.lines) + const result = await correctEntry(supabase, companyId, user.id, id, validation.data.lines, { + description: validation.data.description, + }) return NextResponse.json({ data: result }) }, { requireWrite: true }, diff --git a/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route.ts b/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route.ts index 55904ae4..5f38fd0d 100644 --- a/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route.ts +++ b/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route.ts @@ -6,9 +6,11 @@ * posted with the new lines. All three remain in the verifikationsserie, * linked via reverses_id, reversed_by_id, and correction_of_id. * - * Body: `{ lines: [...] }`: the new balanced lines. The corrected entry - * inherits entry_date, fiscal_period_id, description, and voucher_series - * from the original. + * Body: `{ lines: [...], description? }`: the new balanced lines. The + * corrected entry inherits entry_date, fiscal_period_id, and voucher_series + * from the original. Its description defaults to "Rättelse: "; + * pass `description` to override it (e.g. when the original label named the + * wrong account). * * Idempotent (mandatory Idempotency-Key). Dry-runnable. */ @@ -108,7 +110,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) }, }) } - const { lines } = parsed.data + const { lines, description } = parsed.data const balance = validateBalance(lines) if (!balance.valid) { @@ -178,6 +180,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string ctx.userId, entryId, lines, + { description }, ) return ok( { diff --git a/components/bookkeeping/CorrectionEntryDialog.tsx b/components/bookkeeping/CorrectionEntryDialog.tsx index adb55d97..d7115e31 100644 --- a/components/bookkeeping/CorrectionEntryDialog.tsx +++ b/components/bookkeeping/CorrectionEntryDialog.tsx @@ -11,8 +11,13 @@ import { } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import CorrectionPreview from '@/components/bookkeeping/CorrectionPreview' +import { + autoCorrectionDescription, + correctionDescriptionForSubmit, +} from '@/components/bookkeeping/correction-entry-description' import { nextLineDescriptionForAccountChange } from '@/components/bookkeeping/correction-line-description' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' @@ -40,6 +45,7 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor const router = useRouter() const [accounts, setAccounts] = useState([]) const [lines, setLines] = useState([]) + const [description, setDescription] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const originalLines = ((entry.lines || []) as JournalEntryLine[]) @@ -57,6 +63,9 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor line_description: l.line_description || '', })) ) + // Pre-fill the verifikationstext with the same auto text the server + // would generate; only a user edit is sent along (see handleSubmit). + setDescription(autoCorrectionDescription(entry.description)) fetchAccounts() } }, [open, entry.id]) // eslint-disable-line react-hooks/exhaustive-deps @@ -124,7 +133,12 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor const res = await fetch(`/api/bookkeeping/journal-entries/${entry.id}/correct`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ lines: apiLines }), + body: JSON.stringify({ + lines: apiLines, + // Only sent when the user changed the auto prefill: the server + // fallback ("Rättelse: ") stays the source of truth. + description: correctionDescriptionForSubmit(description, entry.description), + }), }) const result = await res.json() @@ -194,6 +208,23 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor {/* Live diff: original | storno | correction | förändring */} + {/* Verifikationstext for the new (corrected) entry. Pre-filled with + the auto text; editable so a header named after the wrong account + is not echoed on the correction (issue #1031). */} +
+ + setDescription(e.target.value)} + placeholder={autoCorrectionDescription(entry.description)} + /> +

+ Texten på den nya verifikationen. Ändra den om originalets beskrivning inte längre + stämmer, till exempel när rättelsen byter konto. +

+
+ {/* Corrected lines (editable) */}
diff --git a/components/bookkeeping/__tests__/correction-entry-description.test.ts b/components/bookkeeping/__tests__/correction-entry-description.test.ts new file mode 100644 index 00000000..11e829ef --- /dev/null +++ b/components/bookkeeping/__tests__/correction-entry-description.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest' +import { + autoCorrectionDescription, + correctionDescriptionForSubmit, +} from '@/components/bookkeeping/correction-entry-description' + +const ORIGINAL = 'Lån från närstående personer, långfristig del' + +describe('autoCorrectionDescription', () => { + it('matches the server-side fallback format', () => { + expect(autoCorrectionDescription(ORIGINAL)).toBe(`Rättelse: ${ORIGINAL}`) + }) +}) + +describe('correctionDescriptionForSubmit', () => { + it('sends nothing when the field still equals the auto prefill', () => { + expect(correctionDescriptionForSubmit(`Rättelse: ${ORIGINAL}`, ORIGINAL)).toBeUndefined() + }) + + it('sends nothing when the prefill only gained surrounding whitespace', () => { + expect(correctionDescriptionForSubmit(` Rättelse: ${ORIGINAL} `, ORIGINAL)).toBeUndefined() + }) + + it('sends nothing when the field was cleared (server fallback applies)', () => { + expect(correctionDescriptionForSubmit('', ORIGINAL)).toBeUndefined() + expect(correctionDescriptionForSubmit(' ', ORIGINAL)).toBeUndefined() + }) + + it('sends a user-edited description (the reported bug: relabel after account change)', () => { + expect( + correctionDescriptionForSubmit('Rättelse: Skulder till närstående personer, kortfristig del', ORIGINAL), + ).toBe('Rättelse: Skulder till närstående personer, kortfristig del') + }) + + it('trims a user-edited description before sending', () => { + expect(correctionDescriptionForSubmit(' Omföring till rätt konto ', ORIGINAL)).toBe( + 'Omföring till rätt konto', + ) + }) +}) diff --git a/components/bookkeeping/correction-entry-description.ts b/components/bookkeeping/correction-entry-description.ts new file mode 100644 index 00000000..4967acc3 --- /dev/null +++ b/components/bookkeeping/correction-entry-description.ts @@ -0,0 +1,34 @@ +/** + * Entry-level verifikationstext handling for the correction dialog. + * + * The correction header defaults server-side to "Rättelse: " + * (lib/core/bookkeeping/storno-service.ts). If the original entry was + * labelled after the wrong account (e.g. "Lån från närstående personer, + * långfristig del"), that header keeps echoing the wrong label even after + * the account itself is corrected (issue #1031). The dialog therefore + * pre-fills an editable field with the same auto text and only sends a + * description when the user actually changed it: an untouched auto prefill + * is omitted from the request so the server-side fallback stays the single + * source of truth for the default format. Same only-overwrite-auto-filled + * principle as the line-description guard from #1029. + */ + +/** The auto text the server would generate for the correction header. */ +export function autoCorrectionDescription(originalDescription: string): string { + return `Rättelse: ${originalDescription}` +} + +/** + * Decide what to send as the correction's description. + * Returns undefined when the field is blank or still equals the auto prefill: + * in both cases the server-side fallback should apply. + */ +export function correctionDescriptionForSubmit( + currentDescription: string, + originalDescription: string, +): string | undefined { + const trimmed = currentDescription.trim() + if (!trimmed) return undefined + if (trimmed === autoCorrectionDescription(originalDescription).trim()) return undefined + return trimmed +} diff --git a/lib/api/__tests__/schemas.test.ts b/lib/api/__tests__/schemas.test.ts index 672b2c11..f32fd271 100644 --- a/lib/api/__tests__/schemas.test.ts +++ b/lib/api/__tests__/schemas.test.ts @@ -2212,6 +2212,31 @@ describe('CorrectJournalEntrySchema', () => { }) expect(result.success).toBe(false) }) + + it('accepts an optional description and trims it', () => { + const result = CorrectJournalEntrySchema.safeParse({ + description: ' Rättelse: Skulder till närstående personer, kortfristig del ', + lines: [ + validJournalEntryLine({ account_number: '6200', debit_amount: 500, credit_amount: 0 }), + validJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 500 }), + ], + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.description).toBe('Rättelse: Skulder till närstående personer, kortfristig del') + } + }) + + it('rejects a blank description (omit it to use the server fallback)', () => { + const result = CorrectJournalEntrySchema.safeParse({ + description: ' ', + lines: [ + validJournalEntryLine({ account_number: '6200', debit_amount: 500, credit_amount: 0 }), + validJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 500 }), + ], + }) + expect(result.success).toBe(false) + }) }) // ============================================================ diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index ba7ebad1..69357d18 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -910,6 +910,10 @@ export const CreateJournalEntrySchema = z.object({ }) export const CorrectJournalEntrySchema = z.object({ + // Optional verifikationstext for the corrected entry. When omitted the + // server falls back to "Rättelse: "; supplying it lets + // the user replace a header that echoed the wrong account's label (#1031). + description: z.string().trim().min(1, 'Description cannot be empty').optional(), lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required for double-entry'), }) diff --git a/lib/core/bookkeeping/__tests__/storno-service.test.ts b/lib/core/bookkeeping/__tests__/storno-service.test.ts index 1a2cea48..59b2c6e6 100644 --- a/lib/core/bookkeeping/__tests__/storno-service.test.ts +++ b/lib/core/bookkeeping/__tests__/storno-service.test.ts @@ -287,6 +287,43 @@ describe('correctEntry', () => { expect(result.corrected).toBeDefined() }) + it('uses a caller-supplied description for the corrected entry (issue #1031)', async () => { + setupResults() + const supabase = makeClient() + await correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines, { + description: 'Rättelse: Skulder till närstående personer, kortfristig del', + }) + + const entryInserts = inserts.filter((i) => i.table === 'journal_entries') + expect(entryInserts).toHaveLength(2) + const corrected = entryInserts[1].payload as { source_type: string; description: string } + expect(corrected.source_type).toBe('correction') + expect(corrected.description).toBe('Rättelse: Skulder till närstående personer, kortfristig del') + }) + + it('falls back to "Rättelse: " when no description is supplied', async () => { + setupResults() + const supabase = makeClient() + await correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines) + + const entryInserts = inserts.filter((i) => i.table === 'journal_entries') + const corrected = entryInserts[1].payload as { source_type: string; description: string } + expect(corrected.source_type).toBe('correction') + expect(corrected.description).toBe('Rättelse: Test purchase') + }) + + it('falls back to the auto text when the supplied description is blank', async () => { + setupResults() + const supabase = makeClient() + await correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines, { + description: ' ', + }) + + const entryInserts = inserts.filter((i) => i.table === 'journal_entries') + const corrected = entryInserts[1].payload as { description: string } + expect(corrected.description).toBe('Rättelse: Test purchase') + }) + it('accepts a source_type=correction entry as the original (chained correction, BFL 5 kap. 5 §)', async () => { // The user just corrected entry A → got correction C. They now want to // correct C. Service must not care about source_type of the original: diff --git a/lib/core/bookkeeping/storno-service.ts b/lib/core/bookkeeping/storno-service.ts index 3172f690..2ed56dd6 100644 --- a/lib/core/bookkeeping/storno-service.ts +++ b/lib/core/bookkeeping/storno-service.ts @@ -130,6 +130,13 @@ export async function correctEntry( * the small TOCTOU window a second independent read would open. */ preloadedOriginal?: OriginalWithLines + /** + * Verifikationstext for the corrected entry. Defaults to + * "Rättelse: ". Supplying it lets a user who + * corrects the account BECAUSE the original label was wrong avoid the + * stale label echoing in the correction header (issue #1031). + */ + description?: string } ): Promise<{ reversal: JournalEntry; corrected: JournalEntry; documentRelinkError?: string }> { // Validate the corrected lines are balanced @@ -340,7 +347,9 @@ export async function correctEntry( voucher_number: correctedVoucherNumber, voucher_series: original.voucher_series || 'A', entry_date: correctedDate, - description: `Rättelse: ${original.description}`, + // A caller-supplied verifikationstext wins; blank falls back to the + // canonical auto text so the header never ends up empty. + description: options?.description?.trim() || `Rättelse: ${original.description}`, source_type: 'correction', correction_of_id: originalEntryId, status: 'draft',