feat: Invoice PDF + Beleg file upload with content-addressed storage

2 new features:

1. Invoice PDF (GET /api/aos/invoices/[id]/pdf)
   - Generates German invoice as print-ready HTML (browser → Print to PDF)
   - Real agency header (name, legal form, USt-IdNr, register, bank, BIC from settings)
   - Customer block, line items table, net/tax/gross totals
   - ZUGFeRD/XRechnung badges if applicable
   - German number/date formatting (1.234,56 € / 27.10.2026)
   - GoBD: no-store cache header
   - 3.2KB per invoice, contains RECHNUNG + Gesamt
   (Production: Typst for server-side PDF generation per PRD §5.2)

2. Beleg file upload (POST /api/aos/belege/upload)
   - Multipart/form-data file upload
   - Content-addressed storage (SHA-256 hash, atomic write, dedup)
   - Magic number content type detection (PDF, PNG, JPEG, etc.)
   - Duplicate detection (same hash → 409)
   - GoBD retention (8 years for invoices, 6 for others)
   - Audit-logged
   - Tested: upload OK, duplicate → 409

0 TS errors, ESLint clean.
This commit is contained in:
Z User committed 2026-10-28 01:24:48 +00:00
1 parent 2aa3496a90
commit de6573b15e
117 files changed
+271 -1

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { storeFile, detectContentType } from '@/lib/storage'
import { z } from 'zod'
import { checkRateLimit } from '@/lib/rate-limit'
export const dynamic = 'force-dynamic'
export const maxDuration = 30
// POST /api/aos/belege/upload
// Upload a receipt file with content-addressed storage (PRD §5.5 + §5.7 Belege).
// Accepts multipart/form-data with file + metadata.
export async function POST(req: Request) {
try {
const rateLimited = checkRateLimit(req, 'public')
if (rateLimited) return rateLimited
const formData = await req.formData()
const file = formData.get('file') as File | null
const type = formData.get('type') as string | null
const vendor = formData.get('vendor') as string | null
const invoiceNumber = formData.get('invoiceNumber') as string | null
const amountCentsStr = formData.get('amountCents') as string | null
const bookingDateStr = formData.get('bookingDate') as string | null
if (!file) {
return NextResponse.json({ error: 'file required (multipart/form-data, field name: file)' }, { status: 400 })
}
// Read file content
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
// Detect content type from magic bytes
const detectedType = detectContentType(buffer)
const fileName = file.name
const fileType = file.type || detectedType
const fileSizeBytes = buffer.length
// Store file (content-addressed, dedup, atomic write)
const stored = await storeFile(buffer, fileType)
// Check for duplicate (same hash = same file already registered as Beleg)
const existing = await db.beleg.findUnique({ where: { fileHash: stored.hash } })
if (existing) {
return NextResponse.json({
beleg: existing,
duplicate: true,
message: 'File already registered (same SHA-256 hash)',
}, { status: 409 })
}
// Create Beleg record
const count = await db.beleg.count()
const number = `BEL-2026-${String(count + 1).padStart(4, '0')}`
const belegType = type || 'sonstiges'
// GoBD retention: 8 years for invoices, 6 for business letters
const retentionUntil = new Date()
retentionUntil.setFullYear(retentionUntil.getFullYear() + (belegType.includes('rechnung') ? 8 : 6))
const beleg = await db.beleg.create({
data: {
number,
type: belegType,
fileHash: stored.hash,
fileName,
fileType,
fileSizeBytes,
vendor: vendor ?? null,
invoiceNumber: invoiceNumber ?? null,
amountCents: amountCentsStr ? parseInt(amountCentsStr) : null,
bookingDate: bookingDateStr ? new Date(bookingDateStr) : null,
retentionUntil,
},
})
await db.auditLog.create({
data: {
actor: 'nora@nordlicht.digital',
action: 'beleg.upload',
entity: 'Beleg',
entityId: beleg.id,
after: JSON.stringify({ number, type: belegType, fileName, hash: stored.hash.slice(0, 16) + '…', sizeBytes: fileSizeBytes }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({
beleg,
storage: {
hash: stored.hash,
sizeBytes: stored.size,
contentType: stored.contentType,
detectedType,
deduped: stored.size !== fileSizeBytes, // if size differs, existing file was returned
},
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
View File
File mode changed.
+165
View File
@@ -0,0 +1,165 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
export const maxDuration = 15
// GET /api/aos/invoices/[id]/pdf
// Generates a real PDF invoice (German layout) using HTML → print-ready format.
// Returns the invoice as an HTML page that browsers can "Print to PDF".
// In production, this would use Typst (PRD §5.2) for server-side PDF generation.
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const invoice = await db.invoice.findUnique({
where: { id },
include: { company: true, lines: true },
})
if (!invoice) {
return NextResponse.json({ error: 'invoice not found' }, { status: 404 })
}
// Get agency settings for the invoice header
const settings = await db.setting.findMany()
const getSetting = (key: string) => settings.find((s) => s.key === key)?.value ?? ''
const agencyName = getSetting('company.name') || 'Nordlicht Digital GmbH'
const agencyLegalForm = getSetting('company.legalForm') || 'GmbH'
const agencyVatId = getSetting('company.vatId') || ''
const agencyRegisterCourt = getSetting('company.registerCourt') || ''
const agencyRegisterNumber = getSetting('company.registerNumber') || ''
const agencyCity = getSetting('company.city') || ''
const agencyBank = getSetting('company.bank') || ''
const agencyBic = getSetting('company.bic') || ''
// Generate German invoice HTML (print-ready)
const html = generateInvoiceHTML({
invoice,
agency: { name: agencyName, legalForm: agencyLegalForm, vatId: agencyVatId, registerCourt: agencyRegisterCourt, registerNumber: agencyRegisterNumber, city: agencyCity, bank: agencyBank, bic: agencyBic },
})
return new Response(html, {
headers: {
'content-type': 'text/html; charset=utf-8',
'content-disposition': `inline; filename="${invoice.number}.html"`,
'cache-control': 'no-store',
},
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
function generateInvoiceHTML({ invoice, agency }: {
invoice: {
number: string; status: string; type: string; issueDate: Date; dueDate: Date;
netCents: number; taxBp: number; grossCents: number;
zugferd: boolean; xrechnung: boolean;
company: { name: string; legalForm: string | null; city: string | null; vatId: string | null };
lines: { description: string; quantity: number; unitCents: number; totalCents: number }[];
};
agency: { name: string; legalForm: string; vatId: string; registerCourt: string; registerNumber: string; city: string; bank: string; bic: string };
}): string {
const fmtDate = (d: Date) => d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
const fmtEuro = (cents: number) => (cents / 100).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'
const taxPct = (invoice.taxBp / 100).toFixed(0)
return `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Rechnung ${invoice.number}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; color: #111; line-height: 1.6; padding: 40px; max-width: 800px; margin: 0 auto; }
.header { display: flex; justify-content: space-between; margin-bottom: 40px; }
.agency h1 { font-size: 18px; font-weight: 700; }
.agency p { font-size: 12px; color: #666; }
.invoice-meta { text-align: right; }
.invoice-meta h2 { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
.invoice-meta p { font-size: 12px; color: #666; }
.customer { margin-bottom: 30px; }
.customer h3 { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
.customer p { font-size: 13px; color: #333; }
table { width: 100%; border-collapse: collapse; margin-bottom: 30px; }
th { text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #999; border-bottom: 2px solid #111; padding: 8px; }
td { font-size: 13px; padding: 10px 8px; border-bottom: 1px solid #eee; }
.right { text-align: right; }
.totals { width: 300px; margin-left: auto; }
.totals td { border: none; padding: 6px 8px; font-size: 13px; }
.totals .total { font-weight: 700; font-size: 16px; border-top: 2px solid #111; padding-top: 10px; }
.footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 11px; color: #999; }
.footer p { margin-bottom: 4px; }
@media print { body { padding: 0; } }
</style>
</head>
<body>
<div class="header">
<div class="agency">
<h1>${agency.name}</h1>
<p>${agency.city}</p>
<p>USt-IdNr.: ${agency.vatId}</p>
<p>${agency.registerCourt} ${agency.registerNumber}</p>
</div>
<div class="invoice-meta">
<h2>RECHNUNG</h2>
<p>Nr.: ${invoice.number}</p>
<p>Datum: ${fmtDate(invoice.issueDate)}</p>
<p>Fällig: ${fmtDate(invoice.dueDate)}</p>
${invoice.zugferd ? '<p style="font-weight:600">✓ ZUGFeRD</p>' : ''}
${invoice.xrechnung ? '<p style="font-weight:600">✓ XRechnung</p>' : ''}
</div>
</div>
<div class="customer">
<h3>Rechnungsempfänger</h3>
<p><strong>${invoice.company.name}</strong></p>
${invoice.company.legalForm ? `<p>${invoice.company.legalForm}</p>` : ''}
${invoice.company.city ? `<p>${invoice.company.city}</p>` : ''}
${invoice.company.vatId ? `<p>USt-IdNr.: ${invoice.company.vatId}</p>` : ''}
</div>
<table>
<thead>
<tr>
<th>Beschreibung</th>
<th class="right">Menge</th>
<th class="right">Einzelpreis</th>
<th class="right">Gesamt</th>
</tr>
</thead>
<tbody>
${invoice.lines.map((l) => `
<tr>
<td>${l.description}</td>
<td class="right">${l.quantity}</td>
<td class="right">${fmtEuro(l.unitCents)}</td>
<td class="right">${fmtEuro(l.totalCents)}</td>
</tr>`).join('')}
</tbody>
</table>
<table class="totals">
<tr><td>Netto</td><td class="right">${fmtEuro(invoice.netCents)}</td></tr>
<tr><td>USt (${taxPct}%)</td><td class="right">${fmtEuro(invoice.grossCents - invoice.netCents)}</td></tr>
<tr class="total"><td>Gesamt</td><td class="right">${fmtEuro(invoice.grossCents)}</td></tr>
</table>
<div class="footer">
<p><strong>${agency.name}</strong> · ${agency.city}</p>
<p>Bank: ${agency.bank}</p>
<p>BIC: ${agency.bic}</p>
<p>USt-IdNr.: ${agency.vatId}</p>
<p>Register: ${agency.registerCourt} ${agency.registerNumber}</p>
<p style="margin-top:10px">Rechnung Nr. ${invoice.number} vom ${fmtDate(invoice.issueDate)}. Fällig bis ${fmtDate(invoice.dueDate)}.</p>
</div>
</body>
</html>`
}
View File
File mode changed.
View File
File mode changed.
Regular → Executable
View File
File mode changed.