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
@@ -107,6 +107,13 @@ APICORE_API_KEY=
|
|||||||
# abgelehnt; der CronJob gleicht dann alle 10 Minuten ab.
|
# abgelehnt; der CronJob gleicht dann alle 10 Minuten ab.
|
||||||
APICORE_WEBHOOK_SECRET=
|
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) —
|
# DHL Parcel DE Shipping API ("Post & Parcel Germany", developer.dhl.com) —
|
||||||
# ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im
|
# ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im
|
||||||
# Admin-Bestelldetail (siehe src/lib/dhl.ts). Ohne diese fuenf Werte zeigt
|
# Admin-Bestelldetail (siehe src/lib/dhl.ts). Ohne diese fuenf Werte zeigt
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ stringData:
|
|||||||
# ab (alle 10 Minuten).
|
# ab (alle 10 Minuten).
|
||||||
APICORE_WEBHOOK_SECRET: ""
|
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
|
# DHL-Versandmarken (Admin -> Bestellung -> "DHL-Versandmarke"), siehe
|
||||||
# .env.example fuer die vollstaendige Einrichtung. Leer = der Knopf meldet
|
# .env.example fuer die vollstaendige Einrichtung. Leer = der Knopf meldet
|
||||||
# "noch nicht konfiguriert", der Rest des Shops ist unberuehrt. Ob Sandbox
|
# "noch nicht konfiguriert", der Rest des Shops ist unberuehrt. Ob Sandbox
|
||||||
|
|||||||
@@ -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" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: "[email protected]",
|
||||||
|
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 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user