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

+175
View File
@@ -0,0 +1,175 @@
import { timingSafeEqual } from "node:crypto";
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/prisma";
import { generateInvoicePdf } from "@/lib/invoice-pdf";
import { readStoredInvoicePdf } from "@/lib/invoice-storage";
import { vatBreakdown, type VatBreakdownLine } from "@/lib/vat";
import { isLocale, defaultLocale } from "@/i18n/config";
/**
* Nur lesender Export der Rechnungen fuer die Buchhaltung (ERPNext unter
* collab.vycept.com).
*
* ERPNext holt die Rechnungen stuendlich ab, legt sie dort als
* Ausgangsrechnung mit angehaengtem PDF an und ordnet ihnen die
* Stripe-Zahlung zu (ueber den PaymentIntent). Rechnungen entstehen weiterhin
* nur hier im Shop; ERPNext uebernimmt Nummer, Positionen und die
* Umsatzsteuer je Satz unveraendert. Deshalb steht die Aufteilung je Satz
* (inklusive Versand- und Rabattanteil) fertig im Export: ERPNext rechnet sie
* nicht nach, sonst koennten Beleg und Buchung um einen Cent auseinanderlaufen.
*
* Zugang nur mit ERP_EXPORT_TOKEN als Bearer-Token. Ohne Token oder mit einem
* zu kurzen ist der Export abgeschaltet.
*/
export const MIN_TOKEN_LENGTH = 32;
export const MAX_PAGE_SIZE = 200;
const INVOICE_NUMBER_PATTERN = /^RE-\d{4}-\d{4,}$/;
export function erpExportAuthorized(header: string | null, token = process.env.ERP_EXPORT_TOKEN): boolean {
if (!token || token.length < MIN_TOKEN_LENGTH || !header?.startsWith("Bearer ")) return false;
const given = Buffer.from(header.slice("Bearer ".length));
const expected = Buffer.from(token);
return given.length === expected.length && timingSafeEqual(given, expected);
}
export function isInvoiceNumber(value: string): boolean {
return INVOICE_NUMBER_PATTERN.test(value);
}
/** `seit` (ISO-Zeitpunkt, einschliesslich) und `limit` (1 bis MAX_PAGE_SIZE); null bei ungueltigen Werten. */
export function parseErpQuery(params: URLSearchParams): { since: Date; limit: number } | null {
const since = new Date(params.get("seit") ?? "");
const limit = Number(params.get("limit") ?? MAX_PAGE_SIZE);
if (Number.isNaN(since.getTime()) || !Number.isInteger(limit) || limit < 1 || limit > MAX_PAGE_SIZE) return null;
return { since, limit };
}
const invoiceInclude = {
order: {
include: {
items: { include: { product: { select: { slug: true, category: true } } } },
payment: { select: { provider: true, stripePaymentIntentId: true, status: true } },
discountCode: { select: { code: true } },
},
},
} satisfies Prisma.InvoiceInclude;
type ErpInvoiceRecord = Prisma.InvoiceGetPayload<{ include: typeof invoiceInclude }>;
export interface ErpVatLine extends VatBreakdownLine {
/** Darin enthaltener Versandanteil (brutto). */
shippingCents: number;
/** Davon abgezogener Rabattanteil (brutto). */
discountCents: number;
}
/**
* Umsatzsteuer je Satz wie auf der Rechnung (lib/vat.ts), dazu der Versand-
* und der Rabattanteil je Satz. Beide Anteile ergeben sich aus derselben
* Rechnung ohne den jeweils anderen Betrag, also ohne eigene Verteilungslogik.
* null, wenn eine Position keinen Steuersatz hat (Bestellungen aus der Zeit
* vor der Steuerausweisung).
*/
export function erpVatLines(order: {
items: ReadonlyArray<{ totalCents: number; vatRatePercent: number | null }>;
shippingCents: number;
discountCents: number;
}): ErpVatLine[] | null {
const items: { totalCents: number; vatRatePercent: number }[] = [];
for (const item of order.items) {
if (item.vatRatePercent == null) return null;
items.push({ totalCents: item.totalCents, vatRatePercent: item.vatRatePercent });
}
const full = vatBreakdown({ items, shippingCents: order.shippingCents, discountCents: order.discountCents });
const plain = vatBreakdown({ items, shippingCents: 0, discountCents: 0 });
const withShipping = vatBreakdown({ items, shippingCents: order.shippingCents, discountCents: 0 });
const withDiscount = vatBreakdown({ items, shippingCents: 0, discountCents: order.discountCents });
return full.map((line, index) => ({
...line,
shippingCents: withShipping[index].grossCents - plain[index].grossCents,
discountCents: plain[index].grossCents - withDiscount[index].grossCents,
}));
}
/** Rechnung fuer ERPNext; bewusst ohne Telefonnummer, Postnummer und Notizen (nicht buchungsrelevant). */
export function toErpInvoice(invoice: ErpInvoiceRecord) {
const { order } = invoice;
return {
invoiceNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt.toISOString(),
totalCents: invoice.totalCents,
order: {
id: order.id,
orderNumber: order.orderNumber,
createdAt: order.createdAt.toISOString(),
fulfillmentMethod: order.fulfillmentMethod,
currency: order.currency,
subtotalCents: order.subtotalCents,
shippingCents: order.shippingCents,
discountCents: order.discountCents,
discountCode: order.discountCode?.code ?? null,
totalCents: order.totalCents,
},
payment: order.payment
? { provider: order.payment.provider, stripePaymentIntentId: order.payment.stripePaymentIntentId, status: order.payment.status }
: null,
customer: {
shopCustomerId: order.customerId,
email: order.email,
firstName: order.firstName,
lastName: order.lastName,
street: order.street,
zip: order.zip,
city: order.city,
country: order.country,
},
items: order.items.map((item) => ({
productId: item.productId,
productSlug: item.product.slug,
category: item.product.category,
productName: item.productName,
quantity: item.quantity,
unitPriceCents: item.unitPriceCents,
totalCents: item.totalCents,
weightGrams: item.weightGrams,
vatRatePercent: item.vatRatePercent,
})),
vat: erpVatLines(order),
};
}
export type ErpInvoice = ReturnType<typeof toErpInvoice>;
export async function listErpInvoices(since: Date, limit: number): Promise<ErpInvoice[]> {
const invoices = await prisma.invoice.findMany({
where: { issuedAt: { gte: since } },
orderBy: [{ issuedAt: "asc" }, { id: "asc" }],
take: limit,
include: invoiceInclude,
});
return invoices.map(toErpInvoice);
}
/**
* Das PDF so, wie die Kundschaft es bekommen hat: zuerst das abgelegte
* Dokument, nur ohne Ablage neu erzeugt (wie /rechnung/<nummer>/pdf).
*/
export async function erpInvoicePdf(invoiceNumber: string): Promise<Buffer | null> {
const invoice = await prisma.invoice.findUnique({
where: { invoiceNumber },
include: { order: { include: { items: true, discountCode: { select: { code: true } } } } },
});
if (!invoice) return null;
const stored = invoice.pdfPath ? await readStoredInvoicePdf(invoice.pdfPath) : null;
return (
stored ??
(await generateInvoicePdf({
locale: isLocale(invoice.order.locale) ? invoice.order.locale : defaultLocale,
invoiceNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt,
totalCents: invoice.totalCents,
order: invoice.order,
}))
);
}