feat(erp): read-only invoice export for ERPNext

ERPNext (collab.vycept.com) now picks up every shop invoice with its PDF,
books it as a Wabenhain sales invoice and matches it to the Stripe payment
via the PaymentIntent, so fees, revenue and the invoice document sit on one
booking.

- GET /api/erp/rechnungen?seit=<ISO>&limit=<1-200>: invoices oldest first,
  with order, payment intent, customer address (no phone, no notes), items
  and the VAT split per rate exactly as printed (lib/vat.ts), including the
  shipping and discount share per rate, so ERPNext books the same cents.
- GET /api/erp/rechnungen/<RE-YYYY-NNNN>/pdf: the stored invoice PDF.
- Bearer token ERP_EXPORT_TOKEN (min. 32 chars, timing-safe compare);
  without it the export is off. Failed attempts are rate limited.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-10 04:39:11 +02:00
1 parent 7698d4cef3
commit b246c9ff4e
6 files changed
+417

No files matched your search

@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { erpExportAuthorized, erpInvoicePdf, isInvoiceNumber } from "@/lib/erp-export";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
const FAILED_ATTEMPTS_PER_WINDOW = 20;
const FAILED_WINDOW_MS = 15 * 60 * 1000;
/** Rechnungs-PDF fuer ERPNext (Anhang an der Ausgangsrechnung). Nur mit ERP_EXPORT_TOKEN. */
export async function GET(request: Request, { params }: { params: Promise<{ invoiceNumber: string }> }) {
if (!erpExportAuthorized(request.headers.get("authorization"))) {
const ip = await getClientIp();
const status = rateLimit(`erp-export:${ip}`, FAILED_ATTEMPTS_PER_WINDOW, FAILED_WINDOW_MS) ? 401 : 429;
return NextResponse.json({ error: "Nicht berechtigt." }, { status });
}
const { invoiceNumber } = await params;
const pdf = isInvoiceNumber(invoiceNumber) ? await erpInvoicePdf(invoiceNumber) : null;
if (!pdf) {
return NextResponse.json({ error: "Rechnung nicht gefunden." }, { status: 404 });
}
return new NextResponse(new Uint8Array(pdf), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${invoiceNumber}.pdf"`,
"Cache-Control": "no-store",
},
});
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { erpExportAuthorized, listErpInvoices, parseErpQuery } from "@/lib/erp-export";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
const FAILED_ATTEMPTS_PER_WINDOW = 20;
const FAILED_WINDOW_MS = 15 * 60 * 1000;
/**
* Rechnungen fuer ERPNext, aelteste zuerst: GET /api/erp/rechnungen?seit=<ISO>&limit=<1-200>.
* Nur lesend und nur mit ERP_EXPORT_TOKEN, siehe lib/erp-export.ts.
*/
export async function GET(request: Request) {
if (!erpExportAuthorized(request.headers.get("authorization"))) {
const ip = await getClientIp();
const status = rateLimit(`erp-export:${ip}`, FAILED_ATTEMPTS_PER_WINDOW, FAILED_WINDOW_MS) ? 401 : 429;
return NextResponse.json({ error: "Nicht berechtigt." }, { status });
}
const query = parseErpQuery(new URL(request.url).searchParams);
if (!query) {
return NextResponse.json({ error: "seit (ISO-Zeitpunkt) und limit (1-200) pruefen." }, { status: 400 });
}
const invoices = await listErpInvoices(query.since, query.limit);
return NextResponse.json(
{ invoices, hasMore: invoices.length === query.limit },
{ headers: { "Cache-Control": "no-store" } }
);
}