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:
1 parent
3cb7532417
commit
1573931c0f
13 files changed
+2234
-5
No files matched your search
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user