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}
|
||||
|
||||
Reference in New Issue
Block a user