diff --git a/src/app/api/aos/expenses/[id]/route.ts b/src/app/api/aos/expenses/[id]/route.ts index b39d3fe..7348757 100755 --- a/src/app/api/aos/expenses/[id]/route.ts +++ b/src/app/api/aos/expenses/[id]/route.ts @@ -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: -// - DATEV-exported expenses cannot be deleted (GoBD lock). Returns 409. -// - Expenses already converted to an invoice cannot be deleted. Returns 409. -// - Otherwise hard delete — expenses are typically just bookkeeping drafts -// and don't have downstream FKs. -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( - { error: 'expense is DATEV-exported and cannot be deleted (GoBD)' }, - { status: 409 } - ) - } - 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), - }, - }) - - 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 }) - } +// Expenses are accounting records. Per GoBD they may be STORNO'd (reversed) +// but never deleted. Use the dedicated storno flow at +// PATCH /api/aos/expenses/[id]/storno instead — it sets status='rejected', +// records a storno reason, and writes an AuditLog entry. The expense row is +// retained indefinitely. DATEV-exported expenses are fully locked. +export async function DELETE() { + return NextResponse.json( + { + error: 'Ausgaben können nicht gelöscht werden (GoBD). Bitte über „Storno“ stornieren.', + useInstead: 'PATCH /api/aos/expenses/[id]/storno', + }, + { status: 405 } + ) } diff --git a/src/app/api/aos/expenses/[id]/storno/route.ts b/src/app/api/aos/expenses/[id]/storno/route.ts new file mode 100644 index 0000000..a294431 --- /dev/null +++ b/src/app/api/aos/expenses/[id]/storno/route.ts @@ -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 }) + } +} diff --git a/src/app/api/aos/licenses/[id]/route.ts b/src/app/api/aos/licenses/[id]/route.ts index a8430a8..0846e8e 100755 --- a/src/app/api/aos/licenses/[id]/route.ts +++ b/src/app/api/aos/licenses/[id]/route.ts @@ -1,84 +1,20 @@ import { NextResponse } from 'next/server' -import { db } from '@/lib/db' 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: -// - it has NO activations, AND -// - it has NO linked instance (License 1:1 Instance), AND -// - it has NO child module licenses (parentId === this id), AND -// - it is in status 'cancelled' (cancelled licenses that have reached their -// effective date are safe to remove from active bookkeeping). -// -// 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 = { activations, instances, modules } - const totalDeps = Object.values(dependencies).reduce((s, n) => s + n, 0) - - if (totalDeps > 0) { - return NextResponse.json( - { - error: 'license has related records — cancel the license instead of deleting it', - dependencies, - total: totalDeps, - }, - { status: 409 } - ) - } - - // 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 }) - } +// Licenses carry billing/subscription data and are accounting records. +// Per GoBD they may be CANCELLED but never deleted. Use the dedicated +// cancel flow at PATCH /api/aos/licenses/[id]/cancel instead — it sets +// status='cancelled', records cancellationEffectiveAt, and writes an +// AuditLog entry. The license row is retained indefinitely. +export async function DELETE() { + return NextResponse.json( + { + error: 'Lizenzen können nicht gelöscht werden (GoBD). Bitte über „Kündigen“ stornieren.', + useInstead: 'PATCH /api/aos/licenses/[id]/cancel', + }, + { status: 405 } + ) } diff --git a/src/components/aos/crud/delete-dialog.tsx b/src/components/aos/crud/delete-dialog.tsx index 29b317b..f147109 100755 --- a/src/components/aos/crud/delete-dialog.tsx +++ b/src/components/aos/crud/delete-dialog.tsx @@ -37,7 +37,10 @@ interface DeleteDialogProps { // still has related records that block deletion — we render that list // 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({ open, onOpenChange, diff --git a/src/components/aos/views/expenses.tsx b/src/components/aos/views/expenses.tsx index 326af27..d76b410 100755 --- a/src/components/aos/views/expenses.tsx +++ b/src/components/aos/views/expenses.tsx @@ -11,7 +11,7 @@ import { Receipt, TrendingUp, Pencil, - Trash2, + Ban, } from 'lucide-react' import { useFetch } from '@/hooks/use-fetch' 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 { Skeleton } from '@/components/ui/skeleton' 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 { formatEuro, formatEuroCompact, formatDate, daysUntil } from '@/lib/format' import { cn } from '@/lib/utils' import { toast } from 'sonner' import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Cell } from 'recharts' import { ExpenseFormDialog, type ExpenseFormValue } from '../crud/expense-form-dialog' -import { DeleteDialog } from '../crud/delete-dialog' interface Expense { id: string @@ -87,7 +96,7 @@ export function ExpensesView() { const [converting, setConverting] = useState(null) const [dialogOpen, setDialogOpen] = useState(false) const [editTarget, setEditTarget] = useState(null) - const [deleteTarget, setDeleteTarget] = useState(null) + const [stornoTarget, setStornoTarget] = useState(null) // Companies + projects needed for the create/edit dialog (project linking). 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 canConvert = e.billable && e.status !== 'invoiced' && e.companyName 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 ( {e.number} @@ -258,14 +267,14 @@ export function ExpensesView() { )} - {canDelete && ( + {canStorno && ( )} @@ -370,20 +379,115 @@ export function ExpensesView() { onSaved={refetch} /> - {deleteTarget && ( - !o && setDeleteTarget(null)} - entityLabel="Ausgabe" - entityName={deleteTarget.description} - endpoint={`/api/aos/expenses/${deleteTarget.id}`} - onDeleted={refetch} - /> - )} + !o && setStornoTarget(null)} + onDone={refetch} + /> ) } +// --------------------------------------------------------------------------- +// 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 ( + { + if (!o) reset() + onOpenChange(o) + }} + > + + + + + Ausgabe stornieren + + + {expense?.number} — {expense?.description} + + +
+ +