db395977-b1fb-4bf5-89ba-32fa96701360

This commit is contained in:
Z User committed 2026-10-29 23:22:03 +00:00
1 parent e2c17aea18
commit cd7591fd65
6 files changed
+257 -159

No files matched your search

+12 -47
View File
@@ -92,54 +92,19 @@ export async function PATCH(
} }
} }
// DELETE /api/aos/expenses/[id] — delete an expense. // DELETE /api/aos/expenses/[id] — DISABLED (GoBD).
// //
// Cascade policy: // Expenses are accounting records. Per GoBD they may be STORNO'd (reversed)
// - DATEV-exported expenses cannot be deleted (GoBD lock). Returns 409. // but never deleted. Use the dedicated storno flow at
// - Expenses already converted to an invoice cannot be deleted. Returns 409. // PATCH /api/aos/expenses/[id]/storno instead — it sets status='rejected',
// - Otherwise hard delete — expenses are typically just bookkeeping drafts // records a storno reason, and writes an AuditLog entry. The expense row is
// and don't have downstream FKs. // retained indefinitely. DATEV-exported expenses are fully locked.
export async function DELETE( export async function DELETE() {
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const existing = await db.expense.findUnique({ where: { id } })
if (!existing) {
return NextResponse.json({ error: 'expense not found' }, { status: 404 })
}
if (existing.datevExported) {
return NextResponse.json( return NextResponse.json(
{ error: 'expense is DATEV-exported and cannot be deleted (GoBD)' }, {
{ status: 409 } error: 'Ausgaben können nicht gelöscht werden (GoBD). Bitte über „Storno“ stornieren.',
) useInstead: 'PATCH /api/aos/expenses/[id]/storno',
}
if (existing.status === 'invoiced' || existing.convertedInvoiceId) {
return NextResponse.json(
{ error: 'expense already converted to an invoice — cancel the invoice first' },
{ status: 409 }
)
}
await db.expense.delete({ where: { id } })
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'expense:delete',
entity: 'Expense',
entityId: id,
before: JSON.stringify(existing),
after: JSON.stringify({ deleted: true }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
}, },
}) { status: 405 }
)
return NextResponse.json({ ok: true, deleted: true, id })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
} }
@@ -0,0 +1,107 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
const stornoSchema = z.object({
reason: z.string().min(1).max(500),
})
// PATCH /api/aos/expenses/[id]/storno — Storno (GoBD reversal).
//
// Expenses are accounting records: they may be STORNO'd (reversed) but never
// deleted. Storno sets status='rejected' and records the reason + timestamp.
// The original expense row is retained indefinitely for GoBD retention.
//
// Guard rails:
// - DATEV-exported expenses are fully locked (immutable, § 146 AO). 409.
// - Expenses already converted to an invoice cannot be storno'd directly —
// the invoice must be storno'd instead (the booking flows from there). 409.
// - Already-rejected expenses return 409 (idempotent: no double-storno).
//
// Audit: writes an AuditLog entry with action='expense.storno' capturing
// before/after + reason. This is the durable record of the reversal.
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const parsed = stornoSchema.safeParse(await req.json().catch(() => ({})))
if (!parsed.success) {
return NextResponse.json(
{ error: 'Storno-Grund erforderlich', details: parsed.error.flatten() },
{ status: 400 }
)
}
const { reason } = parsed.data
const existing = await db.expense.findUnique({ where: { id } })
if (!existing) {
return NextResponse.json({ error: 'expense not found' }, { status: 404 })
}
// GoBD lock: DATEV-exported records are immutable.
if (existing.datevExported) {
return NextResponse.json(
{ error: 'Ausgabe ist DATEV-exportiert und gesperrt (GoBD § 146 AO) — Storno nicht möglich.' },
{ status: 409 }
)
}
// If already invoiced, the booking lives on the invoice — storno the invoice.
if (existing.status === 'invoiced' || existing.convertedInvoiceId) {
return NextResponse.json(
{ error: 'Ausgabe bereits in eine Rechnung umgewandelt — bitte die Rechnung stornieren.' },
{ status: 409 }
)
}
// Idempotent: already storno'd.
if (existing.status === 'rejected') {
return NextResponse.json(
{ error: 'Ausgabe ist bereits storniert.' },
{ status: 409 }
)
}
const now = new Date()
const updated = await db.expense.update({
where: { id },
data: { status: 'rejected' },
})
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'expense.storno',
entity: 'Expense',
entityId: id,
before: JSON.stringify({
status: existing.status,
description: existing.description,
amountCents: existing.amountCents,
vendor: existing.vendor,
}),
after: JSON.stringify({
status: 'rejected',
stornoReason: reason,
stornoAt: now,
}),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({
ok: true,
expense: updated,
stornoReason: reason,
stornoAt: now,
message: 'Ausgabe storniert (GoBD). Datensatz bleibt für Aufbewahrung erhalten.',
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+10 -74
View File
@@ -1,84 +1,20 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
// DELETE /api/aos/licenses/[id] — hard-delete a license. // DELETE /api/aos/licenses/[id] — DISABLED (GoBD).
// //
// Cascade policy: a license may only be hard-deleted when: // Licenses carry billing/subscription data and are accounting records.
// - it has NO activations, AND // Per GoBD they may be CANCELLED but never deleted. Use the dedicated
// - it has NO linked instance (License 1:1 Instance), AND // cancel flow at PATCH /api/aos/licenses/[id]/cancel instead — it sets
// - it has NO child module licenses (parentId === this id), AND // status='cancelled', records cancellationEffectiveAt, and writes an
// - it is in status 'cancelled' (cancelled licenses that have reached their // AuditLog entry. The license row is retained indefinitely.
// effective date are safe to remove from active bookkeeping). export async function DELETE() {
//
// Otherwise the call returns 409 with the dependency counts.
//
// GoBD: if the license has been issued, activated, or used to bill a
// customer, do NOT delete — cancel (cancel/route.ts) keeps the audit record.
// This endpoint exists for genuine cleanup (e.g. demo licenses that never
// started a subscription, or test fixtures).
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const existing = await db.license.findUnique({ where: { id } })
if (!existing) {
return NextResponse.json({ error: 'license not found' }, { status: 404 })
}
const [activations, instances, modules] = await Promise.all([
db.licenseActivation.count({ where: { licenseId: id } }),
db.instance.count({ where: { licenseId: id } }),
db.license.count({ where: { parentId: id } }),
])
const dependencies: Record<string, number> = { activations, instances, modules }
const totalDeps = Object.values(dependencies).reduce((s, n) => s + n, 0)
if (totalDeps > 0) {
return NextResponse.json( return NextResponse.json(
{ {
error: 'license has related records — cancel the license instead of deleting it', error: 'Lizenzen können nicht gelöscht werden (GoBD). Bitte über „Kündigen“ stornieren.',
dependencies, useInstead: 'PATCH /api/aos/licenses/[id]/cancel',
total: totalDeps,
}, },
{ status: 409 } { status: 405 }
) )
} }
// Only allow deletion when the license is already cancelled or never started.
// Active/trial/suspended licenses must go through the cancel flow.
if (
existing.status !== 'cancelled' &&
existing.status !== 'revoked' &&
existing.status !== 'expired'
) {
return NextResponse.json(
{ error: `license is ${existing.status} — cancel before deleting (GoBD retention)` },
{ status: 409 }
)
}
await db.license.delete({ where: { id } })
await db.auditLog.create({
data: {
actor: 'admin:ui',
action: 'license:delete',
entity: 'License',
entityId: id,
before: JSON.stringify({ key: existing.key, status: existing.status, product: existing.product }),
after: JSON.stringify({ deleted: true }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ ok: true, deleted: true, id })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+4 -1
View File
@@ -37,7 +37,10 @@ interface DeleteDialogProps {
// still has related records that block deletion — we render that list // still has related records that block deletion — we render that list
// inside the dialog so the user knows exactly what to clean up first. // inside the dialog so the user knows exactly what to clean up first.
// //
// GoBD: callers must NOT use this for invoices (no DELETE endpoint exists). // GoBD RULE — accounting entities must NOT use this dialog:
// Invoices, Expenses, Licenses, Kassenbuch, Belege, Buchungssätze.
// Those are cancel/storno-only (retained indefinitely). Only non-accounting
// entities (Company, Deal, Project, Ticket, Instance, Repo, …) may hard-delete.
export function DeleteDialog({ export function DeleteDialog({
open, open,
onOpenChange, onOpenChange,
+121 -17
View File
@@ -11,7 +11,7 @@ import {
Receipt, Receipt,
TrendingUp, TrendingUp,
Pencil, Pencil,
Trash2, Ban,
} from 'lucide-react' } from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch' import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -19,13 +19,22 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { PageHeader } from '../primitives' import { PageHeader } from '../primitives'
import { formatEuro, formatEuroCompact, formatDate, daysUntil } from '@/lib/format' import { formatEuro, formatEuroCompact, formatDate, daysUntil } from '@/lib/format'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Cell } from 'recharts' import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Cell } from 'recharts'
import { ExpenseFormDialog, type ExpenseFormValue } from '../crud/expense-form-dialog' import { ExpenseFormDialog, type ExpenseFormValue } from '../crud/expense-form-dialog'
import { DeleteDialog } from '../crud/delete-dialog'
interface Expense { interface Expense {
id: string id: string
@@ -87,7 +96,7 @@ export function ExpensesView() {
const [converting, setConverting] = useState<string | null>(null) const [converting, setConverting] = useState<string | null>(null)
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [editTarget, setEditTarget] = useState<ExpenseFormValue | null>(null) const [editTarget, setEditTarget] = useState<ExpenseFormValue | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Expense | null>(null) const [stornoTarget, setStornoTarget] = useState<Expense | null>(null)
// Companies + projects needed for the create/edit dialog (project linking). // Companies + projects needed for the create/edit dialog (project linking).
const { data: crmData } = useFetch<{ companies: { id: string; name: string }[] } | null>('/api/aos/crm') const { data: crmData } = useFetch<{ companies: { id: string; name: string }[] } | null>('/api/aos/crm')
@@ -207,7 +216,7 @@ export function ExpensesView() {
const st = STATUS_META[e.status] ?? STATUS_META.recorded const st = STATUS_META[e.status] ?? STATUS_META.recorded
const canConvert = e.billable && e.status !== 'invoiced' && e.companyName const canConvert = e.billable && e.status !== 'invoiced' && e.companyName
const canEdit = !e.datevExported && e.status !== 'invoiced' const canEdit = !e.datevExported && e.status !== 'invoiced'
const canDelete = !e.datevExported && e.status !== 'invoiced' const canStorno = !e.datevExported && e.status !== 'invoiced' && e.status !== 'rejected'
return ( return (
<tr key={e.id} className="group transition hover:bg-muted/40"> <tr key={e.id} className="group transition hover:bg-muted/40">
<td className="px-4 py-2.5"><code className="font-mono text-[11px]">{e.number}</code></td> <td className="px-4 py-2.5"><code className="font-mono text-[11px]">{e.number}</code></td>
@@ -258,14 +267,14 @@ export function ExpensesView() {
<Pencil className="size-3.5" /> <Pencil className="size-3.5" />
</button> </button>
)} )}
{canDelete && ( {canStorno && (
<button <button
type="button" type="button"
onClick={() => setDeleteTarget(e)} onClick={() => setStornoTarget(e)}
className="rounded p-1 text-muted-foreground opacity-0 transition group-hover:opacity-100 hover:bg-muted hover:text-foreground" className="rounded p-1 text-muted-foreground opacity-0 transition group-hover:opacity-100 hover:bg-muted hover:text-foreground"
title="Löschen" title="Storno (GoBD)"
> >
<Trash2 className="size-3.5" /> <Ban className="size-3.5" />
</button> </button>
)} )}
</div> </div>
@@ -370,20 +379,115 @@ export function ExpensesView() {
onSaved={refetch} onSaved={refetch}
/> />
{deleteTarget && ( <StornoDialog
<DeleteDialog expense={stornoTarget}
open={!!deleteTarget} onOpenChange={(o) => !o && setStornoTarget(null)}
onOpenChange={(o) => !o && setDeleteTarget(null)} onDone={refetch}
entityLabel="Ausgabe"
entityName={deleteTarget.description}
endpoint={`/api/aos/expenses/${deleteTarget.id}`}
onDeleted={refetch}
/> />
)}
</div> </div>
) )
} }
// ---------------------------------------------------------------------------
// StornoDialog — GoBD reversal of an expense (cancel, not delete).
// Calls PATCH /api/aos/expenses/[id]/storno with a reason. The expense row is
// retained (status='rejected'); an AuditLog entry is the durable record.
// ---------------------------------------------------------------------------
function StornoDialog({
expense,
onOpenChange,
onDone,
}: {
expense: Expense | null
onOpenChange: (o: boolean) => void
onDone: () => void
}) {
const [reason, setReason] = useState('')
const [busy, setBusy] = useState(false)
const reset = () => setReason('')
const commit = async () => {
if (!expense) return
if (reason.trim().length < 3) {
toast.error('Bitte einen Storno-Grund angeben (mind. 3 Zeichen).')
return
}
setBusy(true)
const t = toast.loading('Ausgabe wird storniert…')
try {
const r = await fetch(`/api/aos/expenses/${expense.id}/storno`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ reason: reason.trim() }),
})
const res = await r.json()
if (!r.ok) throw new Error(res.error || 'Storno fehlgeschlagen')
toast.success(res.message ?? 'Ausgabe storniert (GoBD)', { id: t, duration: 5000 })
onOpenChange(false)
reset()
onDone()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Storno fehlgeschlagen', { id: t })
} finally {
setBusy(false)
}
}
return (
<Dialog
open={!!expense}
onOpenChange={(o) => {
if (!o) reset()
onOpenChange(o)
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Ban className="size-4 text-muted-foreground" />
Ausgabe stornieren
</DialogTitle>
<DialogDescription>
{expense?.number} — {expense?.description}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="storno-reason" className="text-xs">
Storno-Grund <span className="text-muted-foreground">(GoBD — wird im Audit-Log protokolliert)</span>
</Label>
<Textarea
id="storno-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="z. B. Doppelte Buchung, falscher Betrag, Lieferung storniert…"
rows={3}
disabled={busy}
/>
<p className="text-[11px] text-muted-foreground">
Die Ausgabe wird auf „Abgelehnt“ gesetzt. Der Datensatz bleibt gemäß GoBD erhalten und wird
nicht gelöscht. DATEV-exportierte Ausgaben sind gesperrt.
</p>
</div>
<DialogFooter className="gap-2">
<Button variant="ghost" size="sm" disabled={busy} onClick={() => onOpenChange(false)}>
Abbrechen
</Button>
<Button
size="sm"
disabled={busy || reason.trim().length < 3}
onClick={() => void commit()}
className="gap-1.5 bg-foreground text-background hover:bg-foreground/90"
>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Ban className="size-3.5" />}
Storno
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// Convert an Expense (view shape) into the form's value shape. // Convert an Expense (view shape) into the form's value shape.
function expenseToForm(e: Expense): ExpenseFormValue { function expenseToForm(e: Expense): ExpenseFormValue {
return { return {
+3 -20
View File
@@ -19,7 +19,6 @@ import {
FlaskConical, FlaskConical,
Loader2, Loader2,
CircleDollarSign, CircleDollarSign,
Trash2,
} from 'lucide-react' } from 'lucide-react'
import { useFetch } from '@/hooks/use-fetch' import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -51,7 +50,6 @@ import { PageHeader } from '../primitives'
import { formatEuro, formatEuroCompact, formatDate, formatRelative, daysUntil } from '@/lib/format' import { formatEuro, formatEuroCompact, formatDate, formatRelative, daysUntil } from '@/lib/format'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from 'sonner' import { toast } from 'sonner'
import { DeleteDialog } from '../crud/delete-dialog'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -199,7 +197,6 @@ export function LicensesView() {
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [suspendTarget, setSuspendTarget] = useState<License | null>(null) const [suspendTarget, setSuspendTarget] = useState<License | null>(null)
const [cancelTarget, setCancelTarget] = useState<License | null>(null) const [cancelTarget, setCancelTarget] = useState<License | null>(null)
const [deleteTarget, setDeleteTarget] = useState<License | null>(null)
const [filter, setFilter] = useState<FilterKey>('all') const [filter, setFilter] = useState<FilterKey>('all')
if (loading || !data) { if (loading || !data) {
@@ -353,7 +350,6 @@ export function LicensesView() {
onSuspend={(lic) => setSuspendTarget(lic)} onSuspend={(lic) => setSuspendTarget(lic)}
onCancel={(lic) => setCancelTarget(lic)} onCancel={(lic) => setCancelTarget(lic)}
onPromote={handlePromote} onPromote={handlePromote}
onDelete={(lic) => setDeleteTarget(lic)}
/> />
)) ))
)} )}
@@ -475,17 +471,6 @@ export function LicensesView() {
onOpenChange={(o) => !o && setCancelTarget(null)} onOpenChange={(o) => !o && setCancelTarget(null)}
onDone={refetch} onDone={refetch}
/> />
{deleteTarget && (
<DeleteDialog
open={!!deleteTarget}
onOpenChange={(o) => !o && setDeleteTarget(null)}
entityLabel="Lizenz"
entityName={deleteTarget.key}
endpoint={`/api/aos/licenses/${deleteTarget.id}`}
description="Gekündigte Lizenzen ohne Aktivierungen, Instanzen oder Module können gelöscht werden. Audit-Log wird geschrieben (GoBD)."
onDeleted={refetch}
/>
)}
</div> </div>
) )
} }
@@ -499,13 +484,11 @@ function LicenseRow({
onSuspend, onSuspend,
onCancel, onCancel,
onPromote, onPromote,
onDelete,
}: { }: {
license: License license: License
onSuspend: (l: License) => void onSuspend: (l: License) => void
onCancel: (l: License) => void onCancel: (l: License) => void
onPromote: (l: License) => void onPromote: (l: License) => void
onDelete: (l: License) => void
}) { }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const st = LIC_STATUS[license.status] ?? LIC_STATUS.active const st = LIC_STATUS[license.status] ?? LIC_STATUS.active
@@ -639,9 +622,9 @@ function LicenseRow({
</Button> </Button>
)} )}
{cancelled && ( {cancelled && (
<Button size="sm" variant="ghost" className="h-7 px-2 text-[10px]" onClick={() => onDelete(license)} title="Löschen"> <Badge variant="outline" className="h-7 gap-1 text-[10px] text-muted-foreground" title="GoBD: storniert — Datensatz bleibt erhalten">
<Trash2 className="size-3" /> <Ban className="size-3" />Storniert
</Button> </Badge>
)} )}
</div> </div>
</td> </td>