diff --git a/.env.example b/.env.example index 2665f1f..d616c01 100644 --- a/.env.example +++ b/.env.example @@ -107,6 +107,13 @@ APICORE_API_KEY= # abgelehnt; der CronJob gleicht dann alle 10 Minuten ab. APICORE_WEBHOOK_SECRET= +# Rechnungsexport fuer die Buchhaltung (ERPNext, collab.vycept.com): ERPNext +# holt damit stuendlich alle Rechnungen samt PDF ab und ordnet sie der +# Stripe-Zahlung zu (GET /api/erp/rechnungen, nur lesend, siehe +# src/lib/erp-export.ts). Mindestens 32 Zeichen, z. B. `openssl rand -hex 32`; +# derselbe Wert steht in ERPNext als WABENHAIN_ERP_TOKEN. Leer = Export aus. +ERP_EXPORT_TOKEN= + # DHL Parcel DE Shipping API ("Post & Parcel Germany", developer.dhl.com) — # ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im # Admin-Bestelldetail (siehe src/lib/dhl.ts). Ohne diese fuenf Werte zeigt diff --git a/k8s/02-secret.example.yaml b/k8s/02-secret.example.yaml index b80e14b..6f116c8 100644 --- a/k8s/02-secret.example.yaml +++ b/k8s/02-secret.example.yaml @@ -115,6 +115,11 @@ stringData: # ab (alle 10 Minuten). APICORE_WEBHOOK_SECRET: "" + # Rechnungsexport fuer ERPNext (GET /api/erp/rechnungen, nur lesend): ERPNext + # holt damit die Rechnungen samt PDF ab. Mindestens 32 Zeichen, derselbe + # Wert steht in ERPNext als WABENHAIN_ERP_TOKEN. Leer = Export aus. + ERP_EXPORT_TOKEN: "" + # DHL-Versandmarken (Admin -> Bestellung -> "DHL-Versandmarke"), siehe # .env.example fuer die vollstaendige Einrichtung. Leer = der Knopf meldet # "noch nicht konfiguriert", der Rest des Shops ist unberuehrt. Ob Sandbox diff --git a/src/app/api/erp/rechnungen/[invoiceNumber]/pdf/route.ts b/src/app/api/erp/rechnungen/[invoiceNumber]/pdf/route.ts new file mode 100644 index 0000000..c97f65d --- /dev/null +++ b/src/app/api/erp/rechnungen/[invoiceNumber]/pdf/route.ts @@ -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", + }, + }); +} diff --git a/src/app/api/erp/rechnungen/route.ts b/src/app/api/erp/rechnungen/route.ts new file mode 100644 index 0000000..99453d5 --- /dev/null +++ b/src/app/api/erp/rechnungen/route.ts @@ -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=&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" } } + ); +} diff --git a/src/lib/erp-export.test.ts b/src/lib/erp-export.test.ts new file mode 100644 index 0000000..8754014 --- /dev/null +++ b/src/lib/erp-export.test.ts @@ -0,0 +1,176 @@ +/** + * Tests fuer den Rechnungsexport an ERPNext. + * + * Zwei Dinge duerfen hier nicht schiefgehen: der Export darf ohne den + * richtigen Schluessel nichts preisgeben, und die Umsatzsteuer je Satz muss + * genau der Rechnung entsprechen (ERPNext bucht sie unveraendert). + */ +import { describe, test, expect, vi, beforeEach } from "vitest"; + +const findMany = vi.fn(); +vi.mock("@/lib/prisma", () => ({ prisma: { invoice: { findMany: (...args: unknown[]) => findMany(...args) } } })); +vi.mock("@/lib/invoice-pdf", () => ({ generateInvoicePdf: vi.fn(async () => Buffer.from("pdf")) })); +vi.mock("@/lib/invoice-storage", () => ({ readStoredInvoicePdf: vi.fn(async () => null) })); + +import { + erpExportAuthorized, + erpVatLines, + isInvoiceNumber, + listErpInvoices, + parseErpQuery, + toErpInvoice, + MIN_TOKEN_LENGTH, +} from "./erp-export"; +import { invoiceVatBreakdown } from "./vat"; + +const TOKEN = "t".repeat(MIN_TOKEN_LENGTH); + +describe("erpExportAuthorized", () => { + test("laesst nur den genauen Bearer-Token durch", () => { + expect(erpExportAuthorized(`Bearer ${TOKEN}`, TOKEN)).toBe(true); + expect(erpExportAuthorized(`Bearer ${TOKEN}x`, TOKEN)).toBe(false); + expect(erpExportAuthorized(TOKEN, TOKEN)).toBe(false); + expect(erpExportAuthorized(null, TOKEN)).toBe(false); + }); + + test("ist ohne oder mit zu kurzem Token abgeschaltet", () => { + expect(erpExportAuthorized("Bearer ", "")).toBe(false); + expect(erpExportAuthorized("Bearer kurz", "kurz")).toBe(false); + expect(erpExportAuthorized(`Bearer ${TOKEN}`, undefined)).toBe(false); + }); +}); + +describe("parseErpQuery und isInvoiceNumber", () => { + test("verlangt einen gueltigen Zeitpunkt und begrenzt das Limit", () => { + expect(parseErpQuery(new URLSearchParams("seit=2026-01-01T00:00:00Z&limit=50"))).toEqual({ since: new Date("2026-01-01T00:00:00Z"), limit: 50 }); + expect(parseErpQuery(new URLSearchParams("seit=2026-01-01"))?.limit).toBe(200); + expect(parseErpQuery(new URLSearchParams("seit=gestern"))).toBeNull(); + expect(parseErpQuery(new URLSearchParams("seit=2026-01-01&limit=0"))).toBeNull(); + expect(parseErpQuery(new URLSearchParams("seit=2026-01-01&limit=201"))).toBeNull(); + }); + + test("erkennt nur Rechnungsnummern des Shops", () => { + expect(isInvoiceNumber("RE-2026-0001")).toBe(true); + expect(isInvoiceNumber("RE-2026-12345")).toBe(true); + expect(isInvoiceNumber("../RE-2026-0001")).toBe(false); + expect(isInvoiceNumber("RE-26-1")).toBe(false); + }); +}); + +describe("erpVatLines", () => { + const order = { + items: [ + { totalCents: 2370, vatRatePercent: 7 }, + { totalCents: 1250, vatRatePercent: 19 }, + ], + shippingCents: 590, + discountCents: 300, + }; + + test("entspricht der Steueraufstellung der Rechnung", () => { + const lines = erpVatLines(order)!; + const rechnung = invoiceVatBreakdown(order); + expect(lines.map(({ ratePercent, grossCents, netCents, vatCents }) => ({ ratePercent, grossCents, netCents, vatCents }))).toEqual(rechnung); + }); + + test("teilt Versand und Rabatt vollstaendig auf die Saetze auf", () => { + const lines = erpVatLines(order)!; + expect(lines.reduce((sum, l) => sum + l.shippingCents, 0)).toBe(590); + expect(lines.reduce((sum, l) => sum + l.discountCents, 0)).toBe(300); + for (const line of lines) { + const ware = order.items.filter((i) => i.vatRatePercent === line.ratePercent).reduce((s, i) => s + i.totalCents, 0); + expect(ware + line.shippingCents - line.discountCents).toBe(line.grossCents); + } + }); + + test("liefert null, wenn eine Position keinen Steuersatz hat", () => { + expect(erpVatLines({ items: [{ totalCents: 100, vatRatePercent: null }], shippingCents: 0, discountCents: 0 })).toBeNull(); + }); +}); + +const record = { + id: "inv1", + orderId: "ord1", + invoiceNumber: "RE-2026-0007", + issuedAt: new Date("2026-10-01T10:00:00Z"), + totalCents: 3620, + pdfPath: "2026/kaeufer-max/RE-2026-0007.pdf", + order: { + id: "ord1", + orderNumber: "WH-1007", + customerId: null, + status: "BEZAHLT", + fulfillmentMethod: "VERSAND", + locale: "de", + email: "max@example.com", + firstName: "Max", + lastName: "Kaeufer", + phone: "+49 821 1", + street: "Lindenstrasse 5", + zip: "86150", + city: "Augsburg", + country: "DE", + packstationNumber: null, + postNumber: null, + subtotalCents: 3620, + shippingCents: 590, + discountCents: 590, + totalCents: 3620, + currency: "EUR", + discountCodeId: "d1", + createdAt: new Date("2026-10-01T09:58:00Z"), + discountCode: { code: "VERSANDFREI" }, + payment: { provider: "stripe", stripePaymentIntentId: "pi_123", status: "SUCCEEDED" }, + items: [ + { + productId: "p1", + productName: "Waldhonig 500 g", + quantity: 3, + unitPriceCents: 790, + totalCents: 2370, + weightGrams: 500, + vatRatePercent: 7, + product: { slug: "waldhonig-2026", category: "HONIG" }, + }, + { + productId: "p2", + productName: "Bienenwachskerze", + quantity: 1, + unitPriceCents: 1250, + totalCents: 1250, + weightGrams: 120, + vatRatePercent: 19, + product: { slug: "kerze-gross", category: "KERZEN" }, + }, + ], + }, +}; + +describe("toErpInvoice", () => { + test("uebergibt Rechnung, Zahlung, Positionen und Steuer, aber keine Telefonnummer", () => { + const erp = toErpInvoice(record as never); + expect(erp.invoiceNumber).toBe("RE-2026-0007"); + expect(erp.payment?.stripePaymentIntentId).toBe("pi_123"); + expect(erp.order).toMatchObject({ orderNumber: "WH-1007", discountCode: "VERSANDFREI", fulfillmentMethod: "VERSAND" }); + expect(erp.items).toEqual([ + expect.objectContaining({ productSlug: "waldhonig-2026", category: "HONIG", quantity: 3, totalCents: 2370, vatRatePercent: 7 }), + expect.objectContaining({ productSlug: "kerze-gross", category: "KERZEN", quantity: 1, totalCents: 1250, vatRatePercent: 19 }), + ]); + expect(erp.vat?.reduce((sum, l) => sum + l.grossCents, 0)).toBe(3620); + expect(JSON.stringify(erp)).not.toContain("+49 821 1"); + }); +}); + +describe("listErpInvoices", () => { + beforeEach(() => findMany.mockReset()); + + test("holt ab dem Zeitpunkt, aelteste zuerst, hoechstens limit", async () => { + findMany.mockResolvedValue([record]); + const since = new Date("2026-10-01T00:00:00Z"); + const result = await listErpInvoices(since, 50); + expect(result).toHaveLength(1); + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { issuedAt: { gte: since } }, orderBy: [{ issuedAt: "asc" }, { id: "asc" }], take: 50 }) + ); + }); +}); diff --git a/src/lib/erp-export.ts b/src/lib/erp-export.ts new file mode 100644 index 0000000..874c3ee --- /dev/null +++ b/src/lib/erp-export.ts @@ -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; + +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); +} + +/** + * Das PDF so, wie die Kundschaft es bekommen hat: zuerst das abgelegte + * Dokument, nur ohne Ablage neu erzeugt (wie /rechnung//pdf). + */ +export async function erpInvoicePdf(invoiceNumber: string): Promise { + 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, + })) + ); +}