Files
Wabenhain/src/lib/erp-export.ts
T
Nick SzaunigandClaude Opus 5.5 4d1bcbbdb7 feat(invoice): Stornorechnung und Rechnungskopie an Admin-Adresse
Rechnungskopie: unter Einstellungen > Firma > "Rechnungskopie an" laesst sich
eine Adresse hinterlegen; jede Rechnung und Stornorechnung geht zusaetzlich
als eigene Mail mit derselben E-Rechnung dorthin (lib/invoice-mail.ts).

Stornorechnung: eine Rechnung wird nie geloescht. Bei Stornierung im Admin,
erstatteter Retoure oder voller Erstattung bei Stripe entsteht genau eine
Stornorechnung ST-JJJJ-NNNN (eigener lueckenloser Nummernkreis) mit allen
Positionen und Bezug auf die Rechnung, als E-Rechnung (Gutschrift 381) an
die Kundschaft und als Kopie. Gleichzeitige Ausloeser (Admin-Storno und
Stripe-Webhook derselben Erstattung) enden bei einer Stornorechnung.
Bestellungen mit Stornorechnung lassen sich nicht wieder oeffnen.

ERP-Export: GET /api/erp/rechnungen?art=storno liefert Stornorechnungen mit
Bezug (cancels), Rechnungen tragen cancelledBy; PDFs auch fuer ST-Nummern.
Admin-Bestelldetail zeigt die Stornorechnung mit Download.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:19:42 +02:00

193 lines
7.6 KiB
TypeScript

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 { vatLinesWithShares, type VatLineWithShares } from "@/lib/vat";
import { cancellationPdf, isCancellationNumber } from "@/lib/cancellation-invoice";
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);
}
/** Rechnung (RE-…) oder Stornorechnung (ST-…). */
export function isErpDocumentNumber(value: string): boolean {
return isInvoiceNumber(value) || isCancellationNumber(value);
}
/** Welche Belege: "rechnung" (Vorgabe, wie bisher) oder "storno" (Stornorechnungen). */
export type ErpDocumentKind = "rechnung" | "storno";
/**
* `seit` (ISO-Zeitpunkt, einschliesslich), `limit` (1 bis MAX_PAGE_SIZE) und
* `art` (rechnung/storno); null bei ungueltigen Werten. Stornorechnungen
* kommen bewusst nur auf ausdrueckliche Nachfrage: ein aelterer Import, der
* sie nicht kennt, saehe sie sonst als weitere Rechnung.
*/
export function parseErpQuery(params: URLSearchParams): { since: Date; limit: number; kind: ErpDocumentKind } | null {
const since = new Date(params.get("seit") ?? "");
const limit = Number(params.get("limit") ?? MAX_PAGE_SIZE);
const kind = params.get("art") ?? "rechnung";
if (Number.isNaN(since.getTime()) || !Number.isInteger(limit) || limit < 1 || limit > MAX_PAGE_SIZE) return null;
if (kind !== "rechnung" && kind !== "storno") return null;
return { since, limit, kind };
}
const invoiceInclude = {
order: {
include: {
items: { include: { product: { select: { slug: true, category: true } } } },
payment: { select: { provider: true, stripePaymentIntentId: true, status: true } },
discountCode: { select: { code: true } },
},
},
cancellation: { select: { number: true } },
} satisfies Prisma.InvoiceInclude;
type ErpInvoiceRecord = Prisma.InvoiceGetPayload<{ include: typeof invoiceInclude }>;
export type ErpVatLine = VatLineWithShares;
/** Umsatzsteuer je Satz mit Versand- und Rabattanteil, siehe lib/vat.ts. */
export const erpVatLines = vatLinesWithShares;
/** Rechnung fuer ERPNext; bewusst ohne Telefonnummer, Postnummer und Notizen (nicht buchungsrelevant). */
export function toErpInvoice(invoice: ErpInvoiceRecord) {
const { order } = invoice;
return {
type: "invoice" as "invoice" | "cancellation",
invoiceNumber: invoice.invoiceNumber,
issuedAt: invoice.issuedAt.toISOString(),
totalCents: invoice.totalCents,
/** Nummer der Stornorechnung, falls die Rechnung aufgehoben ist. */
cancelledBy: invoice.cancellation?.number ?? null,
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);
}
/**
* Stornorechnungen fuer ERPNext, aelteste zuerst. Gleiche Form wie die
* Rechnung (Positionen und Steuer je Satz der aufgehobenen Rechnung, positive
* Betraege), dazu `cancels` mit Nummer und Datum der Rechnung. ERPNext bucht
* daraus die Rechnungskorrektur gegen die Ausgangsrechnung.
*/
export async function listErpCancellations(since: Date, limit: number) {
const cancellations = await prisma.cancellationInvoice.findMany({
where: { issuedAt: { gte: since } },
orderBy: [{ issuedAt: "asc" }, { id: "asc" }],
take: limit,
include: { invoice: { include: invoiceInclude } },
});
return cancellations.map((cancellation) => ({
...toErpInvoice(cancellation.invoice),
type: "cancellation" as const,
invoiceNumber: cancellation.number,
issuedAt: cancellation.issuedAt.toISOString(),
totalCents: cancellation.totalCents,
cancelledBy: null,
cancels: { invoiceNumber: cancellation.invoice.invoiceNumber, issuedAt: cancellation.invoice.issuedAt.toISOString() },
reason: cancellation.reason,
}));
}
/**
* Das PDF so, wie die Kundschaft es bekommen hat: zuerst das abgelegte
* Dokument, nur ohne Ablage neu erzeugt (wie /rechnung/<nummer>/pdf). Gilt
* fuer Rechnungen (RE-…) und Stornorechnungen (ST-…).
*/
export async function erpInvoicePdf(invoiceNumber: string): Promise<Buffer | null> {
if (isCancellationNumber(invoiceNumber)) return cancellationPdf(invoiceNumber);
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,
}))
);
}