From 7bf7565852b4199bc32422207acc808f96ef81ea Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:12:03 +0200 Subject: [PATCH] feat: delete last voucher, notes field, schema cache fix (#230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: delete last voucher, notes field, schema cache fix Address three customer feedback items from William (wigu.se): 1. Delete last voucher per series (Fortnox model): - New `delete_last_voucher` RPC with full safety checks (last-in-series, open period, no references, owner/admin only) - Session variable bypass for immutability/retention/line triggers - Full JSONB audit trail (BFNAR 2013:2 behandlingshistorik) - DELETE endpoint + UI with confirmation dialogs - Storno restoration when deleting a reversal entry 2. Notes/comment field on vouchers: - `notes` column on journal_entries (always-editable internal metadata) - Immutability trigger updated to allow notes-only updates on posted entries - PATCH endpoint, inline-edit UI on detail page, form textarea 3. Schema cache fix: - NOTIFY pgrst applied to production (immediate fix) - Retroactive migration + CLAUDE.md migration rule added Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address Greptile review — tighten trigger, lock voucher sequence P1: The notes-only exception in enforce_journal_entry_immutability was too broad — it only checked 7 verifikation fields, allowing silent mutation of correction_of_id, reverses_id, reversed_by_id, committed_at, and user_id on posted entries. Now guards all metadata fields; only notes and updated_at may differ. P2: Lock voucher_sequences row FOR UPDATE before the MAX(voucher_number) check in delete_last_voucher to serialise against concurrent commit_journal_entry calls, preventing voucher number gaps. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- CLAUDE.md | 1 + app/(dashboard)/bookkeeping/[id]/page.tsx | 178 +++++++++++++- app/(dashboard)/expenses/page.tsx | 2 +- .../[id]/chain/__tests__/route.test.ts | 28 ++- .../journal-entries/[id]/chain/route.ts | 19 +- .../journal-entries/[id]/notes/route.ts | 43 ++++ .../bookkeeping/journal-entries/[id]/route.ts | 48 ++++ components/bookkeeping/JournalEntryForm.tsx | 20 +- components/bookkeeping/JournalEntryList.tsx | 82 ++++++- .../bookkeeping/JournalEntryReviewContent.tsx | 8 + components/dashboard/DashboardNav.tsx | 2 +- components/import/BankFileConfirmStep.tsx | 2 +- components/import/BankFilePreviewStep.tsx | 2 +- lib/api/schemas.ts | 1 + lib/bookkeeping/engine.ts | 1 + lib/events/types.ts | 1 + .../20260413120001_pgrst_schema_reload.sql | 4 + ...20260413120002_add_journal_entry_notes.sql | 73 ++++++ .../20260413120003_delete_last_voucher.sql | 223 ++++++++++++++++++ tests/helpers.ts | 1 + types/index.ts | 2 + 21 files changed, 713 insertions(+), 28 deletions(-) create mode 100644 app/api/bookkeeping/journal-entries/[id]/notes/route.ts create mode 100644 supabase/migrations/20260413120001_pgrst_schema_reload.sql create mode 100644 supabase/migrations/20260413120002_add_journal_entry_notes.sql create mode 100644 supabase/migrations/20260413120003_delete_last_voucher.sql diff --git a/CLAUDE.md b/CLAUDE.md index ed5a07d2..99ff8b00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -408,6 +408,7 @@ export async function POST(request: Request) { 5. **Never modify existing migrations** — create new ones 6. **Never modify enforcement triggers** (migration 017) — legally required 7. **Apply via Supabase MCP tool**: `mcp__plugin_supabase_supabase__apply_migration` +8. **Always include `NOTIFY pgrst, 'reload schema'`** at the end of migrations that alter table structure (ADD/DROP COLUMN, CREATE TABLE, ALTER TYPE). Without this, PostgREST serves stale schema until next reload. --- diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index 5b51cb46..e9f81979 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -1,27 +1,39 @@ 'use client' import { useState, useEffect, useCallback, use } from 'react' +import { useRouter } from 'next/navigation' import Link from 'next/link' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { AccountNumber } from '@/components/ui/account-number' -import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock } from 'lucide-react' +import { Textarea } from '@/components/ui/textarea' +import { Loader2, ArrowLeft, Paperclip, AlertTriangle, Lock, MessageSquare, Pencil, Check, X } from 'lucide-react' import { useCanWrite } from '@/lib/hooks/use-can-write' import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments' import JournalEntryStatusBadge, { sourceTypeLabels } from '@/components/bookkeeping/JournalEntryStatusBadge' import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog' import CorrectionChain from '@/components/bookkeeping/CorrectionChain' +import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' +import { useToast } from '@/components/ui/use-toast' import type { JournalEntry, JournalEntryLine } from '@/types' export default function JournalEntryDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params) + const router = useRouter() const { canWrite } = useCanWrite() + const { toast } = useToast() const [entry, setEntry] = useState(null) const [chain, setChain] = useState([]) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const [showCorrection, setShowCorrection] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const [isLastInSeries, setIsLastInSeries] = useState(false) const [attachmentCount, setAttachmentCount] = useState(0) + const [editingNotes, setEditingNotes] = useState(false) + const [notesValue, setNotesValue] = useState('') + const [savingNotes, setSavingNotes] = useState(false) const fetchData = useCallback(async () => { setIsLoading(true) @@ -36,6 +48,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i const { data } = await res.json() setEntry(data.entry) setChain(data.chain) + setIsLastInSeries(data.is_last_in_series ?? false) } catch { setError('Kunde inte hämta verifikation') } finally { @@ -43,6 +56,50 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i } }, [id]) + const saveNotes = useCallback(async (value: string) => { + setSavingNotes(true) + try { + const res = await fetch(`/api/bookkeeping/journal-entries/${id}/notes`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ notes: value || null }), + }) + if (res.ok) { + setEntry(prev => prev ? { ...prev, notes: value || null } : prev) + setEditingNotes(false) + } else { + toast({ title: 'Kunde inte spara anteckning', variant: 'destructive' }) + } + } catch { + toast({ title: 'Kunde inte spara anteckning', variant: 'destructive' }) + } finally { + setSavingNotes(false) + } + }, [id, toast]) + + const handleDelete = useCallback(async () => { + setIsDeleting(true) + try { + const res = await fetch(`/api/bookkeeping/journal-entries/${id}`, { method: 'DELETE' }) + const result = await res.json() + if (res.ok) { + toast({ + title: 'Verifikat raderat', + description: `Verifikat ${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''} har raderats.`, + }) + router.push('/bookkeeping') + } else { + toast({ title: 'Kunde inte radera', description: result.error, variant: 'destructive' }) + setShowDeleteConfirm(false) + } + } catch { + toast({ title: 'Kunde inte radera verifikat', variant: 'destructive' }) + setShowDeleteConfirm(false) + } finally { + setIsDeleting(false) + } + }, [id, router, toast]) + useEffect(() => { fetchData() }, [fetchData]) @@ -120,18 +177,35 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i

{entry.description}

- {canCorrect && ( - + {entry.status === 'posted' && ( +
+ {isLastInSeries && ( + + )} + {canCorrect && ( + + )} +
)} @@ -156,6 +230,61 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i Typ {sourceTypeLabels[entry.source_type] || entry.source_type} + {/* Notes — always editable (internal metadata, not BFL verifikation content) */} +
+
+ + + Anteckning + + {!editingNotes && canWrite && ( + + )} +
+ {editingNotes ? ( +
+