feat: delete last voucher, notes field, schema cache fix (#230)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-13 16:12:03 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent ade4ad5971
commit 7bf7565852
21 changed files with 713 additions and 28 deletions
+19 -1
View File
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
@@ -45,6 +46,7 @@ interface Props {
initialLines?: FormLine[]
initialDate?: string
initialDescription?: string
initialNotes?: string
sourceType?: JournalEntrySourceType
sourceId?: string
submitUrl?: string
@@ -59,6 +61,7 @@ export default function JournalEntryForm({
initialLines,
initialDate,
initialDescription,
initialNotes,
sourceType,
sourceId,
submitUrl,
@@ -70,6 +73,7 @@ export default function JournalEntryForm({
const [selectedPeriod, setSelectedPeriod] = useState('')
const [entryDate, setEntryDate] = useState(initialDate ?? new Date().toISOString().split('T')[0])
const [description, setDescription] = useState(initialDescription ?? '')
const [notes, setNotes] = useState(initialNotes ?? '')
const [lines, setLines] = useState<FormLine[]>(
initialLines ?? [{ ...BLANK_LINE }, { ...BLANK_LINE }]
)
@@ -90,7 +94,7 @@ export default function JournalEntryForm({
const isUploading = uploadedFiles.some((f) => f.status === 'uploading')
const hasContent = description !== '' ||
const hasContent = description !== '' || notes !== '' ||
lines.some(l => l.account_number !== '' || l.debit_amount !== '' || l.credit_amount !== '') ||
uploadedFiles.length > 0
useUnsavedChanges(hasContent)
@@ -285,6 +289,7 @@ export default function JournalEntryForm({
source_type: sourceType ?? 'manual',
source_id: sourceId,
voucher_series: voucherSeries || 'A',
notes: notes || undefined,
lines: entryLines,
}),
})
@@ -330,6 +335,7 @@ export default function JournalEntryForm({
setShowReview(false)
// Reset form
setDescription('')
setNotes('')
setUploadedFiles([])
setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }])
setEntryCurrency('SEK')
@@ -386,6 +392,17 @@ export default function JournalEntryForm({
placeholder="Verifikationstext..."
/>
</div>
<div className={embedded ? 'hidden' : 'col-span-full'}>
<Label>Intern anteckning <span className="text-muted-foreground font-normal">(valfritt)</span></Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="T.ex. anledning till bokning, referens till mejl, etc."
className="mt-1 resize-none"
rows={2}
maxLength={2000}
/>
</div>
{!embedded && (
<div>
<Label>Serie</Label>
@@ -720,6 +737,7 @@ export default function JournalEntryForm({
periodName={periods.find((p) => p.id === selectedPeriod)?.name || ''}
entryDate={entryDate}
description={description}
notes={notes || undefined}
voucherSeries={!embedded ? voucherSeries : undefined}
lines={lines}
totalDebit={totalDebit}
+81 -1
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useMemo } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@@ -13,6 +13,8 @@ import { AccountNumber } from '@/components/ui/account-number'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { useToast } from '@/components/ui/use-toast'
import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge'
import type { JournalEntry, JournalEntryLine } from '@/types'
@@ -30,6 +32,7 @@ interface Props {
}
export default function JournalEntryList({ periodId }: Props) {
const { toast } = useToast()
const [entries, setEntries] = useState<JournalEntry[]>([])
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState<string | null>(null)
@@ -38,6 +41,8 @@ export default function JournalEntryList({ periodId }: Props) {
const [attachmentCounts, setAttachmentCounts] = useState<Record<string, number>>({})
const [showMissingOnly, setShowMissingOnly] = useState(false)
const [correctionEntry, setCorrectionEntry] = useState<JournalEntry | null>(null)
const [deleteEntry, setDeleteEntry] = useState<JournalEntry | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const [dateSortDir, setDateSortDir] = useState<'desc' | 'asc'>('desc')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
@@ -134,6 +139,43 @@ export default function JournalEntryList({ periodId }: Props) {
setAttachmentCounts((prev) => ({ ...prev, [entryId]: count }))
}, [])
// Compute which entries are the last in their series (enables delete button)
const lastInSeriesIds = useMemo(() => {
const maxPerSeries = new Map<string, { id: string; num: number }>()
for (const e of entries) {
if (e.status !== 'posted') continue
const key = `${e.fiscal_period_id}:${e.voucher_series}`
const current = maxPerSeries.get(key)
if (!current || e.voucher_number > current.num) {
maxPerSeries.set(key, { id: e.id, num: e.voucher_number })
}
}
return new Set(Array.from(maxPerSeries.values()).map(v => v.id))
}, [entries])
const handleDeleteEntry = useCallback(async () => {
if (!deleteEntry) return
setIsDeleting(true)
try {
const res = await fetch(`/api/bookkeeping/journal-entries/${deleteEntry.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.`,
})
setDeleteEntry(null)
fetchEntries()
} else {
toast({ title: 'Kunde inte radera', description: result.error, variant: 'destructive' })
}
} catch {
toast({ title: 'Kunde inte radera verifikat', variant: 'destructive' })
} finally {
setIsDeleting(false)
}
}, [deleteEntry, toast])
const toggleExpand = (id: string) => {
setExpandedId(expandedId === id ? null : id)
}
@@ -420,6 +462,12 @@ export default function JournalEntryList({ periodId }: Props) {
</>
)}
{entry.notes && (
<p className="mt-3 text-xs text-muted-foreground italic px-1">
{entry.notes}
</p>
)}
<JournalEntryAttachments
journalEntryId={entry.id}
onCountChange={(c) => handleAttachmentCountChange(entry.id, c)}
@@ -429,6 +477,16 @@ export default function JournalEntryList({ periodId }: Props) {
<Button variant="outline" size="sm" className="w-full sm:w-auto" asChild>
<Link href={`/bookkeeping/${entry.id}`}>Visa detaljer</Link>
</Button>
{entry.status === 'posted' && lastInSeriesIds.has(entry.id) && (
<Button
variant="destructive"
size="sm"
className="w-full sm:w-auto"
onClick={() => setDeleteEntry(entry)}
>
Radera
</Button>
)}
{entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && (
<Button
variant="outline"
@@ -457,6 +515,28 @@ export default function JournalEntryList({ periodId }: Props) {
/>
)}
{/* Delete confirmation dialog */}
<ConfirmationDialog
open={!!deleteEntry}
onOpenChange={(open) => { if (!open) setDeleteEntry(null) }}
onConfirm={handleDeleteEntry}
isSubmitting={isDeleting}
title="Radera verifikat"
warningText={deleteEntry ? `Verifikat ${deleteEntry.voucher_series}${deleteEntry.voucher_number} raderas permanent. Denna åtgärd kan inte ångras.` : ''}
confirmLabel="Radera permanent"
>
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/10 p-4">
<AlertTriangle className="h-5 w-5 text-destructive mt-0.5 shrink-0" />
<div className="text-sm">
<p className="font-medium mb-1">Permanent radering</p>
<p className="text-muted-foreground">
Verifikatet och dess kontorader tas bort. Kopplade transaktioner och
fakturor markeras som ej bokförda. Underlag behålls men avlänkas.
</p>
</div>
</div>
</ConfirmationDialog>
{/* Pagination */}
{count > pageSize && (
<div className="flex justify-center gap-2">
@@ -15,6 +15,7 @@ interface JournalEntryReviewContentProps {
periodName: string
entryDate: string
description: string
notes?: string
voucherSeries?: string
lines: ReviewLine[]
totalDebit: number
@@ -32,6 +33,7 @@ export function JournalEntryReviewContent({
periodName,
entryDate,
description,
notes,
voucherSeries,
lines,
totalDebit,
@@ -70,6 +72,12 @@ export function JournalEntryReviewContent({
<span className="text-muted-foreground">Beskrivning</span>
<p className="font-medium">{description}</p>
</div>
{notes && (
<div className="text-sm">
<span className="text-muted-foreground">Intern anteckning</span>
<p className="text-muted-foreground italic">{notes}</p>
</div>
)}
</div>
{/* Balance status */}
+1 -1
View File
@@ -66,7 +66,7 @@ const navItems: NavItem[] = [
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'försäljning' },
{ href: '/customers', label: 'Kunder', icon: Users, group: 'försäljning' },
// AP — Accounts Payable
{ href: '/expenses', label: 'Utgifter', icon: Wallet, group: 'inköp' },
{ href: '/expenses', label: 'Leverantörsfakturor', icon: Wallet, group: 'inköp' },
// Temporarily hidden pending module rework (see feedback #49)
{ href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'inköp', hidden: true },
{ href: '/supplier-invoices', label: 'Leverantörsfakturor', icon: FileInput, group: 'inköp', hidden: true },
+1 -1
View File
@@ -128,7 +128,7 @@ export default function BankFileConfirmStep({
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-xs">Utgifter</span>
<span className="text-xs">Leverantörsfakturor</span>
</div>
<p className="text-xl font-display font-medium tabular-nums">
{formatCurrency(stats.total_expenses)}
+1 -1
View File
@@ -85,7 +85,7 @@ export default function BankFilePreviewStep({
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<TrendingDown className="h-4 w-4" />
<span className="text-sm">Utgifter</span>
<span className="text-sm">Leverantörsfakturor</span>
</div>
<p className="text-lg font-display font-medium tabular-nums">
{formatCurrency(stats.total_expenses)}