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; export async function listErpInvoices(since: Date, limit: number): Promise { 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//pdf). Gilt * fuer Rechnungen (RE-…) und Stornorechnungen (ST-…). */ export async function erpInvoicePdf(invoiceNumber: string): Promise { 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, })) ); }