feat: Haben-clone + client portal privacy fix

Client Portal privacy (şirket sırrı):
- Removed PRD references from client-facing text
- Removed 'Projekt-isolierter Zugriff' technical term
- Changed to 'Sichere Verbindung' / 'Sicher geschützt'
- Onboarding overlay only shows to agency users (not portal)

Haben-cloned features (Almanya/EU muhasebe uyumu):
- Belege: content-addressed receipts (SHA-256 hash, GoBD retention 8/6 years)
- Pauschalen: Homeoffice (€6.92/day), Kilometer (€0.30/km), Verpflegung (€14/€24/€28)
  - EStG § 4 Abs. 5, § 9 Abs. 1 compliant rates (2026)
- Anlagen & AfA: Anlagenverzeichnis with linear depreciation
  - GWG (≤€800 Sofortabschreibung, § 6 Abs. 2 EStG)
  - Sammelposten (€251-€1000, § 6 Abs. 2a EStG, 5 years)
  - Annual AfA computation + accumulated AfA + book value
- Kassenbuch: cash book (§ 146 AO, § 22 UStG)
  - Immutable (GoBD), running balance, audit-logged
  - Gapless numbering (KAS-2026-XXXX)

Seed data: 5 Belege, 7 Pauschalen, 5 Anlagen, 5 Kassenbuch entries
TypeScript: 0 errors | ESLint: clean
This commit is contained in:
Z User committed 2026-10-27 17:40:47 +00:00
1 parent 3cb7532417
commit 1573931c0f
13 files changed
+2234 -5

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/anlagen — list asset register with AfA
export async function GET() {
try {
const anlagen = await db.anlage.findMany({ orderBy: { purchaseDate: 'desc' } })
const stats = {
total: anlagen.length,
totalPurchaseCents: anlagen.reduce((s, a) => s + a.purchaseCostCents, 0),
totalBookValueCents: anlagen.reduce((s, a) => s + a.bookValueCents, 0),
totalAnnualAfaCents: anlagen.reduce((s, a) => s + a.annualAfaCents, 0),
gwgCount: anlagen.filter((a) => a.isGWG).length,
sammelpostenCount: anlagen.filter((a) => a.isSammelposten).length,
activeCount: anlagen.filter((a) => a.status === 'active').length,
}
return NextResponse.json({ anlagen, stats })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// POST /api/aos/anlagen — create an asset with AfA computation
export async function POST(req: Request) {
try {
const { name, category, purchaseDate, purchaseCostCents, usefulLifeYears, afaMethod } = (await req.json()) as {
name: string; category: string; purchaseDate: string; purchaseCostCents: number
usefulLifeYears?: number; afaMethod?: string
}
if (!name?.trim()) return NextResponse.json({ error: 'name required' }, { status: 400 })
if (!purchaseCostCents || purchaseCostCents <= 0) return NextResponse.json({ error: 'purchaseCostCents must be > 0' }, { status: 400 })
const cost = Math.round(purchaseCostCents)
const years = usefulLifeYears ?? 3
// GWG (Geringwertiger Wirtschaftsgüter): ≤ €800 net → Sofortabschreibung (§ 6 Abs. 2 EStG)
const isGWG = cost <= 80000 // €800.00 in cents
// Sammelposten: €251-€1000 net → Sammelabschreibung über 5 Jahre (§ 6 Abs. 2a EStG)
const isSammelposten = !isGWG && cost > 80000 && cost <= 100000
// Compute AfA
let annualAfaCents = 0
let bookValueCents = cost
if (isGWG) {
// Sofortabschreibung: full cost in year 1
annualAfaCents = cost
bookValueCents = 0
} else if (isSammelposten) {
// Sammelabschreibung: 1/5 per year over 5 years
annualAfaCents = Math.round(cost / 5)
bookValueCents = cost - annualAfaCents // after year 1
} else {
// Lineare AfA: cost / usefulLifeYears
annualAfaCents = Math.round(cost / years)
bookValueCents = cost - annualAfaCents // after year 1
}
const count = await db.anlage.count()
const number = `ANL-2026-${String(count + 1).padStart(4, '0')}`
const anlage = await db.anlage.create({
data: {
number, name: name.trim(), category: category || 'hardware',
purchaseDate: new Date(purchaseDate),
purchaseCostCents: cost,
afaMethod: afaMethod ?? 'linear',
usefulLifeYears: isGWG ? 1 : isSammelposten ? 5 : years,
isGWG, isSammelposten,
annualAfaCents,
accumulatedAfaCents: annualAfaCents, // year 1
bookValueCents,
},
})
await db.auditLog.create({
data: {
actor: 'nora@nordlicht.digital', action: 'anlage.create', entity: 'Anlage', entityId: anlage.id,
after: JSON.stringify({ number, name, cost, isGWG, annualAfaCents, bookValueCents }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ anlage })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+70
View File
@@ -0,0 +1,70 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { createHash } from 'crypto'
export const dynamic = 'force-dynamic'
// GET /api/aos/belege — list receipts/documents
export async function GET() {
try {
const belege = await db.beleg.findMany({ orderBy: { createdAt: 'desc' } })
const stats = {
total: belege.length,
totalAmountCents: belege.reduce((s, b) => s + (b.amountCents ?? 0), 0),
byType: belege.reduce((acc, b) => { acc[b.type] = (acc[b.type] ?? 0) + 1; return acc }, {} as Record<string, number>),
}
return NextResponse.json({ belege, stats })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// POST /api/aos/belege — register a receipt (content-addressed)
export async function POST(req: Request) {
try {
const { fileName, fileType, fileSizeBytes, fileContent, type, vendor, invoiceNumber, amountCents, bookingDate } = (await req.json()) as {
fileName: string; fileType: string; fileSizeBytes: number; fileContent: string // base64
type: string; vendor?: string; invoiceNumber?: string; amountCents?: number; bookingDate?: string
}
if (!fileName?.trim()) return NextResponse.json({ error: 'fileName required' }, { status: 400 })
// Compute SHA-256 hash of file content (content-addressed storage)
const fileHash = createHash('sha256').update(fileContent ?? fileName).digest('hex')
// Check for duplicate (same file hash)
const existing = await db.beleg.findUnique({ where: { fileHash } })
if (existing) return NextResponse.json({ error: 'duplicate file (same SHA-256 hash)', beleg: existing }, { status: 409 })
const count = await db.beleg.count()
const number = `BEL-2026-${String(count + 1).padStart(4, '0')}`
// GoBD retention: 8 years for invoices, 6 for business letters
const retentionUntil = new Date()
retentionUntil.setFullYear(retentionUntil.getFullYear() + (type.includes('rechnung') ? 8 : 6))
const beleg = await db.beleg.create({
data: {
number, type, fileHash, fileName, fileType, fileSizeBytes,
vendor: vendor ?? null, invoiceNumber: invoiceNumber ?? null,
amountCents: amountCents ?? null,
bookingDate: bookingDate ? new Date(bookingDate) : null,
retentionUntil,
},
})
await db.auditLog.create({
data: {
actor: 'nora@nordlicht.digital', action: 'beleg.create', entity: 'Beleg', entityId: beleg.id,
after: JSON.stringify({ number, type, fileHash: fileHash.slice(0, 16), amountCents }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ beleg })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+72
View File
@@ -0,0 +1,72 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/kassenbuch — list cash book entries (GoBD: unveränderlich)
export async function GET() {
try {
const entries = await db.kassenbuchEntry.findMany({ orderBy: { date: 'desc' } })
const totalIn = entries.filter((e) => e.type === 'einnahme').reduce((s, e) => s + e.amountCents, 0)
const totalOut = entries.filter((e) => e.type === 'ausgabe').reduce((s, e) => s + Math.abs(e.amountCents), 0)
const balance = entries[0]?.balanceCents ?? 0 // latest entry has current balance
return NextResponse.json({
entries,
stats: {
total: entries.length,
totalInCents: totalIn,
totalOutCents: totalOut,
balanceCents: balance,
taxFreeCount: entries.filter((e) => e.taxFree).length,
},
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// POST /api/aos/kassenbuch — add a cash book entry (§ 146 AO, § 22 UStG)
export async function POST(req: Request) {
try {
const { date, type, description, amountCents, taxBp, taxFree, belegId } = (await req.json()) as {
date: string; type: 'einnahme' | 'ausgabe'; description: string; amountCents: number
taxBp?: number; taxFree?: boolean; belegId?: string
}
if (!description?.trim()) return NextResponse.json({ error: 'description required' }, { status: 400 })
if (!amountCents || amountCents <= 0) return NextResponse.json({ error: 'amountCents must be > 0' }, { status: 400 })
if (!['einnahme', 'ausgabe'].includes(type)) return NextResponse.json({ error: 'type must be einnahme|ausgabe' }, { status: 400 })
// Compute running balance
const lastEntry = await db.kassenbuchEntry.findFirst({ orderBy: { date: 'desc' } })
const signedAmount = type === 'einnahme' ? amountCents : -amountCents
const newBalance = (lastEntry?.balanceCents ?? 0) + signedAmount
const count = await db.kassenbuchEntry.count()
const number = `KAS-2026-${String(count + 1).padStart(4, '0')}`
const entry = await db.kassenbuchEntry.create({
data: {
number, date: new Date(date), type, description: description.trim(),
amountCents: signedAmount, taxBp: taxBp ?? null, taxFree: taxFree ?? false,
belegId: belegId ?? null, balanceCents: newBalance,
},
})
// GoBD: Kassenbuch is immutable — audit log every entry
await db.auditLog.create({
data: {
actor: 'nora@nordlicht.digital', action: 'kassenbuch.create', entity: 'KassenbuchEntry', entityId: entry.id,
after: JSON.stringify({ number, type, amountCents: signedAmount, balanceCents: newBalance }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ entry })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+110
View File
@@ -0,0 +1,110 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
const CENTS = (e: number) => Math.round(e * 100)
// GET /api/aos/pauschalen — list flat-rate allowances
export async function GET() {
try {
const pauschalen = await db.pauschale.findMany({ orderBy: { date: 'desc' } })
const stats = {
total: pauschalen.length,
totalAmountCents: pauschalen.reduce((s, p) => s + p.amountCents, 0),
homeoffice: pauschalen.filter((p) => p.type === 'homeoffice').length,
kilometer: pauschalen.filter((p) => p.type === 'kilometer').length,
verpflegung: pauschalen.filter((p) => p.type === 'verpflegung').length,
}
return NextResponse.json({ pauschalen, stats, rates: getRates() })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// POST /api/aos/pauschalen — create a pauschale (auto-computes amount)
export async function POST(req: Request) {
try {
const body = (await req.json()) as {
type: 'homeoffice' | 'kilometer' | 'verpflegung'
date: string
userId?: string
days?: number
km?: number
vehicleType?: string
tripPurpose?: string
hoursAway?: number
overnight?: boolean
description?: string
projectId?: string
}
if (!body.type || !body.date) return NextResponse.json({ error: 'type + date required' }, { status: 400 })
// Auto-compute amount based on German tax law (2026 rates)
const rates = getRates()
let amountCents = 0
if (body.type === 'homeoffice') {
const days = body.days ?? 1
amountCents = rates.homeofficePerDay * days
} else if (body.type === 'kilometer') {
const km = body.km ?? 0
const rate = body.vehicleType === 'auto' ? rates.kilometerAuto : rates.kilometerBike
amountCents = rate * km
} else if (body.type === 'verpflegung') {
const hours = body.hoursAway ?? 0
if (body.overnight) {
amountCents = rates.verpflegungOvernight
} else if (hours >= 14) {
amountCents = rates.verpflegungOver14h
} else if (hours >= 8) {
amountCents = rates.verpflegung8to14h
}
}
const pauschale = await db.pauschale.create({
data: {
type: body.type,
date: new Date(body.date),
userId: body.userId ?? null,
amountCents,
days: body.days ?? null,
km: body.km ?? null,
vehicleType: body.vehicleType ?? null,
tripPurpose: body.tripPurpose ?? null,
hoursAway: body.hoursAway ?? null,
overnight: body.overnight ?? false,
description: body.description ?? null,
projectId: body.projectId ?? null,
},
})
await db.auditLog.create({
data: {
actor: 'max@nordlicht.digital', action: 'pauschale.create', entity: 'Pauschale', entityId: pauschale.id,
after: JSON.stringify({ type: body.type, amountCents }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ pauschale })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// German tax rates 2026 (EStG § 4 Abs. 5, § 9 Abs. 1)
function getRates() {
return {
homeofficePerDay: CENTS(6.92), // 2026: €6.92/day (up from €4.17), max €1.260/year
homeofficeMaxYear: CENTS(1260),
kilometerAuto: CENTS(0.30), // €0.30/km (car, up to 5000km → €0.38/km for carpooling)
kilometerBike: CENTS(0.30), // €0.30/km (bicycle, § 9 EStG)
verpflegung8to14h: CENTS(14), // €14 for 8-14h away
verpflegungOver14h: CENTS(24), // €24 for >14h same day
verpflegungOvernight: CENTS(28), // €28 for overnight (additional)
}
}
+4
View File
@@ -23,6 +23,10 @@ export async function POST() {
await db.portalApproval.deleteMany()
await db.article.deleteMany()
await db.taskComment.deleteMany()
await db.kassenbuchEntry.deleteMany()
await db.anlage.deleteMany()
await db.pauschale.deleteMany()
await db.beleg.deleteMany()
await db.estimateLine.deleteMany()
await db.estimate.deleteMany()
await db.ticketMessage.deleteMany()