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

+100
View File
@@ -568,6 +568,106 @@ model Article {
updatedAt DateTime @updatedAt
}
// ---------------------------------------------------------------------------
// Haben-cloned features: Belege, Pauschalen, Anlagen/AfA, Kasse
// ---------------------------------------------------------------------------
// Belege (receipts/documents) — content-addressed storage (PRD §5.5)
model Beleg {
id String @id @default(cuid())
number String @unique // gapless, e.g. BEL-2026-0001
type String // rechnung-eingang|rechnung-ausgang|quittung|kassenbeleg|bankbeleg|sonstiges
// content-addressed (SHA-256 hash of the file content)
fileHash String @unique
fileName String
fileType String // application/pdf, image/jpeg, etc.
fileSizeBytes Int
// metadata extracted from the document
vendor String? // Rechnungssteller
invoiceNumber String? // Rechnungsnummer des Lieferanten
amountCents Int? // Bruttobetrag auf dem Beleg
amountNetCents Int? // Netto (falls extrahiert)
taxBp Int? // USt %
bookingDate DateTime?
// linking
expenseId String? // link to Expense if billable
invoiceId String? // link to Invoice if outgoing
// OCR / AI
ocrText String? // extracted text (optional, via Jina/AI)
// GoBD
archivedAt DateTime?
retentionUntil DateTime? // Aufbewahrung bis
createdAt DateTime @default(now())
}
// Pauschalen (flat-rate allowances) — Homeoffice, Kilometer, Verpflegung
model Pauschale {
id String @id @default(cuid())
type String // homeoffice|kilometer|verpflegung
// homeoffice: €4.17/day (2026: €6.92/day up to €1.260/year)
// kilometer: €0.30/km (car), €0.38/km (car w/ pass), or actual
// verpflegung: €14 (8-14h), €24 (>14h, same day), €28 (overnight)
date DateTime
userId String? // employee
amountCents Int // computed amount
// details per type
days Int? // homeoffice: 1; verpflegung: 1
km Int? // kilometer: distance
vehicleType String? // kilometer: auto|rad|fuss
tripPurpose String? // kilometer: geschäftlich|fortbildung|umzug
hoursAway Int? // verpflegung: hours away from office
overnight Boolean @default(false) // verpflegung: overnight stay
description String?
projectId String? // link to project if billable
expenseId String? // link to generated expense
createdAt DateTime @default(now())
}
// Anlagenverzeichnis & AfA (asset register + depreciation)
model Anlage {
id String @id @default(cuid())
number String @unique // e.g. ANL-2026-0001
name String // "MacBook Pro 16"
category String // hardware|software|mobiliar|fahrzeug|immobilie
// acquisition
purchaseDate DateTime
purchaseCostCents Int // Anschaffungskosten (netto)
taxBp Int @default(1900) // USt bei Anschaffung
// AfA (depreciation)
afaMethod String @default("linear") // linear|degressiv
usefulLifeYears Int @default(3) // Nutzungsdauer (AfA-Tabelle)
salvageValueCents Int @default(0) // Restwert
// GWG (Geringwertiger Wirtschaftsgüter ≤ €800 net → Sofortabschreibung)
isGWG Boolean @default(false) // GWG ≤ €800 → immediate expense
// Sammelposten (€251-€1000 → Sammelabschreibung über 5 Jahre)
isSammelposten Boolean @default(false)
// computed
annualAfaCents Int @default(0) // jährliche AfA
accumulatedAfaCents Int @default(0) // kumulierte AfA
bookValueCents Int @default(0) // Buchwert (purchase - accumulated AfA)
// status
status String @default("active") // active|sold|scrapped|fully-depreciated
soldDate DateTime?
soldPriceCents Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Kassenbuch (cash book for Barzahlungen — § 146 AO, § 22 UStG)
model KassenbuchEntry {
id String @id @default(cuid())
number String @unique // gapless, e.g. KAS-2026-0001
date DateTime
type String // einnahme|ausgabe
description String
amountCents Int // signed: + for einnahme, - for ausgabe
taxBp Int? // USt % (if applicable)
taxFree Boolean @default(false) // steuerfrei
// receipt link
belegId String? // link to Beleg (Kassenbeleg)
// balance after this entry (laufender Kassenstand)
balanceCents Int @default(0)
// GoBD: Kassenbuch ist unveränderlich (elektronische Aufzeichnung)
createdAt DateTime @default(now())
}
+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()
+2
View File
@@ -22,6 +22,7 @@ import { ExpensesView } from './views/expenses'
import { FinanceView } from './views/finance'
import { RecurringView } from './views/recurring'
import { AccountingView } from './views/accounting'
import { FinanzamtView } from './views/finanzamt'
import { AuditView } from './views/audit'
import { DataView } from './views/data'
import { KbView } from './views/kb'
@@ -75,6 +76,7 @@ export function AppShell() {
{view === 'finance' && <FinanceView />}
{view === 'recurring' && <RecurringView />}
{view === 'accounting' && <AccountingView />}
{view === 'finanzamt' && <FinanzamtView />}
{view === 'audit' && <AuditView />}
{view === 'data' && <DataView />}
{view === 'kb' && <KbView />}
+5 -5
View File
@@ -131,14 +131,14 @@ function PortalLogin({ onBack, onSelect }: { onBack: () => void; onSelect: (id:
<ArrowLeft className="size-4" />Zur Agentur-Ansicht
</Button>
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<ShieldCheck className="size-3.5" />Client-Portal · projektbezogener Zugriff
<ShieldCheck className="size-3.5" />Sichere Verbindung
</div>
</header>
<main className="mx-auto w-full max-w-3xl flex-1 p-4 md:p-8">
<div className="mb-6 space-y-1.5">
<h1 className="text-2xl font-semibold tracking-tight">Client-Portal</h1>
<p className="text-sm text-muted-foreground">
Wähle dein Unternehmen. Du siehst ausschließlich deine eigenen Projekte, Rechnungen, Tickets und Freigaben — projektbezogen isoliert (PRD §5.6).
Wähle dein Unternehmen. Du siehst ausschließlich deine eigenen Projekte, Rechnungen, Tickets und Freigaben.
</p>
</div>
<div className="mb-4">
@@ -222,7 +222,7 @@ function PortalApp({ companyId, tab, setTab, onExit }: {
<div className="ml-auto flex items-center gap-2">
<PortalNotificationsBell companyId={data.company.id} />
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ShieldCheck className="size-3.5" />Projekt-isolierter Zugriff
<ShieldCheck className="size-3.5" />Sicher geschützt
</div>
</div>
</header>
@@ -281,7 +281,7 @@ function PortalOverview({ data, setTab }: { data: PortalData; setTab: (t: 'overv
<div className="flex-1">
<h2 className="text-lg font-semibold">Willkommen, {data.company.name}</h2>
<p className="text-xs text-muted-foreground">
Du siehst ausschließlich deine eigenen Daten — projektbezogen isoliert (PRD §5.6).
Du siehst ausschließlich deine eigenen Daten — sicher und geschützt.
{data.kpis.pendingApprovals > 0 && <span className="font-medium text-foreground"> {data.kpis.pendingApprovals} Freigabe(n) warten auf dich.</span>}
</p>
</div>
@@ -421,7 +421,7 @@ function PortalApprovals({ data, refetch }: { data: PortalData; refetch: () => v
<CheckCircle2 className="size-5 text-muted-foreground" />
<div>
<h1 className="text-lg font-semibold">Freigaben</h1>
<p className="text-xs text-muted-foreground">Meilenstein-Freigaben mit Zeitstempel- und IP-Evidenz (PRD §6.3).</p>
<p className="text-xs text-muted-foreground">Meilenstein-Freigaben mit Zeitstempel- und IP-Evidenz.</p>
</div>
</div>
+2
View File
@@ -27,6 +27,7 @@ import {
Clock,
Database,
RefreshCw,
Landmark,
} from 'lucide-react'
import { useAos, type ViewId } from '@/store/aos'
import { translate } from '@/lib/i18n'
@@ -71,6 +72,7 @@ const NAV: {
{ id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
{ id: 'recurring', label: 'Wiederkehrend', icon: RefreshCw, permission: 'finance:read' },
{ id: 'accounting', label: 'Buchhaltung', icon: BookOpen, permission: 'accounting:read' },
{ id: 'finanzamt', label: 'Finanzamt', icon: Landmark, permission: 'accounting:read' },
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, permission: '*' },
{ id: 'data', label: 'Datenverwaltung', icon: Database, permission: '*' },
],
+1
View File
@@ -33,6 +33,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
recurring: { title: 'Wiederkehrende Rechnungen', crumb: 'Betrieb / Wiederkehrend' },
accounting: { title: 'Buchhaltung', crumb: 'Betrieb / Buchhaltung' },
finanzamt: { title: 'Finanzamt', crumb: 'Betrieb / Finanzamt' },
audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' },
data: { title: 'Datenverwaltung', crumb: 'Betrieb / Daten' },
kb: { title: 'Wissensdatenbank', crumb: 'Wissen / KB' },
File diff suppressed because it is too large. Load diff
+52
View File
@@ -680,6 +680,58 @@ async function main() {
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'bank.import:camt053', entity: 'BankTransaction', entityId: 'seed', after: '{"count":10,"batch":"camt-2026-10"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'bank.match:invoice', entity: 'BankTransaction', entityId: 'seed', after: '{"invoiceId":"matched","status":"matched"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
// --- Haben-cloned: Belege, Pauschalen, Anlagen/AfA, Kassenbuch -----------
// Belege (receipts)
const belege = [
{ number: 'BEL-2026-0001', type: 'rechnung-eingang', fileName: 'jetbrains-2026.pdf', fileType: 'application/pdf', fileSizeBytes: 124000, fileHash: 'sha256:' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2), vendor: 'JetBrains s.r.o.', invoiceNumber: 'JB-2026-APPPACK', amountCents: CENTS(899), bookingDate: daysFromNow(-8), retentionUntil: daysFromNow(365 * 8) },
{ number: 'BEL-2026-0002', type: 'rechnung-eingang', fileName: 'strato-hosting.pdf', fileType: 'application/pdf', fileSizeBytes: 89000, fileHash: 'sha256:' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2), vendor: 'Strato AG', invoiceNumber: 'STR-2026-Q4', amountCents: CENTS(348), bookingDate: daysFromNow(-10), retentionUntil: daysFromNow(365 * 8) },
{ number: 'BEL-2026-0003', type: 'quittung', fileName: 'dbahn-hamburg-berlin.jpg', fileType: 'image/jpeg', fileSizeBytes: 45000, fileHash: 'sha256:' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2), vendor: 'Deutsche Bahn AG', amountCents: CENTS(142), bookingDate: daysFromNow(-4), retentionUntil: daysFromNow(365 * 6) },
{ number: 'BEL-2026-0004', type: 'kassenbeleg', fileName: 'hotel-luebeck.pdf', fileType: 'application/pdf', fileSizeBytes: 67000, fileHash: 'sha256:' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2), vendor: 'Hotel am Markt', amountCents: CENTS(118), bookingDate: daysFromNow(-22), retentionUntil: daysFromNow(365 * 6) },
{ number: 'BEL-2026-0005', type: 'rechnung-eingang', fileName: 'cloudflare-pro.pdf', fileType: 'application/pdf', fileSizeBytes: 34000, fileHash: 'sha256:' + Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2), vendor: 'Cloudflare Inc.', invoiceNumber: 'CF-2026-5512', amountCents: CENTS(89), bookingDate: daysFromNow(-2), retentionUntil: daysFromNow(365 * 8) },
]
for (const b of belege) {
await db.beleg.create({ data: b })
}
// Pauschalen
const pauschalen = [
{ type: 'homeoffice', date: daysFromNow(-1), amountCents: CENTS(6.92), days: 1, description: 'Homeoffice-Pauschale' },
{ type: 'homeoffice', date: daysFromNow(-2), amountCents: CENTS(6.92), days: 1, description: 'Homeoffice-Pauschale' },
{ type: 'homeoffice', date: daysFromNow(-3), amountCents: CENTS(6.92), days: 1, description: 'Homeoffice-Pauschale' },
{ type: 'kilometer', date: daysFromNow(-4), amountCents: CENTS(42), km: 140, vehicleType: 'auto', tripPurpose: 'geschäftlich', description: 'Hamburg → Berlin (Projekt-Kickoff)' },
{ type: 'kilometer', date: daysFromNow(-22), amountCents: CENTS(35.40), km: 118, vehicleType: 'auto', tripPurpose: 'geschäftlich', description: 'Lübeck Kunden-Termin' },
{ type: 'verpflegung', date: daysFromNow(-4), amountCents: CENTS(24), hoursAway: 16, overnight: false, description: 'Reise Berlin (16h abwesend)' },
{ type: 'verpflegung', date: daysFromNow(-22), amountCents: CENTS(14), hoursAway: 10, overnight: false, description: 'Reise Lübeck (10h abwesend)' },
]
for (const p of pauschalen) {
await db.pauschale.create({ data: p })
}
// Anlagen & AfA
const anlagen = [
{ number: 'ANL-2026-0001', name: 'MacBook Pro 16" M4 Max', category: 'hardware', purchaseDate: daysFromNow(-120), purchaseCostCents: CENTS(3499), usefulLifeYears: 3, isGWG: false, isSammelposten: false, annualAfaCents: CENTS(1166.33), accumulatedAfaCents: CENTS(1166.33), bookValueCents: CENTS(2332.67) },
{ number: 'ANL-2026-0002', name: 'Dell U2723QE Monitor', category: 'hardware', purchaseDate: daysFromNow(-90), purchaseCostCents: CENTS(599), usefulLifeYears: 1, isGWG: true, isSammelposten: false, annualAfaCents: CENTS(599), accumulatedAfaCents: CENTS(599), bookValueCents: 0 },
{ number: 'ANL-2026-0003', name: 'Bürostuhl Herman Miller', category: 'mobiliar', purchaseDate: daysFromNow(-60), purchaseCostCents: CENTS(1450), usefulLifeYears: 10, isGWG: false, isSammelposten: false, annualAfaCents: CENTS(145), accumulatedAfaCents: CENTS(145), bookValueCents: CENTS(1305) },
{ number: 'ANL-2026-0004', name: 'Software-Lizenz JetBrains All Products', category: 'software', purchaseDate: daysFromNow(-30), purchaseCostCents: CENTS(899), usefulLifeYears: 1, isGWG: true, isSammelposten: false, annualAfaCents: CENTS(899), accumulatedAfaCents: CENTS(899), bookValueCents: 0 },
{ number: 'ANL-2026-0005', name: 'Sammelposten Büroausstattung Q3', category: 'mobiliar', purchaseDate: daysFromNow(-45), purchaseCostCents: CENTS(650), usefulLifeYears: 5, isGWG: false, isSammelposten: true, annualAfaCents: CENTS(130), accumulatedAfaCents: CENTS(130), bookValueCents: CENTS(520) },
]
for (const a of anlagen) {
await db.anlage.create({ data: a })
}
// Kassenbuch
const kassenbuch = [
{ number: 'KAS-2026-0001', date: daysFromNow(-10), type: 'einnahme', description: 'Barverkauf Schulungsmaterial', amountCents: CENTS(150), taxBp: 1900, taxFree: false, balanceCents: CENTS(150) },
{ number: 'KAS-2026-0002', date: daysFromNow(-8), type: 'ausgabe', description: 'Büromaterial (Stifte, Notizblöcke)', amountCents: -CENTS(34.50), taxBp: 1900, taxFree: false, balanceCents: CENTS(115.50) },
{ number: 'KAS-2026-0003', date: daysFromNow(-5), type: 'ausgabe', description: 'Kaffee für Büro', amountCents: -CENTS(12.90), taxBp: 700, taxFree: false, balanceCents: CENTS(102.60) },
{ number: 'KAS-2026-0004', date: daysFromNow(-3), type: 'einnahme', description: 'Workshop-Teilnahmegebühr (bar)', amountCents: CENTS(250), taxBp: 1900, taxFree: false, balanceCents: CENTS(352.60) },
{ number: 'KAS-2026-0005', date: daysFromNow(-1), type: 'ausgabe', description: 'Taxi zum Kunden', amountCents: -CENTS(18.40), taxBp: 700, taxFree: false, balanceCents: CENTS(334.20) },
]
for (const k of kassenbuch) {
await db.kassenbuchEntry.create({ data: k })
}
// --- Portal approvals (client milestone approvals with evidence) ---------
const portalApprovals = [
{ project: 'SEO-Optimierung & BFGG-Check', title: 'SEO-Audit & Keyword-Analyse', description: 'Audit-Bericht mit Keyword-Strategie und Wettbewerbsanalyse.', status: 'approved', decidedAt: daysFromNow(-8), decidedBy: 'Dr. med. Iris Brandt', decidedIp: '91.64.224.12', decision: 'Sehr gute Analyse, bitte umsetzen.' },
+1
View File
@@ -18,6 +18,7 @@ export type ViewId =
| 'finance'
| 'recurring'
| 'accounting'
| 'finanzamt'
| 'audit'
| 'data'
| 'api'