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:
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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" } }
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user