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
+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 } } } } },
|
||||
|
||||
Reference in new issue
Block a user