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:
co-authored by
Claude Opus 4.6
parent
ade4ad5971
commit
7bf7565852
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user