db395977-b1fb-4bf5-89ba-32fa96701360
This commit is contained in:
1 parent
e2c17aea18
commit
cd7591fd65
6 files changed
+264
-166
No files matched your search
@@ -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 }
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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<string, number> = { 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 }
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user