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]>
193 lines
7.6 KiB
TypeScript
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,
|
|
}))
|
|
);
|
|
}
|