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]>
This commit is contained in:
1 parent
508ff24aa7
commit
4d1bcbbdb7
22 files changed
+809
-16
No files matched your search
@@ -9,6 +9,7 @@ import { requireAdmin } from "./_shared";
|
||||
import { fail, succeed } from "@/lib/flash";
|
||||
import { normalizeContactEmail } from "@/lib/contact-email";
|
||||
import { settingsPathForGroup } from "@/lib/settings-tabs";
|
||||
import { INVOICE_COPY_KEY } from "@/lib/invoice-mail";
|
||||
|
||||
const COMPANY_EMAIL_KEY = "company.email";
|
||||
|
||||
@@ -64,7 +65,16 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
|
||||
"Die Firmen-E-Mail ergibt keine gültige Adresse. Möglich sind z. B. [email protected], info [at] wabenhain.com oder info [á] wabenhain.com."
|
||||
);
|
||||
}
|
||||
updates.push({ key: def.key, valueDe: def.key === COMPANY_EMAIL_KEY ? valueDe.trim() : valueDe, valueEn: null });
|
||||
// Die Rechnungskopie geht an genau diese Adresse: leer (keine Kopie) oder
|
||||
// eine echte Adresse, sonst verschwaenden die Kopien stillschweigend.
|
||||
if (def.key === INVOICE_COPY_KEY && valueDe.trim() !== "" && normalizeContactEmail(valueDe) === null) {
|
||||
return fail(
|
||||
settingsPathForGroup(group),
|
||||
"„Rechnungskopie an“ ist keine gültige E-Mail-Adresse. Bitte z. B. [email protected] eintragen oder das Feld leer lassen."
|
||||
);
|
||||
}
|
||||
const trimmed = def.key === COMPANY_EMAIL_KEY || def.key === INVOICE_COPY_KEY;
|
||||
updates.push({ key: def.key, valueDe: trimmed ? valueDe.trim() : valueDe, valueEn: null });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { isLocale, defaultLocale } from "@/i18n/config";
|
||||
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
|
||||
import { requireAdmin, str, strOrNull, fail } from "./_shared";
|
||||
import { recordOrderReturn, recordOrderSale } from "@/lib/apicore-stock/stock-events";
|
||||
import { issueCancellationInvoice } from "@/lib/cancellation-invoice";
|
||||
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
|
||||
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash";
|
||||
|
||||
@@ -151,6 +152,7 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
payment: { select: { status: true, stripePaymentIntentId: true } },
|
||||
items: { select: { productId: true, quantity: true, productName: true } },
|
||||
returnRequest: { select: { status: true } },
|
||||
invoice: { select: { cancellation: { select: { number: true } } } },
|
||||
},
|
||||
});
|
||||
if (!previous) return null;
|
||||
@@ -159,6 +161,12 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
// Bestaetigung aus dem Formular (siehe needsConfirmToChangeStatus). Ohne
|
||||
// sie wird nichts veraendert.
|
||||
const reopening = needsConfirmToChangeStatus(previous.status, status);
|
||||
// Mit Stornorechnung ist die Rechnung aufgehoben und das Geld erstattet;
|
||||
// ein Wiederoeffnen waere ein Verkauf ohne Rechnung. Dann bitte eine neue
|
||||
// Bestellung anlegen.
|
||||
if (reopening && previous.invoice?.cancellation) {
|
||||
return { ...previous, shipment: null, cancelledNow: false, blocked: "cancellation-invoice" as const };
|
||||
}
|
||||
if (reopening && !confirmedReopen) {
|
||||
return { ...previous, shipment: null, cancelledNow: false, blocked: "needs-confirm" as const };
|
||||
}
|
||||
@@ -278,6 +286,12 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
if (result.blocked === "changed") {
|
||||
return fail(detailPath, "Die Bestellung wurde zwischenzeitlich geändert. Bitte die Seite neu laden und erneut versuchen.");
|
||||
}
|
||||
if (result.blocked === "cancellation-invoice") {
|
||||
return fail(
|
||||
detailPath,
|
||||
`Zu dieser Bestellung gibt es die Stornorechnung ${result.invoice?.cancellation?.number}. Sie kann nicht wieder geöffnet werden — bitte eine neue Bestellung anlegen.`
|
||||
);
|
||||
}
|
||||
|
||||
// undefined = keine E-Mail faellig; false = faellig, aber nicht versendet.
|
||||
let mailSent: boolean | undefined;
|
||||
@@ -307,6 +321,15 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
|
||||
stripeCheckoutSessionId: result.stripeCheckoutSessionId,
|
||||
payment: result.payment,
|
||||
});
|
||||
// Hatte die Bestellung eine Rechnung, wird sie durch eine
|
||||
// Stornorechnung aufgehoben (nie geloescht) — auch wenn die Erstattung
|
||||
// oben scheiterte: verkauft ist dann trotzdem nichts mehr.
|
||||
try {
|
||||
await issueCancellationInvoice(orderId, "STORNIERUNG");
|
||||
} catch (error: unknown) {
|
||||
console.error(`[order-cancel] Stornorechnung fuer ${result.orderNumber} fehlgeschlagen:`, error);
|
||||
stornoWarnings.push("Die Stornorechnung konnte nicht erstellt werden — bitte die Stornierung erneut auslösen oder den Support informieren.");
|
||||
}
|
||||
|
||||
if (stornoWarnings.length > 0) {
|
||||
await logAdminAction(admin, {
|
||||
|
||||
@@ -29,7 +29,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
||||
payment: true,
|
||||
shipment: true,
|
||||
pickup: true,
|
||||
invoice: true,
|
||||
invoice: { include: { cancellation: { select: { number: true } } } },
|
||||
customer: { select: { email: true, firstName: true, lastName: true } },
|
||||
discountCode: { select: { code: true } },
|
||||
},
|
||||
@@ -279,6 +279,19 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
||||
>
|
||||
{t.adminOrderDetailPage.downloadPdf}
|
||||
</a>
|
||||
{order.invoice.cancellation && (
|
||||
<>
|
||||
<span className="mt-3">
|
||||
{t.invoicePage.cancellationTitle} {order.invoice.cancellation.number}
|
||||
</span>
|
||||
<a
|
||||
href={`/rechnung/${order.invoice.cancellation.number}/pdf`}
|
||||
className="link-draw text-sm text-heading"
|
||||
>
|
||||
{t.adminOrderDetailPage.downloadPdf}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-ink/65">{t.adminOrderDetailPage.noInvoiceYet}</p>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { returnAcceptedEmail, returnRejectedEmail, returnRefundedEmail } from "@
|
||||
import { restockOrderItems } from "@/lib/inventory";
|
||||
import { recordOrderReturn } from "@/lib/apicore-stock/stock-events";
|
||||
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
|
||||
import { issueCancellationInvoice } from "@/lib/cancellation-invoice";
|
||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||
|
||||
function str(formData: FormData, key: string): string {
|
||||
@@ -122,6 +123,15 @@ export async function refundReturnRequestAction(formData: FormData): Promise<voi
|
||||
});
|
||||
scheduleApiCoreStockSync(returnRequest.order.items.map((item) => item.productId));
|
||||
|
||||
// Voll erstattet: die Rechnung wird durch eine Stornorechnung aufgehoben.
|
||||
// Ein Fehler hier nimmt die Erstattung nicht zurueck; die Stornorechnung
|
||||
// holt spaetestens der Stripe-Webhook der Erstattung nach.
|
||||
try {
|
||||
await issueCancellationInvoice(returnRequest.orderId, "RETOURE");
|
||||
} catch (error: unknown) {
|
||||
console.error(`[return-refund] Stornorechnung fuer ${returnRequest.order.orderNumber} fehlgeschlagen:`, error);
|
||||
}
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: "return_request.refund",
|
||||
entityType: "ReturnRequest",
|
||||
|
||||
@@ -58,6 +58,15 @@ vi.mock("@/lib/invoice-storage", () => ({
|
||||
storeInvoicePdf: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
// Kopie an die Admin-Adresse und Stornorechnung haben eigene Tests; hier
|
||||
// zaehlt, dass der Webhook sie anstoesst.
|
||||
const sendInvoiceCopy = vi.fn(async () => true);
|
||||
vi.mock("@/lib/invoice-mail", () => ({ sendInvoiceCopy: (...args: unknown[]) => sendInvoiceCopy(...(args as [])) }));
|
||||
const issueCancellationInvoice = vi.fn(async () => ({ number: "ST-2026-0001", created: true }));
|
||||
vi.mock("@/lib/cancellation-invoice", () => ({
|
||||
issueCancellationInvoice: (...args: unknown[]) => issueCancellationInvoice(...(args as [])),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Minimaler Prisma-Ersatz. `$transaction` reicht dieselbe Instanz als `tx`
|
||||
* weiter — der Handler verlaesst sich auf die Atomaritaet der Datenbank, und
|
||||
@@ -219,6 +228,10 @@ describe("Stripe-Webhook", () => {
|
||||
// Die Rechnung liegt der Bestaetigung bei — Gastbestellungen haben kein
|
||||
// Konto, in dem sie sonst zu finden waere.
|
||||
expect(sendMail.mock.calls[0][0].attachments?.[0]?.filename).toBe("RE-2026-0001.pdf");
|
||||
// Kopie derselben E-Rechnung an die im Admin hinterlegte Adresse.
|
||||
expect(sendInvoiceCopy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Rechnung", number: "RE-2026-0001", customerEmail: paidOrder.email })
|
||||
);
|
||||
});
|
||||
|
||||
test("zaehlt einen Gutschein bei mehrfach zugestelltem Ereignis nur einmal", async () => {
|
||||
@@ -352,6 +365,26 @@ describe("Stripe-Webhook", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("hebt die Rechnung bei vollstaendiger Erstattung per Stornorechnung auf", async () => {
|
||||
// Auch wenn die Zahlung schon REFUNDED ist (Erstattung aus dem Admin):
|
||||
// der Aufruf ist idempotent und findet die Stornorechnung nur wieder.
|
||||
db.payment.findFirst.mockResolvedValue({
|
||||
id: "pay_1",
|
||||
status: "REFUNDED",
|
||||
orderId: ORDER_ID,
|
||||
order: { orderNumber: paidOrder.orderNumber },
|
||||
});
|
||||
constructEvent.mockReturnValue({
|
||||
type: "charge.refunded",
|
||||
data: { object: { payment_intent: "pi_test_1", refunded: true, amount: 2680, amount_refunded: 2680 } },
|
||||
});
|
||||
|
||||
await post();
|
||||
|
||||
expect(issueCancellationInvoice).toHaveBeenCalledWith(ORDER_ID, "ERSTATTUNG");
|
||||
expect(db.payment.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("laesst den Zahlungsstatus bei einer TEILerstattung unveraendert", async () => {
|
||||
// Der Restbetrag ist weiterhin eingezogen — auf "REFUNDED" gesetzt
|
||||
// wuerde eine Auswertung den ganzen Umsatz abschreiben.
|
||||
@@ -376,6 +409,7 @@ describe("Stripe-Webhook", () => {
|
||||
|
||||
expect(db.payment.update).not.toHaveBeenCalled();
|
||||
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Teilerstattung"));
|
||||
expect(issueCancellationInvoice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("haengt eine Rueckbuchung an die Notizen der Bestellung an, ohne bestehende zu verlieren", async () => {
|
||||
|
||||
@@ -11,6 +11,8 @@ import { invoiceRelativePath, storeInvoicePdf } from "@/lib/invoice-storage";
|
||||
import { formatInvoiceNumber } from "@/lib/invoice-number";
|
||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||
import type Stripe from "stripe";
|
||||
import { sendInvoiceCopy } from "@/lib/invoice-mail";
|
||||
import { issueCancellationInvoice } from "@/lib/cancellation-invoice";
|
||||
import { recordOrderSale } from "@/lib/apicore-stock/stock-events";
|
||||
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
|
||||
|
||||
@@ -174,7 +176,7 @@ async function recordRefund(charge: Stripe.Charge): Promise<void> {
|
||||
|
||||
const payment = await prisma.payment.findFirst({
|
||||
where: { stripePaymentIntentId: paymentIntentId },
|
||||
select: { id: true, status: true, order: { select: { orderNumber: true } } },
|
||||
select: { id: true, status: true, orderId: true, order: { select: { orderNumber: true } } },
|
||||
});
|
||||
if (!payment) {
|
||||
console.warn(
|
||||
@@ -194,6 +196,12 @@ async function recordRefund(charge: Stripe.Charge): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Volle Erstattung: die Rechnung ist damit aufgehoben, es entsteht die
|
||||
// Stornorechnung. Auch wenn die Zahlung schon auf REFUNDED steht — dann kam
|
||||
// die Erstattung aus der Stornierung im Admin oder der Retoure, und dieser
|
||||
// Aufruf findet die dort angelegte Stornorechnung nur wieder.
|
||||
await issueCancellationInvoice(payment.orderId, "ERSTATTUNG");
|
||||
|
||||
if (payment.status === "REFUNDED") return;
|
||||
|
||||
await prisma.payment.update({ where: { id: payment.id }, data: { status: "REFUNDED" } });
|
||||
@@ -456,6 +464,17 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise<void> {
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
// Kopie der E-Rechnung an die im Admin hinterlegte Adresse (lib/invoice-mail.ts).
|
||||
if (invoicePdf) {
|
||||
await sendInvoiceCopy({
|
||||
title: "Rechnung",
|
||||
number: invoice.invoiceNumber,
|
||||
orderNumber: order.orderNumber,
|
||||
customerName: `${order.firstName} ${order.lastName}`.trim(),
|
||||
customerEmail: order.email,
|
||||
pdf: invoicePdf,
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Laut sichtbar machen (statt wie zuvor unbehandelt bis in den
|
||||
// Next.js-Request-Handler durchzureichen) und weiterwerfen: der 500er
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { erpExportAuthorized, erpInvoicePdf, isInvoiceNumber } from "@/lib/erp-export";
|
||||
import { erpExportAuthorized, erpInvoicePdf, isErpDocumentNumber } 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. */
|
||||
/** Rechnungs- oder Stornorechnungs-PDF fuer ERPNext (Anhang am Beleg). 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();
|
||||
@@ -13,7 +13,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ invo
|
||||
return NextResponse.json({ error: "Nicht berechtigt." }, { status });
|
||||
}
|
||||
const { invoiceNumber } = await params;
|
||||
const pdf = isInvoiceNumber(invoiceNumber) ? await erpInvoicePdf(invoiceNumber) : null;
|
||||
const pdf = isErpDocumentNumber(invoiceNumber) ? await erpInvoicePdf(invoiceNumber) : null;
|
||||
if (!pdf) {
|
||||
return NextResponse.json({ error: "Rechnung nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { erpExportAuthorized, listErpInvoices, parseErpQuery } from "@/lib/erp-export";
|
||||
import { erpExportAuthorized, listErpCancellations, 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>.
|
||||
* Rechnungen fuer ERPNext, aelteste zuerst: GET /api/erp/rechnungen?seit=<ISO>&limit=<1-200>,
|
||||
* Stornorechnungen mit &art=storno.
|
||||
* Nur lesend und nur mit ERP_EXPORT_TOKEN, siehe lib/erp-export.ts.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
@@ -19,7 +20,10 @@ export async function GET(request: Request) {
|
||||
if (!query) {
|
||||
return NextResponse.json({ error: "seit (ISO-Zeitpunkt) und limit (1-200) pruefen." }, { status: 400 });
|
||||
}
|
||||
const invoices = await listErpInvoices(query.since, query.limit);
|
||||
const invoices =
|
||||
query.kind === "storno"
|
||||
? await listErpCancellations(query.since, query.limit)
|
||||
: await listErpInvoices(query.since, query.limit);
|
||||
return NextResponse.json(
|
||||
{ invoices, hasMore: invoices.length === query.limit },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
|
||||
@@ -3,6 +3,8 @@ import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { getInvoiceForCustomer, getInvoiceByNumber } from "@/lib/account-data";
|
||||
import { generateInvoicePdf } from "@/lib/invoice-pdf";
|
||||
import { readStoredInvoicePdf } from "@/lib/invoice-storage";
|
||||
import { cancellationPdf, isCancellationNumber } from "@/lib/cancellation-invoice";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||
|
||||
export async function GET(
|
||||
@@ -15,6 +17,24 @@ export async function GET(
|
||||
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
|
||||
}
|
||||
|
||||
// Stornorechnung (ST-…): dieselbe Besitzpruefung ueber die Bestellung der
|
||||
// aufgehobenen Rechnung, dann das abgelegte bzw. neu erzeugte PDF.
|
||||
if (isCancellationNumber(invoiceNumber)) {
|
||||
const owned =
|
||||
customer.role === "ADMIN" ||
|
||||
(await prisma.cancellationInvoice.count({ where: { number: invoiceNumber, invoice: { order: { customerId: customer.id } } } })) > 0;
|
||||
const pdf = owned ? await cancellationPdf(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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const invoice =
|
||||
customer.role === "ADMIN"
|
||||
? await getInvoiceByNumber(invoiceNumber)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Tests fuer die Stornorechnung: hoechstens eine je Rechnung, auch wenn
|
||||
* Admin-Stornierung und Stripe-Webhook derselben Erstattung gleichzeitig
|
||||
* ankommen, und sie geht an die Kundschaft und als Kopie hinaus.
|
||||
*/
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const tx = {
|
||||
cancellationCounter: { upsert: vi.fn() },
|
||||
cancellationInvoice: { create: vi.fn() },
|
||||
};
|
||||
const db = {
|
||||
invoice: { findUnique: vi.fn() },
|
||||
cancellationInvoice: { findUnique: vi.fn(), update: vi.fn() },
|
||||
$transaction: vi.fn(async (run: (client: typeof tx) => Promise<unknown>) => run(tx)),
|
||||
};
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: db }));
|
||||
|
||||
const generateInvoicePdf = vi.fn(async () => Buffer.from("storno-pdf"));
|
||||
vi.mock("@/lib/invoice-pdf", () => ({ generateInvoicePdf: (...args: unknown[]) => generateInvoicePdf(...(args as [])) }));
|
||||
const storeInvoicePdf = vi.fn(async () => undefined);
|
||||
vi.mock("@/lib/invoice-storage", () => ({
|
||||
invoiceRelativePath: vi.fn(() => "2026/musterfrau-erika/ST-2026-0003.pdf"),
|
||||
storeInvoicePdf: (...args: unknown[]) => storeInvoicePdf(...(args as [])),
|
||||
readStoredInvoicePdf: vi.fn(async () => null),
|
||||
}));
|
||||
const sendMail = vi.fn(async () => true);
|
||||
vi.mock("@/lib/mail", () => ({ sendMail: (...args: unknown[]) => sendMail(...(args as [])) }));
|
||||
vi.mock("@/lib/email-templates", () => ({
|
||||
cancellationInvoiceEmail: vi.fn(async () => ({ subject: "Stornorechnung", html: "<p>x</p>", text: "x" })),
|
||||
}));
|
||||
const sendInvoiceCopy = vi.fn(async () => true);
|
||||
vi.mock("@/lib/invoice-mail", () => ({ sendInvoiceCopy: (...args: unknown[]) => sendInvoiceCopy(...(args as [])) }));
|
||||
|
||||
const { issueCancellationInvoice, isCancellationNumber } = await import("./cancellation-invoice");
|
||||
|
||||
const order = {
|
||||
orderNumber: "WH-2026-0042",
|
||||
email: "[email protected]",
|
||||
firstName: "Erika",
|
||||
lastName: "Musterfrau",
|
||||
locale: "de",
|
||||
items: [],
|
||||
};
|
||||
const loaded = {
|
||||
number: "ST-2026-0003",
|
||||
issuedAt: new Date("2026-10-14T09:00:00Z"),
|
||||
pdfPath: null,
|
||||
invoice: { invoiceNumber: "RE-2026-0042", issuedAt: new Date("2026-10-12T10:00:00Z"), totalCents: 5375, order },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
db.invoice.findUnique.mockResolvedValue({ id: "inv1", totalCents: 5375, cancellation: null });
|
||||
tx.cancellationCounter.upsert.mockResolvedValue({ lastNumber: 3 });
|
||||
tx.cancellationInvoice.create.mockImplementation(async ({ data }: { data: { number: string } }) => ({ number: data.number }));
|
||||
db.cancellationInvoice.findUnique.mockResolvedValue(loaded);
|
||||
});
|
||||
|
||||
describe("issueCancellationInvoice", () => {
|
||||
test("ohne Rechnung gibt es nichts aufzuheben", async () => {
|
||||
db.invoice.findUnique.mockResolvedValue(null);
|
||||
|
||||
expect(await issueCancellationInvoice("order1", "STORNIERUNG")).toBeNull();
|
||||
expect(db.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("legt die Stornorechnung mit eigener Nummer an und verschickt sie mit Kopie", async () => {
|
||||
// Act
|
||||
const result = await issueCancellationInvoice("order1", "STORNIERUNG");
|
||||
|
||||
// Assert
|
||||
const year = new Date().getFullYear();
|
||||
expect(result).toEqual({ number: `ST-${year}-0003`, created: true });
|
||||
expect(tx.cancellationInvoice.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { invoiceId: "inv1", number: `ST-${year}-0003`, totalCents: 5375, reason: "STORNIERUNG" } })
|
||||
);
|
||||
expect(generateInvoicePdf).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ invoiceNumber: "RE-2026-0042", cancellation: { number: "ST-2026-0003", issuedAt: loaded.issuedAt } })
|
||||
);
|
||||
expect(storeInvoicePdf).toHaveBeenCalledOnce();
|
||||
expect(sendMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: "[email protected]", attachments: [expect.objectContaining({ filename: `ST-${year}-0003.pdf` })] })
|
||||
);
|
||||
expect(sendInvoiceCopy).toHaveBeenCalledWith(expect.objectContaining({ title: "Stornorechnung", orderNumber: "WH-2026-0042" }));
|
||||
});
|
||||
|
||||
test("zweiter Ausloeser findet die vorhandene Stornorechnung, ohne erneut zu verschicken", async () => {
|
||||
db.invoice.findUnique.mockResolvedValue({ id: "inv1", totalCents: 5375, cancellation: { number: "ST-2026-0001" } });
|
||||
|
||||
expect(await issueCancellationInvoice("order1", "ERSTATTUNG")).toEqual({ number: "ST-2026-0001", created: false });
|
||||
expect(db.$transaction).not.toHaveBeenCalled();
|
||||
expect(sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("gleichzeitig angelegt: die zweite Transaktion scheitert eindeutig und liefert die erste", async () => {
|
||||
// Arrange: die Datenbank lehnt die zweite Stornorechnung zur selben Rechnung ab.
|
||||
tx.cancellationInvoice.create.mockRejectedValue(Object.assign(new Error("Unique constraint failed"), { code: "P2002" }));
|
||||
db.cancellationInvoice.findUnique.mockResolvedValue({ number: "ST-2026-0001" });
|
||||
|
||||
// Act
|
||||
const result = await issueCancellationInvoice("order1", "ERSTATTUNG");
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({ number: "ST-2026-0001", created: false });
|
||||
expect(sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("ein Fehler beim Verschicken laesst die Stornorechnung bestehen", async () => {
|
||||
generateInvoicePdf.mockRejectedValueOnce(new Error("Schrift fehlt"));
|
||||
const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
const result = await issueCancellationInvoice("order1", "RETOURE");
|
||||
|
||||
expect(result?.created).toBe(true);
|
||||
expect(errors).toHaveBeenCalled();
|
||||
errors.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
test("isCancellationNumber erkennt nur ST-Nummern", () => {
|
||||
expect(isCancellationNumber("ST-2026-0001")).toBe(true);
|
||||
expect(isCancellationNumber("RE-2026-0001")).toBe(false);
|
||||
expect(isCancellationNumber("ST-2026-0001/../x")).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formatCancellationNumber } from "@/lib/invoice-number";
|
||||
import { generateInvoicePdf } from "@/lib/invoice-pdf";
|
||||
import { invoiceRelativePath, readStoredInvoicePdf, storeInvoicePdf } from "@/lib/invoice-storage";
|
||||
import { isUniqueConstraintError } from "@/lib/harvest-slug";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { cancellationInvoiceEmail } from "@/lib/email-templates";
|
||||
import { sendInvoiceCopy } from "@/lib/invoice-mail";
|
||||
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||
|
||||
/**
|
||||
* Stornorechnung: der Gegenbeleg, wenn eine Bestellung mit Rechnung
|
||||
* storniert oder voll erstattet wird.
|
||||
*
|
||||
* Eine ausgestellte Rechnung wird nie geloescht oder geaendert (§ 14 UStG,
|
||||
* GoBD). Stattdessen entsteht EINE Stornorechnung je Rechnung, mit eigener
|
||||
* fortlaufender Nummer (ST-JJJJ-NNNN), allen Positionen der Rechnung und
|
||||
* Bezug auf sie. Sie geht als E-Rechnung an die Kundschaft und als Kopie an
|
||||
* die Admin-Adresse; ERPNext holt sie ueber den ERP-Export ab und bucht sie
|
||||
* als Rechnungskorrektur gegen die Ausgangsrechnung.
|
||||
*
|
||||
* Ausloeser (alle idempotent ueber denselben Weg):
|
||||
* - Stornierung im Admin (STORNIERUNG)
|
||||
* - Erstattung einer Retoure (RETOURE)
|
||||
* - volle Erstattung direkt bei Stripe (ERSTATTUNG, Webhook charge.refunded)
|
||||
* Eine Stornierung im Admin loest selbst eine Stripe-Erstattung aus, deren
|
||||
* Webhook gleichzeitig hier ankommen kann. Beide Wege fuehren zu derselben
|
||||
* einen Stornorechnung: die zweite scheitert am eindeutigen invoiceId und
|
||||
* rollt samt Zaehler zurueck, es entsteht also auch keine Nummernluecke.
|
||||
*/
|
||||
|
||||
export type CancellationReason = "STORNIERUNG" | "RETOURE" | "ERSTATTUNG";
|
||||
|
||||
export interface CancellationResult {
|
||||
number: string;
|
||||
/** false: es gab sie schon (zweiter Ausloeser derselben Erstattung). */
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
const CANCELLATION_NUMBER_PATTERN = /^ST-\d{4}-\d{4,}$/;
|
||||
|
||||
export function isCancellationNumber(value: string): boolean {
|
||||
return CANCELLATION_NUMBER_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt die Stornorechnung zur Rechnung der Bestellung an und verschickt sie.
|
||||
* null, wenn die Bestellung keine Rechnung hat (nie bezahlt) — dann gibt es
|
||||
* nichts aufzuheben.
|
||||
*/
|
||||
export async function issueCancellationInvoice(orderId: string, reason: CancellationReason): Promise<CancellationResult | null> {
|
||||
const invoice = await prisma.invoice.findUnique({
|
||||
where: { orderId },
|
||||
select: { id: true, totalCents: true, cancellation: { select: { number: true } } },
|
||||
});
|
||||
if (!invoice) return null;
|
||||
if (invoice.cancellation) return { number: invoice.cancellation.number, created: false };
|
||||
|
||||
let number: string;
|
||||
try {
|
||||
number = await prisma.$transaction(async (tx) => {
|
||||
const year = new Date().getFullYear();
|
||||
const counter = await tx.cancellationCounter.upsert({
|
||||
where: { year },
|
||||
create: { year, lastNumber: 1 },
|
||||
update: { lastNumber: { increment: 1 } },
|
||||
select: { lastNumber: true },
|
||||
});
|
||||
const created = await tx.cancellationInvoice.create({
|
||||
data: {
|
||||
invoiceId: invoice.id,
|
||||
number: formatCancellationNumber(year, counter.lastNumber),
|
||||
totalCents: invoice.totalCents,
|
||||
reason,
|
||||
},
|
||||
select: { number: true },
|
||||
});
|
||||
return created.number;
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (!isUniqueConstraintError(error)) throw error;
|
||||
const existing = await prisma.cancellationInvoice.findUnique({ where: { invoiceId: invoice.id }, select: { number: true } });
|
||||
if (!existing) throw error;
|
||||
return { number: existing.number, created: false };
|
||||
}
|
||||
|
||||
await deliverCancellationInvoice(number);
|
||||
return { number, created: true };
|
||||
}
|
||||
|
||||
async function loadCancellation(number: string) {
|
||||
return prisma.cancellationInvoice.findUnique({
|
||||
where: { number },
|
||||
include: { invoice: { include: { order: { include: { items: true, discountCode: { select: { code: true } } } } } } },
|
||||
});
|
||||
}
|
||||
|
||||
type LoadedCancellation = NonNullable<Awaited<ReturnType<typeof loadCancellation>>>;
|
||||
|
||||
function renderCancellationPdf(cancellation: LoadedCancellation): Promise<Buffer> {
|
||||
const { invoice } = cancellation;
|
||||
return generateInvoicePdf({
|
||||
locale: isLocale(invoice.order.locale) ? invoice.order.locale : defaultLocale,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
issuedAt: invoice.issuedAt,
|
||||
totalCents: invoice.totalCents,
|
||||
order: invoice.order,
|
||||
cancellation: { number: cancellation.number, issuedAt: cancellation.issuedAt },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF erzeugen und ablegen, an die Kundschaft und als Kopie verschicken.
|
||||
* Wirft nicht: die Stornorechnung steht bereits fest, und ihr PDF laesst sich
|
||||
* jederzeit ueber cancellationPdf() neu erzeugen. Ein Fehler hier darf die
|
||||
* Stornierung nicht als gescheitert erscheinen lassen.
|
||||
*/
|
||||
async function deliverCancellationInvoice(number: string): Promise<void> {
|
||||
try {
|
||||
const cancellation = await loadCancellation(number);
|
||||
if (!cancellation) return;
|
||||
const { order } = cancellation.invoice;
|
||||
const pdf = await renderCancellationPdf(cancellation);
|
||||
try {
|
||||
const relativePath = invoiceRelativePath({
|
||||
year: cancellation.issuedAt.getFullYear(),
|
||||
lastName: order.lastName,
|
||||
firstName: order.firstName,
|
||||
invoiceNumber: number,
|
||||
});
|
||||
await storeInvoicePdf(relativePath, pdf);
|
||||
await prisma.cancellationInvoice.update({ where: { number }, data: { pdfPath: relativePath } });
|
||||
} catch (error: unknown) {
|
||||
console.error(`[cancellation] ${number} konnte nicht abgelegt werden:`, error);
|
||||
}
|
||||
|
||||
const attachment = { filename: `${number}.pdf`, content: pdf, contentType: "application/pdf" };
|
||||
const { subject, html, text } = await cancellationInvoiceEmail({
|
||||
locale: isLocale(order.locale) ? order.locale : defaultLocale,
|
||||
orderNumber: order.orderNumber,
|
||||
firstName: order.firstName,
|
||||
cancellationNumber: number,
|
||||
invoiceNumber: cancellation.invoice.invoiceNumber,
|
||||
});
|
||||
await sendMail({ to: order.email, subject, html, text, attachments: [attachment] });
|
||||
await sendInvoiceCopy({
|
||||
title: "Stornorechnung",
|
||||
number,
|
||||
orderNumber: order.orderNumber,
|
||||
customerName: `${order.firstName} ${order.lastName}`.trim(),
|
||||
customerEmail: order.email,
|
||||
pdf,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error(`[cancellation] ${number} konnte nicht erzeugt oder verschickt werden:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Das PDF der Stornorechnung: abgelegt, sonst neu erzeugt. null, wenn es die Nummer nicht gibt. */
|
||||
export async function cancellationPdf(number: string): Promise<Buffer | null> {
|
||||
const cancellation = await loadCancellation(number);
|
||||
if (!cancellation) return null;
|
||||
const stored = cancellation.pdfPath ? await readStoredInvoicePdf(cancellation.pdfPath) : null;
|
||||
return stored ?? (await renderCancellationPdf(cancellation));
|
||||
}
|
||||
@@ -74,6 +74,8 @@ export const CONTENT_REGISTRY: ContentFieldDef[] = [
|
||||
{ key: "company.street", group: "company", labelDe: "Straße", labelEn: "Street", type: "text", localized: false, getDefault: () => siteConfig.address.street },
|
||||
{ key: "company.zip", group: "company", labelDe: "PLZ", labelEn: "ZIP", type: "text", localized: false, getDefault: () => siteConfig.address.zip },
|
||||
{ key: "company.city", group: "company", labelDe: "Ort", labelEn: "City", type: "text", localized: false, getDefault: () => siteConfig.address.city },
|
||||
// Kopie jeder Rechnung und Stornorechnung (lib/invoice-mail.ts). Leer = keine Kopie.
|
||||
{ key: "invoice.copyEmail", group: "company", labelDe: "Rechnungskopie an (E-Mail-Adresse; jede Rechnung und Stornorechnung geht als E-Rechnung zusätzlich an diese Adresse, leer = keine Kopie)", labelEn: "Invoice copy to (email address; every invoice and cancellation invoice is also sent as an e-invoice to this address, empty = no copy)", type: "text", localized: false, getDefault: () => "" },
|
||||
|
||||
// --- Liefergebiet ---
|
||||
// Steuert dreierlei zugleich: die Laenderauswahl im Checkout, die
|
||||
|
||||
@@ -101,6 +101,12 @@ interface EmailStrings {
|
||||
button: string;
|
||||
subject: (orderNumber: string) => string;
|
||||
};
|
||||
cancellationInvoice: {
|
||||
preheader: (orderNumber: string) => string;
|
||||
heading: (firstName: string) => string;
|
||||
intro: (cancellationNumber: string, invoiceNumber: string, orderNumber: string) => string;
|
||||
subject: (cancellationNumber: string, orderNumber: string) => string;
|
||||
};
|
||||
}
|
||||
|
||||
const de: EmailStrings = {
|
||||
@@ -202,6 +208,13 @@ const de: EmailStrings = {
|
||||
button: "ERNEUT BESTELLEN",
|
||||
subject: (orderNumber) => `Zahlung zu Bestellung ${orderNumber} fehlgeschlagen — Wabenhain`,
|
||||
},
|
||||
cancellationInvoice: {
|
||||
preheader: (orderNumber) => `Stornorechnung zu deiner Bestellung ${orderNumber}.`,
|
||||
heading: (firstName) => `Deine Stornorechnung, ${firstName}`,
|
||||
intro: (cancellationNumber, invoiceNumber, orderNumber) =>
|
||||
`Anbei findest du die Stornorechnung <strong>${cancellationNumber}</strong>. Sie hebt die Rechnung <strong>${invoiceNumber}</strong> zu deiner Bestellung <strong>${orderNumber}</strong> vollständig auf. Bitte lege sie zu deinen Unterlagen.`,
|
||||
subject: (cancellationNumber, orderNumber) => `Stornorechnung ${cancellationNumber} zu Bestellung ${orderNumber} — Wabenhain`,
|
||||
},
|
||||
};
|
||||
|
||||
const en: EmailStrings = {
|
||||
@@ -303,6 +316,13 @@ const en: EmailStrings = {
|
||||
button: "ORDER AGAIN",
|
||||
subject: (orderNumber) => `Payment for order ${orderNumber} failed — Wabenhain`,
|
||||
},
|
||||
cancellationInvoice: {
|
||||
preheader: (orderNumber) => `Cancellation invoice for your order ${orderNumber}.`,
|
||||
heading: (firstName) => `Your cancellation invoice, ${firstName}`,
|
||||
intro: (cancellationNumber, invoiceNumber, orderNumber) =>
|
||||
`Please find attached the cancellation invoice <strong>${cancellationNumber}</strong>. It fully reverses invoice <strong>${invoiceNumber}</strong> for your order <strong>${orderNumber}</strong>. Please keep it with your records.`,
|
||||
subject: (cancellationNumber, orderNumber) => `Cancellation invoice ${cancellationNumber} for order ${orderNumber} — Wabenhain`,
|
||||
},
|
||||
};
|
||||
|
||||
const emailStrings: Record<Locale, EmailStrings> = { de, en };
|
||||
|
||||
@@ -375,6 +375,31 @@ export async function returnRefundedEmail(order: OrderStatusUpdateInput): Promis
|
||||
};
|
||||
}
|
||||
|
||||
interface CancellationInvoiceEmailInput extends OrderStatusUpdateInput {
|
||||
cancellationNumber: string;
|
||||
invoiceNumber: string;
|
||||
}
|
||||
|
||||
/** Begleitmail zur Stornorechnung (PDF als Anhang, siehe lib/cancellation-invoice.ts). */
|
||||
export async function cancellationInvoiceEmail(input: CancellationInvoiceEmailInput): Promise<EmailContent> {
|
||||
const s = getEmailStrings(input.locale).cancellationInvoice;
|
||||
const intro = (escape: (value: string) => string) =>
|
||||
s.intro(escape(input.cancellationNumber), escape(input.invoiceNumber), escape(input.orderNumber));
|
||||
const html = await emailLayout(
|
||||
input.locale,
|
||||
s.preheader(input.orderNumber),
|
||||
`
|
||||
<h1 style="font-size:20px;color:#24352B;margin:0 0 12px;">${s.heading(escapeHtml(input.firstName))}</h1>
|
||||
<p style="margin:0;line-height:1.5;">${intro(escapeHtml)}</p>
|
||||
`
|
||||
);
|
||||
return {
|
||||
subject: s.subject(input.cancellationNumber, input.orderNumber),
|
||||
html,
|
||||
text: intro((value) => value).replace(/<\/?strong>/g, ""),
|
||||
};
|
||||
}
|
||||
|
||||
export async function paymentFailedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
|
||||
const s = getEmailStrings(order.locale).paymentFailed;
|
||||
// Der Text (retryNote) fordert bereits zur erneuten Bestellung auf — ohne
|
||||
|
||||
@@ -8,14 +8,22 @@
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const findMany = vi.fn();
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: { invoice: { findMany: (...args: unknown[]) => findMany(...args) } } }));
|
||||
const findCancellations = vi.fn();
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
invoice: { findMany: (...args: unknown[]) => findMany(...args) },
|
||||
cancellationInvoice: { findMany: (...args: unknown[]) => findCancellations(...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,
|
||||
isErpDocumentNumber,
|
||||
isInvoiceNumber,
|
||||
listErpCancellations,
|
||||
listErpInvoices,
|
||||
parseErpQuery,
|
||||
toErpInvoice,
|
||||
@@ -42,18 +50,29 @@ describe("erpExportAuthorized", () => {
|
||||
|
||||
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-01T00:00:00Z&limit=50"))).toEqual({
|
||||
since: new Date("2026-01-01T00:00:00Z"),
|
||||
limit: 50,
|
||||
kind: "rechnung",
|
||||
});
|
||||
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("liefert Stornorechnungen nur mit art=storno", () => {
|
||||
expect(parseErpQuery(new URLSearchParams("seit=2026-01-01&art=storno"))?.kind).toBe("storno");
|
||||
expect(parseErpQuery(new URLSearchParams("seit=2026-01-01&art=alles"))).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);
|
||||
expect(isErpDocumentNumber("ST-2026-0001")).toBe(true);
|
||||
expect(isErpDocumentNumber("ST-2026-0001.pdf")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,5 +191,39 @@ describe("listErpInvoices", () => {
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { issuedAt: { gte: since } }, orderBy: [{ issuedAt: "asc" }, { id: "asc" }], take: 50 })
|
||||
);
|
||||
expect(result[0]).toMatchObject({ type: "invoice", cancelledBy: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("listErpCancellations", () => {
|
||||
beforeEach(() => findCancellations.mockReset());
|
||||
|
||||
test("liefert die Stornorechnung mit Positionen der Rechnung und Bezug", async () => {
|
||||
// Arrange
|
||||
findCancellations.mockResolvedValue([
|
||||
{
|
||||
id: "st1",
|
||||
number: "ST-2026-0002",
|
||||
issuedAt: new Date("2026-10-05T08:00:00Z"),
|
||||
totalCents: 3620,
|
||||
reason: "STORNIERUNG",
|
||||
invoice: { ...record, cancellation: { number: "ST-2026-0002" } },
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
const [erp] = await listErpCancellations(new Date("2026-10-01T00:00:00Z"), 10);
|
||||
|
||||
// Assert
|
||||
expect(erp).toMatchObject({
|
||||
type: "cancellation",
|
||||
invoiceNumber: "ST-2026-0002",
|
||||
issuedAt: "2026-10-05T08:00:00.000Z",
|
||||
totalCents: 3620,
|
||||
reason: "STORNIERUNG",
|
||||
cancels: { invoiceNumber: "RE-2026-0007", issuedAt: "2026-10-01T10:00:00.000Z" },
|
||||
});
|
||||
expect(erp.items).toHaveLength(2);
|
||||
expect(erp.vat?.reduce((sum, line) => sum + line.grossCents, 0)).toBe(3620);
|
||||
});
|
||||
});
|
||||
+51
-4
@@ -4,6 +4,7 @@ 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";
|
||||
|
||||
/**
|
||||
@@ -37,12 +38,27 @@ 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 {
|
||||
/** 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;
|
||||
return { since, limit };
|
||||
if (kind !== "rechnung" && kind !== "storno") return null;
|
||||
return { since, limit, kind };
|
||||
}
|
||||
|
||||
const invoiceInclude = {
|
||||
@@ -53,6 +69,7 @@ const invoiceInclude = {
|
||||
discountCode: { select: { code: true } },
|
||||
},
|
||||
},
|
||||
cancellation: { select: { number: true } },
|
||||
} satisfies Prisma.InvoiceInclude;
|
||||
|
||||
type ErpInvoiceRecord = Prisma.InvoiceGetPayload<{ include: typeof invoiceInclude }>;
|
||||
@@ -66,9 +83,12 @@ export const erpVatLines = vatLinesWithShares;
|
||||
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,
|
||||
@@ -121,11 +141,38 @@ export async function listErpInvoices(since: Date, limit: number): Promise<ErpIn
|
||||
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).
|
||||
* 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 } } } } },
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const values: Record<string, string> = {};
|
||||
vi.mock("@/lib/content", () => ({
|
||||
getContentValues: vi.fn(async (keys: string[]) => Object.fromEntries(keys.map((key) => [key, values[key] ?? ""]))),
|
||||
}));
|
||||
const sendMail = vi.fn(async () => true);
|
||||
vi.mock("@/lib/mail", () => ({ sendMail: (...args: unknown[]) => sendMail(...(args as [])) }));
|
||||
|
||||
const { INVOICE_COPY_KEY, getInvoiceCopyAddress, invoiceCopyContent, sendInvoiceCopy } = await import("./invoice-mail");
|
||||
|
||||
const copy = {
|
||||
title: "Rechnung",
|
||||
number: "RE-2026-0042",
|
||||
orderNumber: "WH-2026-0042",
|
||||
customerName: "Erika <Musterfrau>",
|
||||
customerEmail: "[email protected]",
|
||||
pdf: Buffer.from("pdf"),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete values[INVOICE_COPY_KEY];
|
||||
});
|
||||
|
||||
describe("Rechnungskopie", () => {
|
||||
test("ohne hinterlegte Adresse keine Kopie", async () => {
|
||||
expect(await getInvoiceCopyAddress()).toBeNull();
|
||||
expect(await sendInvoiceCopy(copy)).toBeNull();
|
||||
expect(sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("schickt die E-Rechnung als Anhang an die hinterlegte Adresse", async () => {
|
||||
// Arrange: verschleiert eingetragen wie die Firmen-E-Mail.
|
||||
values[INVOICE_COPY_KEY] = " buchhaltung [at] wabenhain.com ";
|
||||
|
||||
// Act
|
||||
const sent = await sendInvoiceCopy(copy);
|
||||
|
||||
// Assert
|
||||
expect(sent).toBe(true);
|
||||
expect(sendMail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "[email protected]",
|
||||
subject: "Kopie: Rechnung RE-2026-0042 (Bestellung WH-2026-0042)",
|
||||
attachments: [expect.objectContaining({ filename: "RE-2026-0042.pdf", contentType: "application/pdf" })],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("maskiert Kundendaten im HTML-Teil", () => {
|
||||
const { html, text } = invoiceCopyContent(copy);
|
||||
expect(html).toContain("Erika <Musterfrau>");
|
||||
expect(text).toContain("EN 16931");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { getContentValues } from "@/lib/content";
|
||||
import { normalizeContactEmail } from "@/lib/contact-email";
|
||||
import { escapeHtml } from "@/lib/escape-html";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
|
||||
/**
|
||||
* Kopie jeder Rechnung und Stornorechnung an eine im Admin hinterlegte
|
||||
* Adresse (Einstellungen > Firma > "Rechnungskopie an"), etwa fuer die
|
||||
* Buchhaltung. Die Kopie ist eine eigene Mail mit demselben PDF (E-Rechnung
|
||||
* mit eingebettetem XML), nicht ein BCC der Kundenmail: so steht im Betreff,
|
||||
* welcher Beleg es ist, und die Kundenmail bleibt unveraendert.
|
||||
*/
|
||||
|
||||
export const INVOICE_COPY_KEY = "invoice.copyEmail";
|
||||
|
||||
/** Die hinterlegte Adresse, oder null, wenn keine (gueltige) eingetragen ist. */
|
||||
export async function getInvoiceCopyAddress(): Promise<string | null> {
|
||||
try {
|
||||
const values = await getContentValues([INVOICE_COPY_KEY], "de");
|
||||
const raw = values[INVOICE_COPY_KEY]?.trim() ?? "";
|
||||
return raw ? normalizeContactEmail(raw) : null;
|
||||
} catch (error: unknown) {
|
||||
console.error("[invoice-mail] Adresse fuer die Rechnungskopie nicht lesbar:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface InvoiceCopy {
|
||||
/** "Rechnung" oder "Stornorechnung". */
|
||||
title: string;
|
||||
number: string;
|
||||
orderNumber: string;
|
||||
customerName: string;
|
||||
customerEmail: string;
|
||||
pdf: Buffer;
|
||||
}
|
||||
|
||||
/** Betreff und Text der Kopie (ohne Versand, testbar). */
|
||||
export function invoiceCopyContent(copy: Omit<InvoiceCopy, "pdf">): { subject: string; text: string; html: string } {
|
||||
const subject = `Kopie: ${copy.title} ${copy.number} (Bestellung ${copy.orderNumber})`;
|
||||
const text =
|
||||
`${copy.title} ${copy.number} zur Bestellung ${copy.orderNumber} an ${copy.customerName} <${copy.customerEmail}>.\n` +
|
||||
"Anbei die E-Rechnung (PDF/A-3 mit eingebettetem XML nach EN 16931, ZUGFeRD/Factur-X).";
|
||||
const html = text
|
||||
.split("\n")
|
||||
.map((line) => `<p style="margin:0 0 8px;line-height:1.5;">${escapeHtml(line)}</p>`)
|
||||
.join("");
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
/**
|
||||
* Schickt die Kopie. null: keine Adresse hinterlegt; sonst, ob der
|
||||
* Mailserver die Mail angenommen hat. Wirft nie (wie sendMail): eine
|
||||
* fehlende Kopie darf keine Bestellung oder Stornierung aufhalten.
|
||||
*/
|
||||
export async function sendInvoiceCopy(copy: InvoiceCopy): Promise<boolean | null> {
|
||||
const to = await getInvoiceCopyAddress();
|
||||
if (!to) return null;
|
||||
const { subject, text, html } = invoiceCopyContent(copy);
|
||||
return sendMail({
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
attachments: [{ filename: `${copy.number}.pdf`, content: copy.pdf, contentType: "application/pdf" }],
|
||||
});
|
||||
}
|
||||
@@ -22,3 +22,13 @@ const SEQUENCE_DIGITS = 4;
|
||||
export function formatInvoiceNumber(year: number, sequence: number): string {
|
||||
return `RE-${year}-${String(sequence).padStart(SEQUENCE_DIGITS, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bildet "ST-2026-0001": Stornorechnungen haben einen eigenen fortlaufenden
|
||||
* Nummernkreis (Tabelle cancellation_counters). Mehrere Nummernkreise sind
|
||||
* zulaessig, solange jeder fuer sich lueckenlos ist; so bleibt auch die
|
||||
* RE-Folge ohne Spruenge.
|
||||
*/
|
||||
export function formatCancellationNumber(year: number, sequence: number): string {
|
||||
return `ST-${year}-${String(sequence).padStart(SEQUENCE_DIGITS, "0")}`;
|
||||
}
|
||||
Reference in new issue
Block a user