1bc42b25-8a92-4b1e-9e45-a4dba3d1ac57

This commit is contained in:
Z User committed 2026-10-26 14:41:13 +00:00
1 parent 5855f699f3
commit d4c3b0f5ca
21 files changed
+1140

No files matched your search

@@ -0,0 +1,46 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// PATCH /api/aos/contracts/[id]/sign — e-signature hook (mock)
// Transitions draft/sent → signed, records signer + IP + token, writes audit.
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const body = (await req.json().catch(() => ({}))) as { signedBy?: string }
const contract = await db.contract.findUnique({ where: { id } })
if (!contract) return NextResponse.json({ error: 'not found' }, { status: 404 })
if (contract.status === 'signed' || contract.status === 'active') {
return NextResponse.json({ error: 'already signed' }, { status: 409 })
}
const token = 'sig_' + Math.random().toString(36).slice(2, 14)
const updated = await db.contract.update({
where: { id },
data: {
status: 'signed',
signedAt: new Date(),
signedBy: body.signedBy ?? 'Kunde (via Portal)',
signedIp: '91.64.x.x (portal)',
signToken: token,
},
})
await db.auditLog.create({
data: {
actor: 'nora@nordlicht.digital',
action: 'contract.sign',
entity: 'Contract',
entityId: id,
before: JSON.stringify({ status: contract.status }),
after: JSON.stringify({ status: 'signed', signedBy: updated.signedBy, token }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ contract: updated })
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/contracts — contracts list with company, expiry stats
export async function GET() {
const contracts = await db.contract.findMany({
orderBy: { updatedAt: 'desc' },
})
// enrich with company names (separate query to keep SQLite happy)
const companyIds = [...new Set(contracts.map((c) => c.companyId))]
const companies = await db.company.findMany({ where: { id: { in: companyIds } }, select: { id: true, name: true } })
const companyMap = new Map(companies.map((c) => [c.id, c.name]))
const enriched = contracts.map((c) => ({ ...c, companyName: companyMap.get(c.companyId) ?? '—' }))
const stats = {
total: contracts.length,
active: contracts.filter((c) => c.status === 'active').length,
draft: contracts.filter((c) => c.status === 'draft').length,
sent: contracts.filter((c) => c.status === 'sent').length,
signed: contracts.filter((c) => c.status === 'signed' || c.status === 'active').length,
expired: contracts.filter((c) => c.status === 'expired').length,
expiringSoon: contracts.filter((c) => {
if (!c.endDate || c.status === 'expired') return false
const days = (c.endDate.getTime() - Date.now()) / 86400000
return days <= 30 && days >= 0
}).length,
totalValueCents: contracts.filter((c) => c.status === 'active').reduce((s, c) => s + c.valueCents, 0),
}
return NextResponse.json({ contracts: enriched, stats })
}
@@ -0,0 +1,85 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// POST /api/aos/expenses/[id]/convert — convert a billable expense into an invoice
// (adapted from PerfEx "Converting Billable Expense to Invoice")
export async function POST(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const expense = await db.expense.findUnique({ where: { id } })
if (!expense) return NextResponse.json({ error: 'not found' }, { status: 404 })
if (!expense.billable) return NextResponse.json({ error: 'expense not billable' }, { status: 409 })
if (expense.status === 'invoiced') return NextResponse.json({ error: 'already invoiced' }, { status: 409 })
if (!expense.companyId) return NextResponse.json({ error: 'no customer linked' }, { status: 409 })
// compute re-bill amount with markup
const markupMultiplier = 1 + expense.markupPct / 100
const netCents = Math.round(expense.amountCents * markupMultiplier)
const taxBp = expense.taxBp
const grossCents = Math.round(netCents * (1 + taxBp / 10000))
// gapless invoice number
const year = new Date().getFullYear()
const count = await db.invoice.count({ where: { number: { startsWith: `RE-${year}-` } } })
const number = `RE-${year}-${String(count + 1).padStart(4, '0')}`
const dueDate = new Date()
dueDate.setDate(dueDate.getDate() + 14)
const invoice = await db.invoice.create({
data: {
number,
companyId: expense.companyId,
status: 'draft',
type: 'final',
issueDate: new Date(),
dueDate,
netCents,
taxBp,
grossCents,
zugferd: false,
xrechnung: false,
datevExported: false,
projectId: expense.projectId,
},
})
// one line item describing the expense
const desc = expense.markupPct > 0
? `${expense.description} (Fremdleistung +${expense.markupPct}% Service-Pauschale)`
: expense.description
await db.invoiceLine.create({
data: {
invoiceId: invoice.id,
description: desc,
quantity: 1,
unitCents: netCents,
totalCents: netCents,
},
})
// mark expense as invoiced
const updated = await db.expense.update({
where: { id },
data: { status: 'invoiced', convertedInvoiceId: invoice.id },
})
await db.auditLog.create({
data: {
actor: 'nora@nordlicht.digital',
action: 'expense.convert:invoice',
entity: 'Expense',
entityId: id,
before: JSON.stringify({ status: expense.status, amountCents: expense.amountCents }),
after: JSON.stringify({ status: 'invoiced', invoiceId: invoice.id, invoiceNumber: number, netCents }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ expense: updated, invoice })
}
+57
View File
@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
// GET /api/aos/expenses — expenses list with company/project, stats, reports
export async function GET() {
const expenses = await db.expense.findMany({ orderBy: { date: 'desc' } })
const companyIds = [...new Set(expenses.map((e) => e.companyId).filter(Boolean) as string[])]
const projectIds = [...new Set(expenses.map((e) => e.projectId).filter(Boolean) as string[])]
const [companies, projects] = await Promise.all([
db.company.findMany({ where: { id: { in: companyIds } }, select: { id: true, name: true } }),
db.project.findMany({ where: { id: { in: projectIds } }, select: { id: true, name: true } }),
])
const companyMap = new Map(companies.map((c) => [c.id, c.name]))
const projectMap = new Map(projects.map((p) => [p.id, p.name]))
const enriched = expenses.map((e) => ({
...e,
companyName: e.companyId ? companyMap.get(e.companyId) ?? null : null,
projectName: e.projectId ? projectMap.get(e.projectId) ?? null : null,
}))
const stats = {
total: expenses.length,
billable: expenses.filter((e) => e.billable).length,
recurring: expenses.filter((e) => e.recurring).length,
billableCents: expenses.filter((e) => e.billable && e.status !== 'invoiced').reduce((s, e) => s + e.grossCents, 0),
totalGrossCents: expenses.reduce((s, e) => s + e.grossCents, 0),
invoiced: expenses.filter((e) => e.status === 'invoiced').length,
pendingApproval: expenses.filter((e) => e.status === 'recorded').length,
datevExported: expenses.filter((e) => e.datevExported).length,
}
// expense report: by category
const byCategory: Record<string, { count: number; grossCents: number }> = {}
for (const e of expenses) {
if (!byCategory[e.categoryId]) byCategory[e.categoryId] = { count: 0, grossCents: 0 }
byCategory[e.categoryId].count++
byCategory[e.categoryId].grossCents += e.grossCents
}
// monthly (last 6 months)
const now = new Date()
const monthly: { label: string; cents: 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 cents = expenses
.filter((e) => e.date.getMonth() === m.getMonth() && e.date.getFullYear() === m.getFullYear())
.reduce((s, e) => s + e.grossCents, 0)
monthly.push({ label, cents })
}
return NextResponse.json({ expenses: enriched, stats, report: { byCategory, monthly } })
}
+2
View File
@@ -15,6 +15,8 @@ export async function POST() {
try {
// wipe via prisma (SQLite TRUNCATE not supported, use deleteMany per model in order)
const { db } = await import('@/lib/db')
await db.expense.deleteMany()
await db.contract.deleteMany()
await db.invoiceLine.deleteMany()
await db.invoice.deleteMany()
await db.asset.deleteMany()