102a8603-26df-462f-a56d-01cee292f6f6
This commit is contained in:
1 parent
40805e16cd
commit
85c4006803
19 files changed
+987
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
+109
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Verify Accounting module (Haben-inspired German accounting)
|
||||||
|
set -u
|
||||||
|
cd /home/z/my-project
|
||||||
|
BASE=http://127.0.0.1:3000
|
||||||
|
SHOTS=/home/z/my-project/aos-shots
|
||||||
|
mkdir -p "$SHOTS"
|
||||||
|
|
||||||
|
echo "=== Start server ==="
|
||||||
|
pkill -f "next dev" 2>/dev/null; pkill -f "tee dev.log" 2>/dev/null; sleep 2
|
||||||
|
bun run dev > dev.log 2>&1 &
|
||||||
|
SRV=$!
|
||||||
|
for i in $(seq 1 90); do curl -s -o /dev/null "$BASE/" 2>/dev/null && break; sleep 1; done
|
||||||
|
echo " ready after ${i}s"; sleep 2
|
||||||
|
|
||||||
|
echo "=== API tests ==="
|
||||||
|
echo -n " /api/aos/accounting -> "; curl -s -o /dev/null -w "HTTP %{http_code}\n" "$BASE/api/aos/accounting"
|
||||||
|
curl -s "$BASE/api/aos/accounting" | python3 -c "
|
||||||
|
import sys,json
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
v=d['vat']
|
||||||
|
print(f' VAT: current period {v[\"current\"][\"periodMonth\"]}/{v[\"current\"][\"periodYear\"]} status={v[\"current\"][\"status\"]} payable={v[\"current\"][\"payableCents\"]/100:.2f}€')
|
||||||
|
print(f' VAT history: {len(v[\"history\"])} accepted, YTD total: {v[\"totalPayableYTD\"]/100:.2f}€')
|
||||||
|
e=d['eur']
|
||||||
|
print(f' EÜR: profit {e[\"totalProfit\"]/100:.2f}€, income {e[\"totalIncome\"]/100:.2f}€, cost {e[\"totalCost\"]/100:.2f}€, {len(e[\"costsByCategory\"])} categories')
|
||||||
|
b=d['bank']
|
||||||
|
print(f' Bank: {b[\"stats\"][\"matched\"]} matched, {b[\"stats\"][\"suggested\"]} suggested, {b[\"stats\"][\"unmatched\"]} unmatched, inflow {b[\"stats\"][\"inflowCents\"]/100:.0f}€')
|
||||||
|
du=d['dunning']
|
||||||
|
print(f' Dunning: {du[\"stats\"][\"overdueCount\"]} overdue, {du[\"stats\"][\"overdueCents\"]/100:.2f}€, {du[\"stats\"][\"dunningSent\"]} runs')
|
||||||
|
" 2>/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Test VAT submit to ELSTER ==="
|
||||||
|
VAT_ID=$(curl -s "$BASE/api/aos/accounting" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['vat']['current']['id'])" 2>/dev/null)
|
||||||
|
echo " current VAT id: $VAT_ID"
|
||||||
|
if [ -n "$VAT_ID" ]; then
|
||||||
|
RES=$(curl -s -X PATCH "$BASE/api/aos/accounting/vat/$VAT_ID/submit")
|
||||||
|
echo " submit result: $(echo "$RES" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'status={d[\"vat\"][\"status\"]}, elsterRef={d[\"elsterRef\"]}')" 2>/dev/null)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Test bank match ==="
|
||||||
|
# find a suggested bank tx
|
||||||
|
TX_ID=$(curl -s "$BASE/api/aos/accounting" | python3 -c "
|
||||||
|
import sys,json
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
for t in d['bank']['transactions']:
|
||||||
|
if t['matchedStatus']=='suggested':
|
||||||
|
print(t['id']); break
|
||||||
|
" 2>/dev/null)
|
||||||
|
echo " suggested tx id: $TX_ID"
|
||||||
|
# find an unpaid invoice to match against
|
||||||
|
INV_ID=$(curl -s "$BASE/api/aos/finance" | python3 -c "
|
||||||
|
import sys,json
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
for i in d['invoices']:
|
||||||
|
if i['status'] in ('sent','overdue'):
|
||||||
|
print(i['id']); break
|
||||||
|
" 2>/dev/null)
|
||||||
|
echo " invoice id to match: $INV_ID"
|
||||||
|
if [ -n "$TX_ID" ] && [ -n "$INV_ID" ]; then
|
||||||
|
RES=$(curl -s -X POST "$BASE/api/aos/accounting/bank/$TX_ID/match" -H 'content-type: application/json' -d "{\"invoiceId\":\"$INV_ID\"}")
|
||||||
|
echo " match result: $(echo "$RES" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'tx status={d[\"transaction\"][\"matchedStatus\"]}, invoice={d[\"invoiceNumber\"]}')" 2>/dev/null)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Browser: Accounting view ==="
|
||||||
|
agent-browser open "$BASE/" >/dev/null 2>&1
|
||||||
|
agent-browser wait --load networkidle >/dev/null 2>&1 || true
|
||||||
|
agent-browser wait 3000 >/dev/null 2>&1
|
||||||
|
agent-browser set viewport 1440 900 >/dev/null 2>&1
|
||||||
|
# navigate via snapshot ref (more reliable than find text)
|
||||||
|
agent-browser snapshot -i >/tmp/s.txt 2>&1
|
||||||
|
ACCT=$(grep -i 'button "Buchhaltung"' /tmp/s.txt | grep -oE 'ref=e[0-9]+' | head -1)
|
||||||
|
echo " Buchhaltung nav ref: $ACCT"
|
||||||
|
[ -n "$ACCT" ] && agent-browser click "$ACCT" >/dev/null 2>&1
|
||||||
|
agent-browser wait 3000 >/dev/null 2>&1
|
||||||
|
H1=$(agent-browser get text h1 2>/dev/null | head -1)
|
||||||
|
echo " Accounting H1: $H1"
|
||||||
|
agent-browser screenshot --full "$SHOTS/v13-accounting-vat.png" >/dev/null 2>&1 && echo " VAT shot OK"
|
||||||
|
|
||||||
|
echo "=== Switch to EÜR tab ==="
|
||||||
|
agent-browser find role tab click --name "EÜR" >/dev/null 2>&1 || agent-browser find text "EÜR" click >/dev/null 2>&1
|
||||||
|
agent-browser wait 2000 >/dev/null 2>&1
|
||||||
|
agent-browser screenshot --full "$SHOTS/v13-accounting-eur.png" >/dev/null 2>&1 && echo " EÜR shot OK"
|
||||||
|
|
||||||
|
echo "=== Switch to Bankabgleich tab ==="
|
||||||
|
agent-browser find role tab click --name "Bankabgleich" >/dev/null 2>&1 || agent-browser find text "Bankabgleich" click >/dev/null 2>&1
|
||||||
|
agent-browser wait 2000 >/dev/null 2>&1
|
||||||
|
agent-browser screenshot --full "$SHOTS/v13-accounting-bank.png" >/dev/null 2>&1 && echo " Bank shot OK"
|
||||||
|
|
||||||
|
echo "=== Switch to Mahnwesen tab ==="
|
||||||
|
agent-browser find role tab click --name "Mahnwesen" >/dev/null 2>&1 || agent-browser find text "Mahnwesen" click >/dev/null 2>&1
|
||||||
|
agent-browser wait 2000 >/dev/null 2>&1
|
||||||
|
agent-browser screenshot --full "$SHOTS/v13-accounting-dunning.png" >/dev/null 2>&1 && echo " Dunning shot OK"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Errors ==="
|
||||||
|
agent-browser errors 2>&1 | head -8
|
||||||
|
echo "--- console last 5 ---"
|
||||||
|
agent-browser console 2>&1 | tail -5
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
agent-browser close >/dev/null 2>&1
|
||||||
|
kill $SRV 2>/dev/null
|
||||||
|
echo "=== dev.log errors ==="
|
||||||
|
grep -iE "error|⨯|500" dev.log | grep -v SIGTERM | tail -8
|
||||||
|
echo "=== DONE ==="
|
||||||
|
ls -la "$SHOTS"/v13-* 2>/dev/null | tail -6
|
||||||
Binary file not shown.
@@ -264,6 +264,7 @@ model Invoice {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
lines InvoiceLine[]
|
lines InvoiceLine[]
|
||||||
|
dunningRuns DunningRun[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model InvoiceLine {
|
model InvoiceLine {
|
||||||
@@ -412,4 +413,65 @@ model EstimateLine {
|
|||||||
totalCents Int
|
totalCents Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Accounting / Buchhaltung (adapted from Haben, German-law compliant)
|
||||||
|
// VAT return (Umsatzsteuer-Voranmeldung), EÜR, bank reconciliation, dunning.
|
||||||
|
// PRD §6.6: double-entry + ELSTER out of scope; this covers the agency-facing
|
||||||
|
// compliance layer (compute, validate, mock-submit) + bank matching.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Bank transactions imported from CAMT.053 or PSD2 (FinTS).
|
||||||
|
model BankTransaction {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
amountCents Int // signed: + credit (incoming), - debit (outgoing)
|
||||||
|
bookingDate DateTime
|
||||||
|
valueDate DateTime
|
||||||
|
partnerName String?
|
||||||
|
partnerIban String?
|
||||||
|
reference String?
|
||||||
|
purpose String?
|
||||||
|
matchedInvoiceId String?
|
||||||
|
matchedStatus String @default("unmatched")
|
||||||
|
matchReason String?
|
||||||
|
importBatchId String?
|
||||||
|
importedAt DateTime @default(now())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// VAT pre-registration periods (Umsatzsteuer-Voranmeldung)
|
||||||
|
model VatReturn {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
periodYear Int
|
||||||
|
periodMonth Int
|
||||||
|
periodType String @default("monthly")
|
||||||
|
status String @default("draft")
|
||||||
|
k81StandardNetCents Int @default(0)
|
||||||
|
k86ReducedNetCents Int @default(0)
|
||||||
|
k66TaxFreeCents Int @default(0)
|
||||||
|
k83TaxStandardCents Int @default(0)
|
||||||
|
k65TaxReducedCents Int @default(0)
|
||||||
|
k37InputTaxCents Int @default(0)
|
||||||
|
outputTaxCents Int @default(0)
|
||||||
|
inputTaxCents Int @default(0)
|
||||||
|
payableCents Int @default(0)
|
||||||
|
submittedAt DateTime?
|
||||||
|
elsterRef String?
|
||||||
|
acceptedAt DateTime?
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dunning levels for overdue invoices (Mahnwesen)
|
||||||
|
model DunningRun {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
invoiceId String
|
||||||
|
invoice Invoice @relation(fields: [invoiceId], references: [id])
|
||||||
|
level Int
|
||||||
|
status String @default("sent")
|
||||||
|
feesCents Int @default(0)
|
||||||
|
sentAt DateTime @default(now())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
// POST /api/aos/accounting/bank/[id]/match — match a bank transaction to an invoice.
|
||||||
|
// Body: { invoiceId } — marks the transaction matched + invoice paid.
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params
|
||||||
|
const { invoiceId } = (await req.json()) as { invoiceId: string }
|
||||||
|
|
||||||
|
const tx = await db.bankTransaction.findUnique({ where: { id } })
|
||||||
|
if (!tx) return NextResponse.json({ error: 'transaction not found' }, { status: 404 })
|
||||||
|
|
||||||
|
const invoice = await db.invoice.findUnique({ where: { id: invoiceId } })
|
||||||
|
if (!invoice) return NextResponse.json({ error: 'invoice not found' }, { status: 404 })
|
||||||
|
|
||||||
|
const updated = await db.bankTransaction.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
matchedInvoiceId: invoiceId,
|
||||||
|
matchedStatus: 'matched',
|
||||||
|
matchReason: 'Manuell zugeordnet',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// mark invoice as paid
|
||||||
|
await db.invoice.update({
|
||||||
|
where: { id: invoiceId },
|
||||||
|
data: { status: 'paid', paidDate: tx.valueDate },
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actor: 'nora@nordlicht.digital',
|
||||||
|
action: 'bank.match:invoice',
|
||||||
|
entity: 'BankTransaction',
|
||||||
|
entityId: id,
|
||||||
|
after: JSON.stringify({ invoiceId, invoiceNumber: invoice.number, status: 'matched', invoiceStatus: 'paid' }),
|
||||||
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ transaction: updated, invoiceNumber: invoice.number })
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
// GET /api/aos/accounting — accounting overview:
|
||||||
|
// - VAT returns (Umsatzsteuer-Voranmeldung) with Kennzahlen
|
||||||
|
// - EÜR (Einnahmen-Überschuss-Rechnung) from invoices + expenses
|
||||||
|
// - Bank transactions (CAMT.053 import) with match status
|
||||||
|
// - Dunning (Mahnwesen) for overdue invoices
|
||||||
|
export async function GET() {
|
||||||
|
const [vatReturns, bankTx, invoices, expenses, dunning] = await Promise.all([
|
||||||
|
db.vatReturn.findMany({ orderBy: [{ periodYear: 'desc' }, { periodMonth: 'desc' }] }),
|
||||||
|
db.bankTransaction.findMany({ orderBy: { bookingDate: 'desc' } }),
|
||||||
|
db.invoice.findMany({ where: { status: { in: ['paid', 'sent', 'overdue'] } }, include: { company: true } }),
|
||||||
|
db.expense.findMany(),
|
||||||
|
db.dunningRun.findMany({ orderBy: { sentAt: 'desc' }, include: { invoice: { include: { company: true } } } }),
|
||||||
|
])
|
||||||
|
|
||||||
|
// --- VAT stats ---
|
||||||
|
const currentVat = vatReturns.find((v) => v.status === 'computed' || v.status === 'draft')
|
||||||
|
const vatHistory = vatReturns.filter((v) => v.status === 'accepted')
|
||||||
|
const totalPayableYTD = vatReturns.filter((v) => v.status === 'accepted').reduce((s, v) => s + v.payableCents, 0)
|
||||||
|
|
||||||
|
// --- EÜR (income statement) ---
|
||||||
|
// Income = paid invoices (net), Cost = expenses (net, non-billable internal)
|
||||||
|
const now = new Date()
|
||||||
|
const eurMonths: { label: string; incomeCents: number; costCents: number; profitCents: number }[] = []
|
||||||
|
for (let i = 5; i >= 0; i--) {
|
||||||
|
const m = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||||
|
const label = m.toLocaleDateString('de-DE', { month: 'short' })
|
||||||
|
const incomeCents = invoices
|
||||||
|
.filter((inv) => inv.status === 'paid' && inv.paidDate && inv.paidDate.getMonth() === m.getMonth() && inv.paidDate.getFullYear() === m.getFullYear())
|
||||||
|
.reduce((s, i) => s + i.netCents, 0)
|
||||||
|
const costCents = expenses
|
||||||
|
.filter((e) => e.date.getMonth() === m.getMonth() && e.date.getFullYear() === m.getFullYear())
|
||||||
|
.reduce((s, e) => s + e.amountCents, 0)
|
||||||
|
eurMonths.push({ label, incomeCents, costCents, profitCents: incomeCents - costCents })
|
||||||
|
}
|
||||||
|
const totalIncome = eurMonths.reduce((s, m) => s + m.incomeCents, 0)
|
||||||
|
const totalCost = eurMonths.reduce((s, m) => s + m.costCents, 0)
|
||||||
|
const totalProfit = totalIncome - totalCost
|
||||||
|
|
||||||
|
// EÜR by category (costs)
|
||||||
|
const costsByCategory: Record<string, number> = {}
|
||||||
|
for (const e of expenses) {
|
||||||
|
costsByCategory[e.categoryId] = (costsByCategory[e.categoryId] ?? 0) + e.amountCents
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bank reconciliation ---
|
||||||
|
const matched = bankTx.filter((t) => t.matchedStatus === 'matched').length
|
||||||
|
const suggested = bankTx.filter((t) => t.matchedStatus === 'suggested').length
|
||||||
|
const unmatched = bankTx.filter((t) => t.matchedStatus === 'unmatched').length
|
||||||
|
const inflowCents = bankTx.filter((t) => t.amountCents > 0).reduce((s, t) => s + t.amountCents, 0)
|
||||||
|
const outflowCents = bankTx.filter((t) => t.amountCents < 0).reduce((s, t) => s + t.amountCents, 0)
|
||||||
|
|
||||||
|
// --- Dunning ---
|
||||||
|
const overdueInvoices = invoices.filter((i) => i.status === 'overdue')
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
vat: {
|
||||||
|
current: currentVat,
|
||||||
|
history: vatHistory,
|
||||||
|
totalPayableYTD,
|
||||||
|
stats: { periods: vatReturns.length, accepted: vatHistory.length, pending: currentVat ? 1 : 0 },
|
||||||
|
},
|
||||||
|
eur: {
|
||||||
|
months: eurMonths,
|
||||||
|
totalIncome,
|
||||||
|
totalCost,
|
||||||
|
totalProfit,
|
||||||
|
costsByCategory: Object.entries(costsByCategory).map(([cat, cents]) => ({ category: cat, cents })).sort((a, b) => b.cents - a.cents),
|
||||||
|
},
|
||||||
|
bank: {
|
||||||
|
transactions: bankTx,
|
||||||
|
stats: { matched, suggested, unmatched, inflowCents, outflowCents, total: bankTx.length },
|
||||||
|
},
|
||||||
|
dunning: {
|
||||||
|
overdue: overdueInvoices.map((i) => ({ id: i.id, number: i.number, companyName: i.company.name, grossCents: i.grossCents, dueDate: i.dueDate, daysOverdue: Math.round((Date.now() - i.dueDate.getTime()) / 86400000) })),
|
||||||
|
runs: dunning,
|
||||||
|
stats: { overdueCount: overdueInvoices.length, overdueCents: overdueInvoices.reduce((s, i) => s + i.grossCents, 0), dunningSent: dunning.length },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { db } from '@/lib/db'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
// PATCH /api/aos/accounting/vat/[id]/submit — submit VAT return to ELSTER (mock ERiC).
|
||||||
|
// Transitions computed → submitted → accepted, generates elsterRef, writes audit.
|
||||||
|
export async function PATCH(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { id } = await params
|
||||||
|
const vat = await db.vatReturn.findUnique({ where: { id } })
|
||||||
|
if (!vat) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||||
|
if (vat.status === 'accepted') return NextResponse.json({ error: 'already accepted' }, { status: 409 })
|
||||||
|
|
||||||
|
const monthName = new Date(vat.periodYear, vat.periodMonth - 1, 1).toLocaleDateString('de-DE', { month: 'short' })
|
||||||
|
const elsterRef = `ERiC-${vat.periodYear}-${String(vat.periodMonth).padStart(2, '0')}-NORDLICHT-${Math.floor(Math.random() * 9000 + 1000)}`
|
||||||
|
|
||||||
|
const updated = await db.vatReturn.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'accepted',
|
||||||
|
submittedAt: new Date(),
|
||||||
|
acceptedAt: new Date(),
|
||||||
|
elsterRef,
|
||||||
|
notes: `Umsatzsteuer-Voranmeldung ${monthName} ${vat.periodYear} via ERiC übermittelt und vom Finanzamt bestätigt.`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actor: 'nora@nordlicht.digital',
|
||||||
|
action: 'vat.submit:elster',
|
||||||
|
entity: 'VatReturn',
|
||||||
|
entityId: id,
|
||||||
|
before: JSON.stringify({ status: vat.status }),
|
||||||
|
after: JSON.stringify({ status: 'accepted', elsterRef, payableCents: vat.payableCents }),
|
||||||
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ vat: updated, elsterRef })
|
||||||
|
}
|
||||||
@@ -15,6 +15,9 @@ export async function POST() {
|
|||||||
try {
|
try {
|
||||||
// wipe via prisma (SQLite TRUNCATE not supported, use deleteMany per model in order)
|
// wipe via prisma (SQLite TRUNCATE not supported, use deleteMany per model in order)
|
||||||
const { db } = await import('@/lib/db')
|
const { db } = await import('@/lib/db')
|
||||||
|
await db.dunningRun.deleteMany()
|
||||||
|
await db.vatReturn.deleteMany()
|
||||||
|
await db.bankTransaction.deleteMany()
|
||||||
await db.estimateLine.deleteMany()
|
await db.estimateLine.deleteMany()
|
||||||
await db.estimate.deleteMany()
|
await db.estimate.deleteMany()
|
||||||
await db.ticketMessage.deleteMany()
|
await db.ticketMessage.deleteMany()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { LicensesView } from './views/licenses'
|
|||||||
import { ContractsView } from './views/contracts'
|
import { ContractsView } from './views/contracts'
|
||||||
import { ExpensesView } from './views/expenses'
|
import { ExpensesView } from './views/expenses'
|
||||||
import { FinanceView } from './views/finance'
|
import { FinanceView } from './views/finance'
|
||||||
|
import { AccountingView } from './views/accounting'
|
||||||
import { AuditView } from './views/audit'
|
import { AuditView } from './views/audit'
|
||||||
import { SettingsView } from './views/settings'
|
import { SettingsView } from './views/settings'
|
||||||
import { SetupWizardView } from './views/setup-wizard'
|
import { SetupWizardView } from './views/setup-wizard'
|
||||||
@@ -57,6 +58,7 @@ export function AppShell() {
|
|||||||
{view === 'contracts' && <ContractsView />}
|
{view === 'contracts' && <ContractsView />}
|
||||||
{view === 'expenses' && <ExpensesView />}
|
{view === 'expenses' && <ExpensesView />}
|
||||||
{view === 'finance' && <FinanceView />}
|
{view === 'finance' && <FinanceView />}
|
||||||
|
{view === 'accounting' && <AccountingView />}
|
||||||
{view === 'audit' && <AuditView />}
|
{view === 'audit' && <AuditView />}
|
||||||
{view === 'settings' && <SettingsView />}
|
{view === 'settings' && <SettingsView />}
|
||||||
{view === 'setup' && <SetupWizardView />}
|
{view === 'setup' && <SetupWizardView />}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Rocket,
|
Rocket,
|
||||||
|
BookOpen,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAos, type ViewId } from '@/store/aos'
|
import { useAos, type ViewId } from '@/store/aos'
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +46,7 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st
|
|||||||
{ id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' },
|
{ id: 'contracts', label: 'Verträge', icon: FileSignature, hint: 'Werkvertrag, AVV, NDA' },
|
||||||
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' },
|
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, hint: 'Belastbar & DATEV' },
|
||||||
{ id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
|
{ id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' },
|
||||||
|
{ id: 'accounting', label: 'Buchhaltung', icon: BookOpen, hint: 'USt-Voranmeldung, EÜR, Bank' },
|
||||||
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, hint: 'Hash-Kette & GoBD' },
|
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, hint: 'Hash-Kette & GoBD' },
|
||||||
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' },
|
{ id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' },
|
||||||
{ id: 'setup', label: 'Setup-Wizard', icon: Rocket, hint: '≤ 15 Min Installation' },
|
{ id: 'setup', label: 'Setup-Wizard', icon: Rocket, hint: '≤ 15 Min Installation' },
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Rocket,
|
Rocket,
|
||||||
|
BookOpen,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAos, type ViewId } from '@/store/aos'
|
import { useAos, type ViewId } from '@/store/aos'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -57,6 +58,7 @@ const NAV: {
|
|||||||
{ id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' },
|
{ id: 'contracts', label: 'Verträge', icon: FileSignature, permission: 'contracts:read' },
|
||||||
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' },
|
{ id: 'expenses', label: 'Ausgaben', icon: Wallet, permission: 'expenses:read' },
|
||||||
{ id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
|
{ id: 'finance', label: 'Finanzen', icon: Receipt, permission: 'finance:read' },
|
||||||
|
{ id: 'accounting', label: 'Buchhaltung', icon: BookOpen, permission: 'accounting:read' },
|
||||||
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, permission: '*' },
|
{ id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, permission: '*' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
|||||||
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
|
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
|
||||||
expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' },
|
expenses: { title: 'Ausgaben', crumb: 'Betrieb / Ausgaben' },
|
||||||
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
|
finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' },
|
||||||
|
accounting: { title: 'Buchhaltung', crumb: 'Betrieb / Buchhaltung' },
|
||||||
audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' },
|
audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' },
|
||||||
settings: { title: 'Erweiterungen', crumb: 'System / Module' },
|
settings: { title: 'Erweiterungen', crumb: 'System / Module' },
|
||||||
setup: { title: 'Setup-Wizard', crumb: 'System / Setup' },
|
setup: { title: 'Setup-Wizard', crumb: 'System / Setup' },
|
||||||
|
|||||||
@@ -0,0 +1,585 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
Landmark,
|
||||||
|
TrendingUp,
|
||||||
|
ArrowDownLeft,
|
||||||
|
ArrowUpRight,
|
||||||
|
Send,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertTriangle,
|
||||||
|
Loader2,
|
||||||
|
Link2,
|
||||||
|
Banknote,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useFetch } from '@/hooks/use-fetch'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
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 { PageHeader } from '../primitives'
|
||||||
|
import { formatEuro, formatEuroCompact, formatDate, formatDateTime } from '@/lib/format'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis, Cell, Line, LineChart, CartesianGrid } from 'recharts'
|
||||||
|
|
||||||
|
interface VatReturn {
|
||||||
|
id: string
|
||||||
|
periodYear: number
|
||||||
|
periodMonth: number
|
||||||
|
periodType: string
|
||||||
|
status: string
|
||||||
|
k81StandardNetCents: number
|
||||||
|
k86ReducedNetCents: number
|
||||||
|
k66TaxFreeCents: number
|
||||||
|
k83TaxStandardCents: number
|
||||||
|
k65TaxReducedCents: number
|
||||||
|
k37InputTaxCents: number
|
||||||
|
outputTaxCents: number
|
||||||
|
inputTaxCents: number
|
||||||
|
payableCents: number
|
||||||
|
submittedAt: string | null
|
||||||
|
elsterRef: string | null
|
||||||
|
acceptedAt: string | null
|
||||||
|
notes: string | null
|
||||||
|
}
|
||||||
|
interface BankTransaction {
|
||||||
|
id: string
|
||||||
|
amountCents: number
|
||||||
|
bookingDate: string
|
||||||
|
valueDate: string
|
||||||
|
partnerName: string | null
|
||||||
|
partnerIban: string | null
|
||||||
|
reference: string | null
|
||||||
|
purpose: string | null
|
||||||
|
matchedStatus: string
|
||||||
|
matchReason: string | null
|
||||||
|
}
|
||||||
|
interface AccountingData {
|
||||||
|
vat: {
|
||||||
|
current: VatReturn | null
|
||||||
|
history: VatReturn[]
|
||||||
|
totalPayableYTD: number
|
||||||
|
stats: { periods: number; accepted: number; pending: number }
|
||||||
|
}
|
||||||
|
eur: {
|
||||||
|
months: { label: string; incomeCents: number; costCents: number; profitCents: number }[]
|
||||||
|
totalIncome: number
|
||||||
|
totalCost: number
|
||||||
|
totalProfit: number
|
||||||
|
costsByCategory: { category: string; cents: number }[]
|
||||||
|
}
|
||||||
|
bank: {
|
||||||
|
transactions: BankTransaction[]
|
||||||
|
stats: { matched: number; suggested: number; unmatched: number; inflowCents: number; outflowCents: number; total: number }
|
||||||
|
}
|
||||||
|
dunning: {
|
||||||
|
overdue: { id: string; number: string; companyName: string; grossCents: number; dueDate: string; daysOverdue: number }[]
|
||||||
|
runs: { id: string; level: number; status: string; feesCents: number; sentAt: string; invoice: { number: string; company: { name: string } } }[]
|
||||||
|
stats: { overdueCount: number; overdueCents: number; dunningSent: number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const VAT_STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||||
|
draft: { label: 'Entwurf', cls: 'bg-muted text-foreground' },
|
||||||
|
computed: { label: 'Berechnet', cls: 'bg-foreground/50 text-background' },
|
||||||
|
submitted: { label: 'Übermittelt', cls: 'bg-foreground/70 text-background' },
|
||||||
|
accepted: { label: 'Akzeptiert', cls: 'bg-foreground text-background' },
|
||||||
|
corrected: { label: 'Korrigiert', cls: 'bg-muted text-muted-foreground' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTH_NAMES = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
reise: 'Reise', software: 'Software', hardware: 'Hardware', fremdleistung: 'Fremdleistung', buero: 'Büro', marketing: 'Marketing',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountingView() {
|
||||||
|
const { data, loading, refetch } = useFetch<AccountingData>('/api/aos/accounting')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
if (loading || !data) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-4 md:p-6">
|
||||||
|
<Skeleton className="h-9 w-40" />
|
||||||
|
<Skeleton className="h-40" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { vat, eur, bank, dunning } = data
|
||||||
|
|
||||||
|
const submitVat = async (vatId: string) => {
|
||||||
|
setSubmitting(true)
|
||||||
|
const t = toast.loading('Voranmeldung wird an ELSTER übermittelt (ERiC)…')
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/aos/accounting/vat/${vatId}/submit`, { method: 'PATCH' })
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = await r.json().catch(() => ({}))
|
||||||
|
throw new Error(err.error || 'submit failed')
|
||||||
|
}
|
||||||
|
const res = await r.json()
|
||||||
|
toast.success(`Akzeptiert — ELSTER-Ref: ${res.elsterRef}`, { id: t })
|
||||||
|
refetch()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Übermittlung fehlgeschlagen', { id: t })
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchTx = async (txId: string, invoiceId: string) => {
|
||||||
|
const t = toast.loading('Bankbewegung wird zugeordnet…')
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/aos/accounting/bank/${txId}/match`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ invoiceId }),
|
||||||
|
})
|
||||||
|
if (!r.ok) throw new Error('match failed')
|
||||||
|
toast.success('Zugeordnet — Rechnung als bezahlt markiert', { id: t })
|
||||||
|
refetch()
|
||||||
|
} catch {
|
||||||
|
toast.error('Zuordnung fehlgeschlagen', { id: t })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentVat = vat.current
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-4 md:p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Buchhaltung"
|
||||||
|
description="Alman muhasebe uyumlu: Umsatzsteuer-Voranmeldung (ELSTER), EÜR, Bankabgleich (CAMT.053) und Mahnwesen. GoBD-konform mit Audit-Hash-Kette."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* KPIs */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">USt-Zahllast (lfd. Monat)</p>
|
||||||
|
<Landmark className="size-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{currentVat ? formatEuroCompact(currentVat.payableCents) : '—'}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{currentVat ? `${MONTH_NAMES[currentVat.periodMonth - 1]} ${currentVat.periodYear}` : 'keine offene Periode'}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">USt YTD (abgeführt)</p>
|
||||||
|
<CheckCircle2 className="size-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(vat.totalPayableYTD)}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{vat.stats.accepted} Perioden akzeptiert</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">EÜR Gewinn (6 Mo)</p>
|
||||||
|
<TrendingUp className="size-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(eur.totalProfit)}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{formatEuroCompact(eur.totalIncome)} Einnahmen</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">Überfällige Posten</p>
|
||||||
|
<AlertTriangle className="size-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuroCompact(dunning.stats.overdueCents)}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{dunning.stats.overdueCount} Rechnungen</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="vat">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="vat">Umsatzsteuer</TabsTrigger>
|
||||||
|
<TabsTrigger value="eur">EÜR</TabsTrigger>
|
||||||
|
<TabsTrigger value="bank">Bankabgleich</TabsTrigger>
|
||||||
|
<TabsTrigger value="dunning">Mahnwesen</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* VAT tab */}
|
||||||
|
<TabsContent value="vat" className="mt-4 space-y-4">
|
||||||
|
{currentVat && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="border-b border-border pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Voranmeldung {MONTH_NAMES[currentVat.periodMonth - 1]} {currentVat.periodYear}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{currentVat.periodType === 'monthly' ? 'Monatlich' : 'Quartalsweise'} ·{' '}
|
||||||
|
<Badge variant="outline" className={cn('text-[10px]', (VAT_STATUS_META[currentVat.status] ?? VAT_STATUS_META.draft).cls)}>
|
||||||
|
{(VAT_STATUS_META[currentVat.status] ?? VAT_STATUS_META.draft).label}
|
||||||
|
</Badge>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{currentVat.status === 'computed' && (
|
||||||
|
<Button size="sm" className="gap-1.5" onClick={() => submitVat(currentVat.id)} disabled={submitting}>
|
||||||
|
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : <Send className="size-3.5" />}
|
||||||
|
An ELSTER übermitteln
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
{/* Kennzahlen */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="mb-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Kennzahlen (UStVA)</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
|
<Kennzahl kz="81" label="19% Umsatz (netto)" value={currentVat.k81StandardNetCents} />
|
||||||
|
<Kennzahl kz="86" label="7% Umsatz (netto)" value={currentVat.k86ReducedNetCents} />
|
||||||
|
<Kennzahl kz="66" label="Steuerfrei §4" value={currentVat.k66TaxFreeCents} />
|
||||||
|
<Kennzahl kz="83" label="Steuer 19%" value={currentVat.k83TaxStandardCents} />
|
||||||
|
<Kennzahl kz="65" label="Steuer 7%" value={currentVat.k65TaxReducedCents} />
|
||||||
|
<Kennzahl kz="37" label="Vorsteuer" value={currentVat.k37InputTaxCents} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Calculation */}
|
||||||
|
<div className="space-y-1.5 rounded-md border border-border bg-muted/20 p-3 text-xs">
|
||||||
|
<CalcRow label="Umsatzsteuer (Summe 83+65)" value={currentVat.outputTaxCents} />
|
||||||
|
<CalcRow label="Abziehbare Vorsteuer (37)" value={currentVat.inputTaxCents} negative />
|
||||||
|
<div className="mt-1 flex items-center justify-between border-t border-border pt-1.5 font-semibold">
|
||||||
|
<span>Zahllast / Gutschrift</span>
|
||||||
|
<span className={cn('tabular-nums', currentVat.payableCents < 0 && 'text-muted-foreground')}>
|
||||||
|
{currentVat.payableCents < 0 ? '−' : ''}{formatEuro(Math.abs(currentVat.payableCents))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{currentVat.notes && (
|
||||||
|
<p className="mt-3 text-[11px] text-muted-foreground">{currentVat.notes}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
|
||||||
|
<p className="font-medium text-foreground">ELSTER-Übermittlung (ERiC)</p>
|
||||||
|
<p className="mt-1">Die Kennzahlen werden aus den gebuchten Rechnungen und Ausgaben berechnet (Ist-Versteuerung). Übermittlung via ERiC-Schnittstelle. Vor dem Absenden: Vorprüfung jeder Kennzahl mit Herkunftsnachweis.</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* VAT history */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Historie ({vat.history.length} Perioden)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 font-medium">Periode</th>
|
||||||
|
<th className="px-4 py-2 font-medium text-right">81 (19% netto)</th>
|
||||||
|
<th className="px-4 py-2 font-medium text-right">37 (Vorsteuer)</th>
|
||||||
|
<th className="px-4 py-2 font-medium text-right">Zahllast</th>
|
||||||
|
<th className="px-4 py-2 font-medium">ELSTER-Ref</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{vat.history.map((v) => (
|
||||||
|
<tr key={v.id} className="transition hover:bg-muted/40">
|
||||||
|
<td className="px-4 py-2.5 text-xs font-medium">{MONTH_NAMES[v.periodMonth - 1]} {v.periodYear}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-xs tabular-nums">{formatEuro(v.k81StandardNetCents)}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-xs tabular-nums">{formatEuro(v.k37InputTaxCents)}</td>
|
||||||
|
<td className="px-4 py-2.5 text-right text-xs font-semibold tabular-nums">{formatEuro(v.payableCents)}</td>
|
||||||
|
<td className="px-4 py-2.5"><code className="text-[10px] font-mono text-muted-foreground">{v.elsterRef ?? '—'}</code></td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<Badge variant="outline" className={cn('text-[10px]', (VAT_STATUS_META[v.status] ?? VAT_STATUS_META.draft).cls)}>
|
||||||
|
{(VAT_STATUS_META[v.status] ?? VAT_STATUS_META.draft).label}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* EÜR tab */}
|
||||||
|
<TabsContent value="eur" className="mt-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Einnahmen vs. Ausgaben (6 Monate, netto)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<BarChart data={eur.months} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} tickFormatter={(v) => formatEuroCompact(v).replace(' €', '')} width={50} />
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: 'var(--muted)', opacity: 0.4 }}
|
||||||
|
contentStyle={{ background: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', fontSize: 12 }}
|
||||||
|
formatter={(v: number, name: string) => [formatEuro(v), name === 'incomeCents' ? 'Einnahmen' : 'Ausgaben']}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="incomeCents" fill="var(--foreground)" radius={[3, 3, 0, 0]} />
|
||||||
|
<Bar dataKey="costCents" fill="var(--muted-foreground)" fillOpacity={0.5} radius={[3, 3, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Gewinnverlauf</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<LineChart data={eur.months} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
|
||||||
|
<XAxis dataKey="label" tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }} axisLine={false} tickLine={false} tickFormatter={(v) => formatEuroCompact(v).replace(' €', '')} width={50} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: 'var(--popover)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', fontSize: 12 }}
|
||||||
|
formatter={(v: number) => [formatEuro(v), 'Gewinn']}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="profitCents" stroke="var(--foreground)" strokeWidth={2} dot={{ fill: 'var(--foreground)', r: 3 }} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">EÜR-Summe (6 Monate)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1.5 text-xs">
|
||||||
|
<CalcRow label="Einnahmen (bezahlte Rechnungen, netto)" value={eur.totalIncome} />
|
||||||
|
<CalcRow label="Ausgaben (Aufwendungen, netto)" value={eur.totalCost} negative />
|
||||||
|
<div className="mt-1 flex items-center justify-between border-t border-border pt-1.5 font-semibold">
|
||||||
|
<span>Gewinn (Einnahmen-Überschuss)</span>
|
||||||
|
<span className="tabular-nums">{formatEuro(eur.totalProfit)}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Ausgaben nach Kategorie</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{eur.costsByCategory.map((c) => {
|
||||||
|
const max = eur.costsByCategory[0]?.cents ?? 1
|
||||||
|
const pct = Math.round((c.cents / max) * 100)
|
||||||
|
return (
|
||||||
|
<div key={c.category}>
|
||||||
|
<div className="mb-1 flex items-center justify-between text-xs">
|
||||||
|
<span className="font-medium">{CATEGORY_LABELS[c.category] ?? c.category}</span>
|
||||||
|
<span className="tabular-nums text-muted-foreground">{formatEuro(c.cents)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded bg-muted">
|
||||||
|
<div className="h-full rounded bg-foreground" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
|
||||||
|
<p className="font-medium text-foreground">EÜR (Einnahmen-Überschuss-Rechnung)</p>
|
||||||
|
<p className="mt-1">Für Freiberufler und Kleingewerbe (§ 4 Abs. 3 EStG). Einnahmen bei Ist-Versteuerung nach Zufluss, Ausgaben nach Abfluss. Jahres-EÜR mit AVEÜR an das Finanzamt (Anlage EÜR). AfA und pauschalen werden separat berechnet.</p>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Bank reconciliation tab */}
|
||||||
|
<TabsContent value="bank" className="mt-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">Zugeordnet</p>
|
||||||
|
<CheckCircle2 className="size-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xl font-semibold tabular-nums">{bank.stats.matched}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">Vorgeschlagen</p>
|
||||||
|
<Link2 className="size-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xl font-semibold tabular-nums">{bank.stats.suggested}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">Zugänge</p>
|
||||||
|
<ArrowDownLeft className="size-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xl font-semibold tabular-nums">{formatEuroCompact(bank.stats.inflowCents)}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">Abgänge</p>
|
||||||
|
<ArrowUpRight className="size-3.5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xl font-semibold tabular-nums">{formatEuroCompact(bank.stats.outflowCents)}</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm">Bankbewegungen (CAMT.053 Import)</CardTitle>
|
||||||
|
<Button size="sm" variant="outline" className="h-7 gap-1.5 text-xs">
|
||||||
|
<Banknote className="size-3.5" />
|
||||||
|
CAMT.053 importieren
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-y border-border bg-muted/40 text-left text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 font-medium">Buchungstag</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Partner</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Verwendungszweck</th>
|
||||||
|
<th className="px-4 py-2 font-medium text-right">Betrag</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Status</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{bank.transactions.map((tx) => {
|
||||||
|
const isIncome = tx.amountCents > 0
|
||||||
|
return (
|
||||||
|
<tr key={tx.id} className="transition hover:bg-muted/40">
|
||||||
|
<td className="px-4 py-2.5 text-xs">{formatDate(tx.bookingDate)}</td>
|
||||||
|
<td className="px-4 py-2.5 text-xs">
|
||||||
|
<p className="font-medium">{tx.partnerName ?? '—'}</p>
|
||||||
|
{tx.partnerIban && <p className="text-[10px] font-mono text-muted-foreground">{tx.partnerIban}</p>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<p className="truncate text-xs">{tx.purpose ?? tx.reference ?? '—'}</p>
|
||||||
|
{tx.matchReason && <p className="text-[10px] text-muted-foreground">{tx.matchReason}</p>}
|
||||||
|
</td>
|
||||||
|
<td className={cn('px-4 py-2.5 text-right text-xs font-semibold tabular-nums', isIncome ? 'text-foreground' : 'text-muted-foreground')}>
|
||||||
|
{isIncome ? '+' : ''}{formatEuro(tx.amountCents)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<Badge variant="outline" className={cn(
|
||||||
|
'text-[10px]',
|
||||||
|
tx.matchedStatus === 'matched' && 'border-foreground/40',
|
||||||
|
tx.matchedStatus === 'suggested' && 'border-foreground/30',
|
||||||
|
)}>
|
||||||
|
{tx.matchedStatus === 'matched' ? 'Zugeordnet' : tx.matchedStatus === 'suggested' ? 'Vorschlag' : 'Offen'}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
{tx.matchedStatus === 'suggested' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 text-[11px]"
|
||||||
|
onClick={() => matchTx(tx.id, 'demo')}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="size-3" />Zuordnen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{tx.matchedStatus === 'unmatched' && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
{tx.matchedStatus === 'matched' && (
|
||||||
|
<CheckCircle2 className="size-4 text-foreground" />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Dunning tab */}
|
||||||
|
<TabsContent value="dunning" className="mt-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<AlertTriangle className="size-3.5 text-muted-foreground" />
|
||||||
|
Überfällige Rechnungen ({dunning.overdue.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{dunning.overdue.length === 0 && (
|
||||||
|
<p className="py-6 text-center text-xs text-muted-foreground">Keine überfälligen Rechnungen.</p>
|
||||||
|
)}
|
||||||
|
{dunning.overdue.map((inv) => (
|
||||||
|
<div key={inv.id} className="rounded-md border border-border p-2.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<code className="text-[11px] font-mono">{inv.number}</code>
|
||||||
|
<Badge variant="outline" className="text-[10px]">{inv.daysOverdue} d überfällig</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 truncate text-xs">{inv.companyName}</p>
|
||||||
|
<div className="mt-1 flex items-center justify-between">
|
||||||
|
<span className="text-[10px] text-muted-foreground">Fällig: {formatDate(inv.dueDate)}</span>
|
||||||
|
<span className="text-xs font-semibold tabular-nums">{formatEuro(inv.grossCents)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Mahnlauf-Historie ({dunning.runs.length})</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{dunning.runs.length === 0 && (
|
||||||
|
<p className="py-6 text-center text-xs text-muted-foreground">Keine Mahnungen versendet.</p>
|
||||||
|
)}
|
||||||
|
{dunning.runs.map((run) => (
|
||||||
|
<div key={run.id} className="rounded-md border border-border p-2.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge variant="outline" className="text-[10px]">Mahnstufe {run.level}</Badge>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{formatDateTime(run.sentAt)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 truncate text-xs">{run.invoice.company.name} · <code className="font-mono text-[10px]">{run.invoice.number}</code></p>
|
||||||
|
{run.feesCents > 0 && (
|
||||||
|
<p className="mt-1 text-[10px] text-muted-foreground">Mahngebühren: {formatEuro(run.feesCents)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
|
||||||
|
<p className="font-medium text-foreground">Mahnwesen (3 Stufen)</p>
|
||||||
|
<p className="mt-1">Stufe 1: Zahlungserinnerung (kostenlos) · Stufe 2: Mahnung mit Gebühr (2,50 €) · Stufe 3: Letzte Mahnung + Verzugszinsen (§ 288 BGB, 5 % p.a.). Nach Stufe 3: automatische Suspendierung verknüpfter Lizenzen (konfigurierbar).</p>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Kennzahl({ kz, label, value }: { kz: string; label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border p-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<code className="text-[10px] font-mono font-bold">{kz}</code>
|
||||||
|
<span className="text-[9px] text-muted-foreground">{label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm font-semibold tabular-nums">{formatEuro(value)}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalcRow({ label, value, negative }: { label: string; value: number; negative?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="tabular-nums">{negative ? '−' : ''}{formatEuro(value)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ const MODULE_META: Record<string, { label: string; desc: string; phase: string }
|
|||||||
expenses: { label: 'Ausgaben', desc: 'Belastbare Posten, Wiederkehrendes, DATEV-Export.', phase: 'Phase 5' },
|
expenses: { label: 'Ausgaben', desc: 'Belastbare Posten, Wiederkehrendes, DATEV-Export.', phase: 'Phase 5' },
|
||||||
estimates: { label: 'Angebote', desc: 'Quotes mit Positionen, Akzeptanz → Projekt, → Rechnung.', phase: 'Phase 5' },
|
estimates: { label: 'Angebote', desc: 'Quotes mit Positionen, Akzeptanz → Projekt, → Rechnung.', phase: 'Phase 5' },
|
||||||
support: { label: 'Support', desc: 'Ticket-Inbox, SLA-Timer, AI-Summary & Antwort-Vorschläge.', phase: 'Phase 6' },
|
support: { label: 'Support', desc: 'Ticket-Inbox, SLA-Timer, AI-Summary & Antwort-Vorschläge.', phase: 'Phase 6' },
|
||||||
|
accounting: { label: 'Buchhaltung', desc: 'USt-Voranmeldung (ELSTER), EÜR, Bankabgleich, Mahnwesen.', phase: 'Phase 6' },
|
||||||
'sales-docs': { label: 'Angebote & Verträge', desc: 'Angebote, Auftragsbestätigung, Vertragstemplates.', phase: 'Phase 5' },
|
'sales-docs': { label: 'Angebote & Verträge', desc: 'Angebote, Auftragsbestätigung, Vertragstemplates.', phase: 'Phase 5' },
|
||||||
finance: { label: 'Finanzen', desc: 'Rechnungen, E-Rechnung, Mahnwesen, DATEV.', phase: 'Phase 5' },
|
finance: { label: 'Finanzen', desc: 'Rechnungen, E-Rechnung, Mahnwesen, DATEV.', phase: 'Phase 5' },
|
||||||
reporting: { label: 'Reporting', desc: 'Dashboards: Pipeline, Auslastung, MRR (geplant).', phase: 'Later' },
|
reporting: { label: 'Reporting', desc: 'Dashboards: Pipeline, Auslastung, MRR (geplant).', phase: 'Later' },
|
||||||
|
|||||||
@@ -624,6 +624,49 @@ async function main() {
|
|||||||
await db.auditLog.create({ data: { actor: 'sven@nordlicht.digital', action: 'estimate.send', entity: 'Estimate', entityId: 'seed', after: '{"status":"sent"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
await db.auditLog.create({ data: { actor: 'sven@nordlicht.digital', action: 'estimate.send', entity: 'Estimate', entityId: 'seed', after: '{"status":"sent"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
||||||
await db.auditLog.create({ data: { actor: 'sven@nordlicht.digital', action: 'estimate.accept', entity: 'Estimate', entityId: 'seed', after: '{"status":"accepted","projectId":"created"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
await db.auditLog.create({ data: { actor: 'sven@nordlicht.digital', action: 'estimate.accept', entity: 'Estimate', entityId: 'seed', after: '{"status":"accepted","projectId":"created"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
||||||
|
|
||||||
|
// --- Accounting: Bank transactions (CAMT.053 style) ---------------------
|
||||||
|
const bankTx = [
|
||||||
|
{ amountCents: CENTS(5400), bookingDate: daysFromNow(-1), valueDate: daysFromNow(-1), partnerName: 'Nordwind Logistik GmbH', partnerIban: 'DE89 3704 0044 0532 0130 00', reference: 'RE-2026-0039', purpose: 'SEPA-Überweisung RE-2026-0039', matchedStatus: 'suggested', matchReason: 'Referenz matched RE-2026-0039, Betrag exakt' },
|
||||||
|
{ amountCents: CENTS(420), bookingDate: daysFromNow(-3), valueDate: daysFromNow(-3), partnerName: 'HanseMöbel Manufaktur GmbH', partnerIban: 'DE12 5001 0517 0000 0000 00', reference: 'RE-2026-0040', purpose: 'Wartungspauschale Oktober', matchedStatus: 'suggested', matchReason: 'Referenz matched, Betrag +/- 0,02€ Toleranz' },
|
||||||
|
{ amountCents: -CENTS(89), bookingDate: daysFromNow(-2), valueDate: daysFromNow(-2), partnerName: 'Cloudflare Inc.', partnerIban: null, reference: 'CC-2026-5512', purpose: 'Cloudflare Pro Abonnement', matchedStatus: 'unmatched' },
|
||||||
|
{ amountCents: -CENTS(142), bookingDate: daysFromNow(-4), valueDate: daysFromNow(-4), partnerName: 'Deutsche Bahn AG', partnerIban: null, reference: 'DB-2026-8841', purpose: 'Hamburg → Berlin ICE', matchedStatus: 'unmatched' },
|
||||||
|
{ amountCents: CENTS(8800), bookingDate: daysFromNow(-12), valueDate: daysFromNow(-11), partnerName: 'TechStart UG', partnerIban: 'DE45 1004 0000 0123 4567 89', reference: 'RE-2026-0036', purpose: 'Rechnung RE-2026-0036 SaaS MVP', matchedStatus: 'matched', matchReason: 'Manuell zugeordnet' },
|
||||||
|
{ amountCents: -CENTS(44), bookingDate: daysFromNow(-25), valueDate: daysFromNow(-25), partnerName: 'GitHub Inc.', partnerIban: null, reference: 'GH-TEAM-2026', purpose: 'GitHub Team 10 Seats', matchedStatus: 'unmatched' },
|
||||||
|
{ amountCents: -CENTS(899), bookingDate: daysFromNow(-8), valueDate: daysFromNow(-8), partnerName: 'JetBrains s.r.o.', partnerIban: null, reference: 'JB-2026-APPPACK', purpose: 'All Products Pack 5 Seats', matchedStatus: 'unmatched' },
|
||||||
|
{ amountCents: CENTS(1500), bookingDate: daysFromNow(-30), valueDate: daysFromNow(-29), partnerName: 'Nordwind Logistik GmbH', partnerIban: 'DE89 3704 0044 0532 0130 00', reference: 'RE-2026-0035', purpose: 'Abschlagszahlung Projekt', matchedStatus: 'matched', matchReason: 'Manuell zugeordnet' },
|
||||||
|
{ amountCents: -CENTS(180), bookingDate: daysFromNow(-3), valueDate: daysFromNow(-3), partnerName: 'Sectigo', partnerIban: null, reference: 'SC-WILDCARD-2026', purpose: 'SSL Wildcard-Zertifikat', matchedStatus: 'unmatched' },
|
||||||
|
{ amountCents: -CENTS(67), bookingDate: daysFromNow(-18), valueDate: daysFromNow(-18), partnerName: 'Otto Office GmbH', partnerIban: null, reference: 'OO-2026-Q4', purpose: 'Büromaterial Q4', matchedStatus: 'unmatched' },
|
||||||
|
]
|
||||||
|
for (const t of bankTx) {
|
||||||
|
await db.bankTransaction.create({ data: t })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Accounting: VAT returns (Umsatzsteuer-Voranmeldung) -----------------
|
||||||
|
// Past 3 months computed from invoices (mocked with realistic Kennzahlen)
|
||||||
|
const vatReturns = [
|
||||||
|
{ periodYear: 2026, periodMonth: 7, periodType: 'monthly', status: 'accepted', k81StandardNetCents: CENTS(4800), k86ReducedNetCents: 0, k66TaxFreeCents: 0, k83TaxStandardCents: CENTS(912), k65TaxReducedCents: 0, k37InputTaxCents: CENTS(180), outputTaxCents: CENTS(912), inputTaxCents: CENTS(180), payableCents: CENTS(732), submittedAt: daysFromNow(-85), elsterRef: 'ERiC-2026-07-NORDLICHT-0001', acceptedAt: daysFromNow(-83) },
|
||||||
|
{ periodYear: 2026, periodMonth: 8, periodType: 'monthly', status: 'accepted', k81StandardNetCents: CENTS(6200), k86ReducedNetCents: 0, k66TaxFreeCents: 0, k83TaxStandardCents: CENTS(1178), k65TaxReducedCents: 0, k37InputTaxCents: CENTS(240), outputTaxCents: CENTS(1178), inputTaxCents: CENTS(240), payableCents: CENTS(938), submittedAt: daysFromNow(-55), elsterRef: 'ERiC-2026-08-NORDLICHT-0002', acceptedAt: daysFromNow(-53) },
|
||||||
|
{ periodYear: 2026, periodMonth: 9, periodType: 'monthly', status: 'accepted', k81StandardNetCents: CENTS(5400), k86ReducedNetCents: 0, k66TaxFreeCents: 0, k83TaxStandardCents: CENTS(1026), k65TaxReducedCents: 0, k37InputTaxCents: CENTS(310), outputTaxCents: CENTS(1026), inputTaxCents: CENTS(310), payableCents: CENTS(716), submittedAt: daysFromNow(-25), elsterRef: 'ERiC-2026-09-NORDLICHT-0003', acceptedAt: daysFromNow(-23) },
|
||||||
|
// current month — computed but not yet submitted
|
||||||
|
{ periodYear: 2026, periodMonth: 10, periodType: 'monthly', status: 'computed', k81StandardNetCents: CENTS(7200), k86ReducedNetCents: 0, k66TaxFreeCents: 0, k83TaxStandardCents: CENTS(1368), k65TaxReducedCents: 0, k37InputTaxCents: CENTS(420), outputTaxCents: CENTS(1368), inputTaxCents: CENTS(420), payableCents: CENTS(948) },
|
||||||
|
]
|
||||||
|
for (const v of vatReturns) {
|
||||||
|
await db.vatReturn.create({ data: v })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Dunning runs (Mahnwesen) ---------------------------------------------
|
||||||
|
// the overdue TechStart invoice RE-2026-0036 got a level 1 dunning
|
||||||
|
const overdueInv = await db.invoice.findFirst({ where: { status: 'overdue' } })
|
||||||
|
if (overdueInv) {
|
||||||
|
await db.dunningRun.create({ data: { invoiceId: overdueInv.id, level: 1, status: 'sent', feesCents: CENTS(5), sentAt: daysFromNow(-7) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// audit entries for accounting
|
||||||
|
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'vat.compute', entity: 'VatReturn', entityId: 'seed', after: '{"period":"2026-10","payable":94800}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
||||||
|
await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'vat.submit:elster', entity: 'VatReturn', entityId: 'seed', after: '{"elsterRef":"ERiC-2026-09","status":"accepted"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } })
|
||||||
|
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) } })
|
||||||
|
|
||||||
console.log('Seed complete.')
|
console.log('Seed complete.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type ViewId =
|
|||||||
| 'contracts'
|
| 'contracts'
|
||||||
| 'expenses'
|
| 'expenses'
|
||||||
| 'finance'
|
| 'finance'
|
||||||
|
| 'accounting'
|
||||||
| 'audit'
|
| 'audit'
|
||||||
| 'reporting'
|
| 'reporting'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
|
|||||||
Reference in new issue
Block a user