Compare commits

...
7 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 4b15ca0b8d fix(invoice): Rechnung ohne PDF/A statt Fehler, wenn Schriften fehlen
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Fehlt assets/fonts im Container, entsteht das PDF mit Standardschriften
und eingebettetem XML, nur nicht als PDF/A; der Fehler steht laut im Log.
Eine Rechnung darf nie an den Schriften scheitern.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:24:25 +02:00
Nick SzaunigandClaude Opus 5.5 051eedaf7f feat(erp): Kunden und Firmenanfragen fuer das CRM in ERPNext
Zwei nur lesende Endpunkte mit ERP_EXPORT_TOKEN:
- GET /api/erp/kunden: Kundenkonten (ohne Admins, ohne Passwort/2FA) mit
  Standardadresse; ERPNext fuehrt sie als Privatkunden.
- GET /api/erp/anfragen: Firmenanfragen; ERPNext fuehrt die Firma als
  B2B-Kunden mit Ansprechpartner und je Anfrage eine Chance.
Seitenweise ueber den Cursor "nach"; ERPNext holt jeweils die ganze Liste
und erkennt so auch geloeschte Konten.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:22:48 +02:00
Nick SzaunigandClaude Opus 5.5 4d1bcbbdb7 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]>
2026-10-12 06:19:42 +02:00
Nick SzaunigandClaude Opus 5.5 508ff24aa7 feat(invoice): Rechnungen als E-Rechnung (ZUGFeRD/Factur-X EN 16931)
Jedes Rechnungs-PDF ist jetzt eine hybride E-Rechnung: PDF/A-3b mit
eingebettetem factur-x.xml (CII D16B, Profil EN 16931). Netto und Steuer je
Satz kommen unveraendert aus der Aufstellung des PDFs, Versand und Rabatt
stehen als Zu- und Abschlag je Satz; XML und PDF tragen dieselben Summen.

- lib/einvoice/cii.ts: XML-Erzeugung (Rechnung 380, Stornorechnung 381 mit
  Bezug), ohne USt-IdNr. oder bei Positionen ohne Satz nur PDF mit Warnung
- lib/einvoice/facturx-pdf.ts: PDF/A-3b, Factur-X-XMP mit Erweiterungsschema,
  eingebettete Liberation-Schriften (gleiche Laufweiten wie Helvetica/Times)
- invoice-pdf: Darstellung als Stornorechnung (negative Betraege, Bezug)
- Geprueft mit XSD, CEN-Schematron EN 16931 1.3.16 und Mustang 2.26
  (veraPDF: PDF/A-3b konform, XML valid)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 05:32:44 +02:00
Nick SzaunigandClaude Opus 5.5 f28e5e9d84 feat(k8s): Sicherung und Hintergrund-Jobs lauffaehig machen
- Werkzeug-Image wabenhain-tools (Dockerfile "AS tools", Muster wie
  artista-modelcamp-tools): Im App-Image fehlten tsconfig.json und Pakete
  wie stripe, Bestandsabgleich und Bestellfreigabe brachen mit
  "Cannot find module" ab. Die CI baut es neben dem App-Image.
- Datenbank-Sicherung: PV auf der Storage Box (Customer/Wabenhain), der
  Unterordner db-backup entsteht per subPath. Vorher blieb der PVC ohne
  StorageClass haengen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 08:15:51 +02:00
Nick SzaunigandClaude Opus 5.5 b246c9ff4e 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]>
2026-10-10 04:39:11 +02:00
Nick SzaunigandClaude Opus 5.5 7698d4cef3 chore(k8s): CPU- und Speicher-Reservierung entfernen
Requests stehen explizit auf 0 statt zu fehlen, weil Kubernetes einen
fehlenden Request auf das Limit setzt und dann noch mehr reserviert.
Die Limits bleiben als Obergrenze.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 04:08:40 +02:00
48 changed files with 2497 additions and 37 deletions

No files matched your search

+7
View File
@@ -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
+24
View File
@@ -77,6 +77,16 @@ jobs:
type=sha,format=short type=sha,format=short
type=semver,pattern={{version}} type=semver,pattern={{version}}
- name: Metadaten Werkzeuge
id: meta-tools
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_PUSH }}/${{ steps.image.outputs.name }}-tools
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,format=short
type=semver,pattern={{version}}
# Ein einzelnes Image traegt beide Prisma-Client-Varianten (Postgres + # Ein einzelnes Image traegt beide Prisma-Client-Varianten (Postgres +
# MySQL, waehlt zur Laufzeit per DATABASE_PROVIDER, siehe # MySQL, waehlt zur Laufzeit per DATABASE_PROVIDER, siehe
# src/lib/prisma.ts) UND die Migrations-/Seed-Tools (siehe Dockerfile- # src/lib/prisma.ts) UND die Migrations-/Seed-Tools (siehe Dockerfile-
@@ -86,8 +96,22 @@ jobs:
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
target: runner
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
# Werkzeug-Image fuer die CronJobs (Dockerfile "AS tools"); nutzt die
# gemeinsamen Build-Stufen aus dem Cache des ersten Builds.
- name: Werkzeuge bauen und pushen
uses: docker/build-push-action@v6
with:
context: .
target: tools
push: true
tags: ${{ steps.meta-tools.outputs.tags }}
labels: ${{ steps.meta-tools.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+24
View File
@@ -52,6 +52,27 @@ RUN pnpm add --save-prod \
@prisma/client@^7.9.1 @prisma/adapter-pg@^7.9.1 \ @prisma/client@^7.9.1 @prisma/adapter-pg@^7.9.1 \
@prisma/adapter-mariadb@^7.9.1 mariadb@^3.5.3 @prisma/adapter-mariadb@^7.9.1 mariadb@^3.5.3
# --- Werkzeuge (CronJobs) -------------------------------------------------
# Eigenes Image <repo>-tools fuer die Skripte unter scripts/, die als
# CronJob laufen (Bestandsabgleich mit ApiCore, Freigabe haengender
# Bestellungen), nach dem Muster von artista-modelcamp-tools. Das runner-
# Image enthaelt nur den Standalone-Output: dort fehlen tsconfig.json (der
# Pfad-Alias "@/") und Pakete wie stripe, die Skripte brechen dort mit
# "Cannot find module" ab. Steht VOR runner, damit ein Build ohne --target
# weiterhin das App-Image liefert.
FROM base AS tools
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 --ingroup nodejs nextjs
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/src ./src
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/assets ./assets
COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts /app/package.json /app/tsconfig.json ./
ENV PATH="/app/node_modules/.bin:${PATH}"
USER 1001
CMD ["tsx", "scripts/apicore-stock-sync.ts"]
# --- Runtime -------------------------------------------------------------- # --- Runtime --------------------------------------------------------------
FROM base AS runner FROM base AS runner
ENV NODE_ENV=production ENV NODE_ENV=production
@@ -67,6 +88,9 @@ RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
# Eingebettete Schriften der Rechnungs-PDFs (PDF/A-3, lib/einvoice/facturx-pdf.ts
# liest sie zur Laufzeit aus process.cwd()/assets/fonts).
COPY --from=builder /app/assets ./assets
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma
+30
View File
@@ -458,6 +458,36 @@ Vor dem Hochzählen prüft dieselbe Transaktion, ob zur Bestellung bereits eine
Vier Stellen halten die Nummern gleich lang und damit sortierbar. Gibt es in einem Jahr mehr als 9999 Rechnungen, wächst die Nummer mit, statt abgeschnitten zu werden. Vier Stellen halten die Nummern gleich lang und damit sortierbar. Gibt es in einem Jahr mehr als 9999 Rechnungen, wächst die Nummer mit, statt abgeschnitten zu werden.
### E-Rechnung
Jedes Rechnungs-PDF ist eine hybride E-Rechnung nach **ZUGFeRD 2.x / Factur-X, Profil EN 16931**: ein PDF/A-3b mit eingebettetem `factur-x.xml` (UN/CEFACT CII D16B). Das XML ist der maßgebliche Teil; Buchhaltungsprogramme (ERPNext, DATEV, lexoffice …) lesen es direkt. Erzeugt in `lib/einvoice/cii.ts` (XML) und `lib/einvoice/facturx-pdf.ts` (PDF/A, XMP-Metadaten, Anhang).
- **Summen:** Netto und Steuer je Satz kommen unverändert aus der Aufstellung des PDFs. Die Positionen tragen ihren Nettoanteil (centgenau verteilt), Versand und Rabatt stehen als Zu- und Abschlag je Satz. Bei Bruttopreisen kann ein Prüfprogramm, das die Steuer aus dem Netto nachrechnet, einen Cent Unterschied melden; EN 16931 lässt das ausdrücklich zu (BR-S-09/BR-CO-17), der Steuerbetrag entspricht dem aus dem Bruttopreis herausgerechneten.
- **Schriften:** PDF/A verlangt eingebettete Schriften. Statt Helvetica/Times werden Liberation Sans/Serif aus `assets/fonts` eingebettet (gleiche Laufweiten, SIL Open Font License); das Dockerfile kopiert das Verzeichnis ins Image.
- **Ohne USt-IdNr.** (Einstellungen → Firma) oder bei Positionen ohne Steuersatz entsteht nur das PDF, mit Warnung im Log.
- Geprüft mit XSD und CEN-Schematron EN 16931 sowie Mustang (veraPDF): PDF/A-3b konform, XML gültig.
### Rechnungskopie
Unter Einstellungen → Firma → **„Rechnungskopie an“** lässt sich eine Adresse hinterlegen (z. B. die Buchhaltung). Jede Rechnung und jede Stornorechnung geht dann zusätzlich als eigene Mail mit derselben E-Rechnung dorthin (`lib/invoice-mail.ts`). Leer = keine Kopie.
### Stornorechnung
Eine ausgestellte Rechnung wird nie gelöscht oder geändert. Wird eine Bestellung mit Rechnung storniert (Admin), eine Retoure erstattet oder die Zahlung bei Stripe voll erstattet, entsteht **eine** Stornorechnung `ST-<Jahr>-<laufende Nummer>` (eigener Nummernkreis, Tabelle `cancellation_counters`) mit allen Positionen der Rechnung, negativen Beträgen auf dem PDF und Bezug auf die Rechnung (XML: Gutschrift 381 mit Verweis). Sie geht als E-Rechnung an die Kundschaft und als Kopie an die hinterlegte Adresse. Laufen Admin-Stornierung und Stripe-Webhook derselben Erstattung gleichzeitig, bleibt es bei einer Stornorechnung (eindeutige `invoiceId`, die zweite Transaktion rollt samt Zähler zurück). Eine Bestellung mit Stornorechnung lässt sich nicht wieder öffnen. Teilerstattungen bei Stripe erzeugen keine Stornorechnung (Hinweis im Log).
ERPNext holt Stornorechnungen über `GET /api/erp/rechnungen?seit=…&art=storno` und bucht sie als Rechnungskorrektur gegen die Ausgangsrechnung; die PDFs liegen unter `/api/erp/rechnungen/ST-…/pdf`.
### Kunden und Firmenanfragen im CRM (ERPNext)
Zwei weitere nur lesende Endpunkte mit demselben `ERP_EXPORT_TOKEN` (`lib/erp-crm-export.ts`):
| Endpunkt | Inhalt | In ERPNext |
|---|---|---|
| `GET /api/erp/kunden?nach=<id>&limit=<1-200>` | Kundenkonten (ohne Admins, ohne Passwort/2FA), Standardadresse | Kunde in der Gruppe **Privat** mit Kontakt und Adresse |
| `GET /api/erp/anfragen?nach=<id>&limit=<1-200>` | Firmenanfragen der Geschäftskunden-Seite | Kunde (Firma) in der Gruppe **Gewerblich** mit Ansprechpartner, je Anfrage eine Chance |
ERPNext holt bei jedem Lauf die vollständige Liste (Cursor `nach` = letzte ID der vorigen Seite, `next` in der Antwort). Ein im Shop gelöschtes Konto ohne Rechnung verschwindet dadurch auch aus ERPNext; mit Rechnung bleibt der Kunde wegen der Aufbewahrungspflicht stehen.
## Health-Endpunkte ## Health-Endpunkte
Zwei getrennte Endpunkte, bewusst mit unterschiedlicher Aufgabe: Zwei getrennte Endpunkte, bewusst mit unterschiedlicher Aufgabe:
+102
View File
@@ -0,0 +1,102 @@
Digitized data copyright (c) 2010 Google Corporation
with Reserved Font Arimo, Tinos and Cousine.
Copyright (c) 2012 Red Hat, Inc.
with Reserved Font Name Liberation.
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
PREAMBLE The goals of the Open Font License (OFL) are to stimulate
worldwide development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to provide
a free and open framework in which fonts may be shared and improved in
partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves.
The fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such.
This may include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components
as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting ? in part or in whole ?
any of the components of the Original Version, by changing formats or
by porting the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical writer
or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this license, and must not be distributed
under any other license. The requirement for fonts to remain under
this license does not apply to any document created using the Font
Software.
TERMINATION
This license becomes null and void if any of the above conditions are not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -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
+2 -2
View File
@@ -43,8 +43,8 @@ spec:
periodSeconds: 5 periodSeconds: 5
resources: resources:
requests: requests:
cpu: 100m cpu: "0"
memory: 256Mi memory: "0"
limits: limits:
memory: 512Mi memory: 512Mi
volumeClaimTemplates: volumeClaimTemplates:
+2 -2
View File
@@ -42,8 +42,8 @@ spec:
periodSeconds: 5 periodSeconds: 5
resources: resources:
requests: requests:
cpu: 100m cpu: "0"
memory: 256Mi memory: "0"
limits: limits:
memory: 512Mi memory: 512Mi
volumeClaimTemplates: volumeClaimTemplates:
+2 -2
View File
@@ -164,8 +164,8 @@ spec:
# Transcode hart drosseln, auch wenn der Knoten gerade frei ist. # Transcode hart drosseln, auch wenn der Knoten gerade frei ist.
resources: resources:
requests: requests:
cpu: 500m cpu: "0"
memory: 512Mi memory: "0"
limits: limits:
memory: 1Gi memory: 1Gi
volumes: volumes:
+47 -18
View File
@@ -8,9 +8,9 @@
# ausserdem einer zehnjaehrigen Aufbewahrungspflicht (§ 147 AO) — ein # ausserdem einer zehnjaehrigen Aufbewahrungspflicht (§ 147 AO) — ein
# Datenverlust ist hier nicht nur aergerlich, sondern ein Rechtsproblem. # Datenverlust ist hier nicht nur aergerlich, sondern ein Rechtsproblem.
# #
# Diese Sicherung liegt bewusst NUR im Cluster. Sie schuetzt gegen # Diese Sicherung liegt auf derselben Storage Box wie die Datenbank. Sie
# Volume-Verlust und Bedienfehler, nicht gegen den Ausfall des gesamten # schuetzt gegen Volume-Verlust und Bedienfehler, nicht gegen den Ausfall
# Clusters oder Standorts. Fuer eine vollstaendige Absicherung die # der Storage Box oder des Standorts. Fuer eine vollstaendige Absicherung die
# erzeugten .sql.gz-Dateien zusaetzlich regelmaessig aus dem Cluster # erzeugten .sql.gz-Dateien zusaetzlich regelmaessig aus dem Cluster
# herausziehen (Beispielbefehl siehe k8s/README.md, Abschnitt "Sicherung # herausziehen (Beispielbefehl siehe k8s/README.md, Abschnitt "Sicherung
# und Wiederherstellung"). # und Wiederherstellung").
@@ -20,6 +20,33 @@
# pg_dump-Aufruf unten ersetzen durch: # pg_dump-Aufruf unten ersetzen durch:
# mysqldump -h wabenhain-mysql -u wabenhain -p"$PGPASSWORD" \ # mysqldump -h wabenhain-mysql -u wabenhain -p"$PGPASSWORD" \
# --single-transaction --routines wabenhain | gzip -9 > "$file.partial" # --single-transaction --routines wabenhain | gzip -9 > "$file.partial"
# Ziel ist die Storage Box wie bei den uebrigen Wabenhain-Volumes. Das PV
# zeigt auf den vorhandenen Ordner Customer/Wabenhain; der Unterordner
# db-backup entsteht ueber subPath beim ersten Lauf.
apiVersion: v1
kind: PersistentVolume
metadata:
name: wabenhain-db-backup
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
mountOptions: ["vers=3.0", "uid=70", "gid=70", "dir_mode=0700", "file_mode=0600", "noperm"]
csi:
driver: smb.csi.k8s.io
volumeHandle: wabenhain-db-backup
volumeAttributes:
source: //u652065.your-storagebox.de/backup/Server/Disks/Customer/Wabenhain
nodeStageSecretRef:
name: smb-credentials
namespace: wabenhain
claimRef:
namespace: wabenhain
name: wabenhain-db-backup
---
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
metadata: metadata:
@@ -28,7 +55,8 @@ metadata:
spec: spec:
accessModes: accessModes:
- ReadWriteOnce - ReadWriteOnce
# storageClassName: "dein-storage-class" storageClassName: ""
volumeName: wabenhain-db-backup
resources: resources:
requests: requests:
# Eine gzip-komprimierte Sicherung dieses Datenbestandes liegt im # Eine gzip-komprimierte Sicherung dieses Datenbestandes liegt im
@@ -129,13 +157,14 @@ spec:
ls -lh /backup ls -lh /backup
resources: resources:
requests: requests:
cpu: 50m cpu: "0"
memory: 128Mi memory: "0"
limits: limits:
memory: 256Mi memory: 256Mi
volumeMounts: volumeMounts:
- name: backup - name: backup
mountPath: /backup mountPath: /backup
subPath: db-backup
# Nur lesend: dieser Job darf Belege sichern, niemals aendern. # Nur lesend: dieser Job darf Belege sichern, niemals aendern.
- name: invoices - name: invoices
mountPath: /invoices mountPath: /invoices
@@ -209,17 +238,17 @@ spec:
spec: spec:
restartPolicy: OnFailure restartPolicy: OnFailure
# Gleiche Kennung wie der App-Container (Dockerfile: adduser --uid # Gleiche Kennung wie der App-Container (Dockerfile: adduser --uid
# 1001 nextjs) — dasselbe Image, derselbe Nutzer. # 1001 nextjs) — beide Images legen denselben Nutzer an.
securityContext: securityContext:
runAsUser: 1001 runAsUser: 1001
runAsGroup: 1001 runAsGroup: 1001
containers: containers:
- name: release-stale-orders - name: release-stale-orders
# DASSELBE Image und derselbe Tag wie in 05-app-deployment.yaml. # Werkzeug-Image (Dockerfile "AS tools") mit DEMSELBEN Tag wie
# Beide zusammen aktualisieren: der Lauf nutzt denselben # das App-Image in 05-app-deployment.yaml. Beide zusammen
# Prisma-Client wie die App und muss deshalb zum selben # aktualisieren: der Lauf nutzt denselben Prisma-Client wie die
# Datenbankschema passen. # App und muss deshalb zum selben Datenbankschema passen.
image: git.encry.de/nick.rosenloecher/wabenhain:latest image: git.encry.de/nick.rosenloecher/wabenhain-tools:latest
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
# Braucht DATABASE_URL, DATABASE_PROVIDER und STRIPE_SECRET_KEY # Braucht DATABASE_URL, DATABASE_PROVIDER und STRIPE_SECRET_KEY
# — die stehen bereits in denselben beiden Quellen wie fuer die # — die stehen bereits in denselben beiden Quellen wie fuer die
@@ -242,8 +271,8 @@ spec:
exec tsx scripts/release-stale-orders.ts exec tsx scripts/release-stale-orders.ts
resources: resources:
requests: requests:
cpu: 50m cpu: "0"
memory: 128Mi memory: "0"
limits: limits:
# Derselbe Wert wie beim App-Container: der Lauf laedt # Derselbe Wert wie beim App-Container: der Lauf laedt
# denselben Prisma-Client samt Treiber. # denselben Prisma-Client samt Treiber.
@@ -278,8 +307,8 @@ spec:
runAsGroup: 1001 runAsGroup: 1001
containers: containers:
- name: apicore-stock - name: apicore-stock
# DASSELBE Image und derselbe Tag wie in 05-app-deployment.yaml. # Werkzeug-Image mit demselben Tag wie in 05-app-deployment.yaml.
image: git.encry.de/nick.rosenloecher/wabenhain:latest image: git.encry.de/nick.rosenloecher/wabenhain-tools:latest
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
# Braucht DATABASE_URL, DATABASE_PROVIDER, APICORE_API_URL und # Braucht DATABASE_URL, DATABASE_PROVIDER, APICORE_API_URL und
# APICORE_API_KEY — dieselben Quellen wie die App. # APICORE_API_KEY — dieselben Quellen wie die App.
@@ -296,7 +325,7 @@ spec:
exec tsx scripts/apicore-stock-sync.ts exec tsx scripts/apicore-stock-sync.ts
resources: resources:
requests: requests:
cpu: 50m cpu: "0"
memory: 128Mi memory: "0"
limits: limits:
memory: 512Mi memory: 512Mi
@@ -0,0 +1,35 @@
-- Stornorechnungen: Gegenbeleg mit eigenem Nummernkreis (ST-JJJJ-NNNN) statt
-- Loeschen oder Aendern einer Rechnung. Rein additiv.
-- CreateTable
CREATE TABLE "cancellation_counters" (
"year" INTEGER NOT NULL,
"lastNumber" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "cancellation_counters_pkey" PRIMARY KEY ("year")
);
-- CreateTable
CREATE TABLE "cancellation_invoices" (
"id" TEXT NOT NULL,
"invoiceId" TEXT NOT NULL,
"number" TEXT NOT NULL,
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"totalCents" INTEGER NOT NULL,
"reason" TEXT NOT NULL,
"pdfPath" TEXT,
CONSTRAINT "cancellation_invoices_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "cancellation_invoices_invoiceId_key" ON "cancellation_invoices"("invoiceId");
-- CreateIndex
CREATE UNIQUE INDEX "cancellation_invoices_number_key" ON "cancellation_invoices"("number");
-- CreateIndex
CREATE INDEX "cancellation_invoices_issuedAt_idx" ON "cancellation_invoices"("issuedAt");
-- AddForeignKey
ALTER TABLE "cancellation_invoices" ADD CONSTRAINT "cancellation_invoices_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+36
View File
@@ -711,10 +711,46 @@ model Invoice {
pdfPath String? pdfPath String?
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
cancellation CancellationInvoice?
@@map("invoices") @@map("invoices")
} }
/// Eigener fortlaufender Nummernkreis der Stornorechnungen (ST-JJJJ-NNNN),
/// je Kalenderjahr — aus demselben Grund eine Zeile mit Sperre wie
/// InvoiceCounter.
model CancellationCounter {
year Int @id
lastNumber Int @default(0)
@@map("cancellation_counters")
}
/// Stornorechnung: hebt eine Rechnung vollstaendig auf. Eine Rechnung wird
/// nie geloescht oder geaendert (§ 14 UStG, GoBD); wird die Bestellung
/// storniert oder voll erstattet, entsteht stattdessen dieser Gegenbeleg mit
/// eigener Nummer und Bezug auf die Rechnung (lib/cancellation-invoice.ts).
/// Hoechstens einer je Rechnung. ERPNext uebernimmt ihn als
/// Rechnungskorrektur, die Ausgangsrechnung steht dort dann auf
/// "Credit Note Issued".
model CancellationInvoice {
id String @id @default(cuid())
invoiceId String @unique
number String @unique
issuedAt DateTime @default(now())
/// Betrag der aufgehobenen Rechnung (brutto, positiv).
totalCents Int
/// STORNIERUNG (Admin), RETOURE (Retoure erstattet) oder ERSTATTUNG (volle Erstattung bei Stripe).
reason String
/// Wie Invoice.pdfPath, relativ zu INVOICE_DIR.
pdfPath String?
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
@@index([issuedAt])
@@map("cancellation_invoices")
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Marketing / Kontakt // Marketing / Kontakt
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+11 -1
View File
@@ -9,6 +9,7 @@ import { requireAdmin } from "./_shared";
import { fail, succeed } from "@/lib/flash"; import { fail, succeed } from "@/lib/flash";
import { normalizeContactEmail } from "@/lib/contact-email"; import { normalizeContactEmail } from "@/lib/contact-email";
import { settingsPathForGroup } from "@/lib/settings-tabs"; import { settingsPathForGroup } from "@/lib/settings-tabs";
import { INVOICE_COPY_KEY } from "@/lib/invoice-mail";
const COMPANY_EMAIL_KEY = "company.email"; 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." "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 });
} }
} }
+23
View File
@@ -20,6 +20,7 @@ import { isLocale, defaultLocale } from "@/i18n/config";
import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client"; import type { OrderStatus, PaymentStatus } from "@/generated/prisma/client";
import { requireAdmin, str, strOrNull, fail } from "./_shared"; import { requireAdmin, str, strOrNull, fail } from "./_shared";
import { recordOrderReturn, recordOrderSale } from "@/lib/apicore-stock/stock-events"; import { recordOrderReturn, recordOrderSale } from "@/lib/apicore-stock/stock-events";
import { issueCancellationInvoice } from "@/lib/cancellation-invoice";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule"; import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
import { setFlash, succeed, MAIL_FAILED_HINT } from "@/lib/flash"; 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 } }, payment: { select: { status: true, stripePaymentIntentId: true } },
items: { select: { productId: true, quantity: true, productName: true } }, items: { select: { productId: true, quantity: true, productName: true } },
returnRequest: { select: { status: true } }, returnRequest: { select: { status: true } },
invoice: { select: { cancellation: { select: { number: true } } } },
}, },
}); });
if (!previous) return null; if (!previous) return null;
@@ -159,6 +161,12 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
// Bestaetigung aus dem Formular (siehe needsConfirmToChangeStatus). Ohne // Bestaetigung aus dem Formular (siehe needsConfirmToChangeStatus). Ohne
// sie wird nichts veraendert. // sie wird nichts veraendert.
const reopening = needsConfirmToChangeStatus(previous.status, status); 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) { if (reopening && !confirmedReopen) {
return { ...previous, shipment: null, cancelledNow: false, blocked: "needs-confirm" as const }; 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") { if (result.blocked === "changed") {
return fail(detailPath, "Die Bestellung wurde zwischenzeitlich geändert. Bitte die Seite neu laden und erneut versuchen."); 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. // undefined = keine E-Mail faellig; false = faellig, aber nicht versendet.
let mailSent: boolean | undefined; let mailSent: boolean | undefined;
@@ -307,6 +321,15 @@ export async function updateOrderStatusAction(formData: FormData): Promise<void>
stripeCheckoutSessionId: result.stripeCheckoutSessionId, stripeCheckoutSessionId: result.stripeCheckoutSessionId,
payment: result.payment, 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) { if (stornoWarnings.length > 0) {
await logAdminAction(admin, { await logAdminAction(admin, {
+14 -1
View File
@@ -29,7 +29,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
payment: true, payment: true,
shipment: true, shipment: true,
pickup: true, pickup: true,
invoice: true, invoice: { include: { cancellation: { select: { number: true } } } },
customer: { select: { email: true, firstName: true, lastName: true } }, customer: { select: { email: true, firstName: true, lastName: true } },
discountCode: { select: { code: true } }, discountCode: { select: { code: true } },
}, },
@@ -279,6 +279,19 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
> >
{t.adminOrderDetailPage.downloadPdf} {t.adminOrderDetailPage.downloadPdf}
</a> </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> </div>
) : ( ) : (
<p className="text-ink/65">{t.adminOrderDetailPage.noInvoiceYet}</p> <p className="text-ink/65">{t.adminOrderDetailPage.noInvoiceYet}</p>
+10
View File
@@ -11,6 +11,7 @@ import { returnAcceptedEmail, returnRejectedEmail, returnRefundedEmail } from "@
import { restockOrderItems } from "@/lib/inventory"; import { restockOrderItems } from "@/lib/inventory";
import { recordOrderReturn } from "@/lib/apicore-stock/stock-events"; import { recordOrderReturn } from "@/lib/apicore-stock/stock-events";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule"; import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule";
import { issueCancellationInvoice } from "@/lib/cancellation-invoice";
import { isLocale, defaultLocale } from "@/i18n/config"; import { isLocale, defaultLocale } from "@/i18n/config";
function str(formData: FormData, key: string): string { 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)); 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, { await logAdminAction(admin, {
action: "return_request.refund", action: "return_request.refund",
entityType: "ReturnRequest", entityType: "ReturnRequest",
@@ -58,6 +58,15 @@ vi.mock("@/lib/invoice-storage", () => ({
storeInvoicePdf: vi.fn(async () => undefined), 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` * Minimaler Prisma-Ersatz. `$transaction` reicht dieselbe Instanz als `tx`
* weiter — der Handler verlaesst sich auf die Atomaritaet der Datenbank, und * 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 // Die Rechnung liegt der Bestaetigung bei — Gastbestellungen haben kein
// Konto, in dem sie sonst zu finden waere. // Konto, in dem sie sonst zu finden waere.
expect(sendMail.mock.calls[0][0].attachments?.[0]?.filename).toBe("RE-2026-0001.pdf"); 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 () => { 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 () => { test("laesst den Zahlungsstatus bei einer TEILerstattung unveraendert", async () => {
// Der Restbetrag ist weiterhin eingezogen — auf "REFUNDED" gesetzt // Der Restbetrag ist weiterhin eingezogen — auf "REFUNDED" gesetzt
// wuerde eine Auswertung den ganzen Umsatz abschreiben. // wuerde eine Auswertung den ganzen Umsatz abschreiben.
@@ -376,6 +409,7 @@ describe("Stripe-Webhook", () => {
expect(db.payment.update).not.toHaveBeenCalled(); expect(db.payment.update).not.toHaveBeenCalled();
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Teilerstattung")); 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 () => { test("haengt eine Rueckbuchung an die Notizen der Bestellung an, ohne bestehende zu verlieren", async () => {
+20 -1
View File
@@ -11,6 +11,8 @@ import { invoiceRelativePath, storeInvoicePdf } from "@/lib/invoice-storage";
import { formatInvoiceNumber } from "@/lib/invoice-number"; import { formatInvoiceNumber } from "@/lib/invoice-number";
import { isLocale, defaultLocale } from "@/i18n/config"; import { isLocale, defaultLocale } from "@/i18n/config";
import type Stripe from "stripe"; 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 { recordOrderSale } from "@/lib/apicore-stock/stock-events";
import { scheduleApiCoreStockSync } from "@/lib/apicore-stock/schedule"; 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({ const payment = await prisma.payment.findFirst({
where: { stripePaymentIntentId: paymentIntentId }, 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) { if (!payment) {
console.warn( console.warn(
@@ -194,6 +196,12 @@ async function recordRefund(charge: Stripe.Charge): Promise<void> {
return; 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; if (payment.status === "REFUNDED") return;
await prisma.payment.update({ where: { id: payment.id }, data: { status: "REFUNDED" } }); 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) { } catch (error: unknown) {
// Laut sichtbar machen (statt wie zuvor unbehandelt bis in den // Laut sichtbar machen (statt wie zuvor unbehandelt bis in den
// Next.js-Request-Handler durchzureichen) und weiterwerfen: der 500er // Next.js-Request-Handler durchzureichen) und weiterwerfen: der 500er
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { erpExportAuthorized } from "@/lib/erp-export";
import { listErpInquiries, parseErpCursor } from "@/lib/erp-crm-export";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
const FAILED_ATTEMPTS_PER_WINDOW = 20;
const FAILED_WINDOW_MS = 15 * 60 * 1000;
/**
* Firmenanfragen fuer das CRM in ERPNext: GET /api/erp/anfragen?nach=<id>&limit=<1-200>.
* Nur lesend und nur mit ERP_EXPORT_TOKEN, siehe lib/erp-crm-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 = parseErpCursor(new URL(request.url).searchParams);
if (!query) {
return NextResponse.json({ error: "nach (Anfrage-ID) und limit (1-200) pruefen." }, { status: 400 });
}
return NextResponse.json(await listErpInquiries(query.after, query.limit), { headers: { "Cache-Control": "no-store" } });
}
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { erpExportAuthorized } from "@/lib/erp-export";
import { listErpCustomers, parseErpCursor } from "@/lib/erp-crm-export";
import { rateLimit, getClientIp } from "@/lib/rate-limit";
const FAILED_ATTEMPTS_PER_WINDOW = 20;
const FAILED_WINDOW_MS = 15 * 60 * 1000;
/**
* Kundenkonten fuer das CRM in ERPNext: GET /api/erp/kunden?nach=<id>&limit=<1-200>.
* Nur lesend und nur mit ERP_EXPORT_TOKEN, siehe lib/erp-crm-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 = parseErpCursor(new URL(request.url).searchParams);
if (!query) {
return NextResponse.json({ error: "nach (Kunden-ID) und limit (1-200) pruefen." }, { status: 400 });
}
return NextResponse.json(await listErpCustomers(query.after, query.limit), { headers: { "Cache-Control": "no-store" } });
}
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
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- 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();
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 = isErpDocumentNumber(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",
},
});
}
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
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>,
* Stornorechnungen mit &art=storno.
* 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 =
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 { getInvoiceForCustomer, getInvoiceByNumber } from "@/lib/account-data";
import { generateInvoicePdf } from "@/lib/invoice-pdf"; import { generateInvoicePdf } from "@/lib/invoice-pdf";
import { readStoredInvoicePdf } from "@/lib/invoice-storage"; import { readStoredInvoicePdf } from "@/lib/invoice-storage";
import { cancellationPdf, isCancellationNumber } from "@/lib/cancellation-invoice";
import { prisma } from "@/lib/prisma";
import { isLocale, defaultLocale } from "@/i18n/config"; import { isLocale, defaultLocale } from "@/i18n/config";
export async function GET( export async function GET(
@@ -15,6 +17,24 @@ export async function GET(
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 }); 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 = const invoice =
customer.role === "ADMIN" customer.role === "ADMIN"
? await getInvoiceByNumber(invoiceNumber) ? await getInvoiceByNumber(invoiceNumber)
+6
View File
@@ -1652,6 +1652,12 @@ export const de: Dictionary = {
thankYou: "Vielen Dank, dass du unsere Imkerei unterstützt!", thankYou: "Vielen Dank, dass du unsere Imkerei unterstützt!",
pageLabel: (page, total) => `Seite ${page} von ${total}`, pageLabel: (page, total) => `Seite ${page} von ${total}`,
legalNote: "Diese Rechnung wurde automatisch erzeugt und ist ohne Unterschrift gültig.", legalNote: "Diese Rechnung wurde automatisch erzeugt und ist ohne Unterschrift gültig.",
cancellationTitle: "Stornorechnung",
cancellationNumberLabel: "Stornorechnungsnr.",
cancellationDateLabel: "Datum",
cancelsInvoiceLabel: "Zur Rechnung",
cancellationNote: (invoiceNumber, invoiceDate) =>
`Diese Stornorechnung hebt die Rechnung ${invoiceNumber} vom ${invoiceDate} vollständig auf. Der Betrag wird über die ursprüngliche Zahlungsart erstattet.`,
}, },
addressesPage: { addressesPage: {
title: "Meine Adressen", title: "Meine Adressen",
+6
View File
@@ -1626,6 +1626,12 @@ export const en: Dictionary = {
thankYou: "Thank you for supporting our apiary!", thankYou: "Thank you for supporting our apiary!",
pageLabel: (page, total) => `Page ${page} of ${total}`, pageLabel: (page, total) => `Page ${page} of ${total}`,
legalNote: "This invoice was generated automatically and is valid without a signature.", legalNote: "This invoice was generated automatically and is valid without a signature.",
cancellationTitle: "Cancellation invoice",
cancellationNumberLabel: "Cancellation no.",
cancellationDateLabel: "Date",
cancelsInvoiceLabel: "Cancels invoice",
cancellationNote: (invoiceNumber, invoiceDate) =>
`This cancellation invoice fully reverses invoice ${invoiceNumber} dated ${invoiceDate}. The amount will be refunded via the original payment method.`,
}, },
addressesPage: { addressesPage: {
title: "My Addresses", title: "My Addresses",
+5
View File
@@ -1169,6 +1169,11 @@ export interface Dictionary {
thankYou: string; thankYou: string;
pageLabel: (page: number, total: number) => string; pageLabel: (page: number, total: number) => string;
legalNote: string; legalNote: string;
cancellationTitle: string;
cancellationNumberLabel: string;
cancellationDateLabel: string;
cancelsInvoiceLabel: string;
cancellationNote: (invoiceNumber: string, invoiceDate: string) => string;
}; };
addressesPage: { addressesPage: {
title: string; title: string;
+125
View File
@@ -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);
});
+165
View File
@@ -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));
}
+2
View File
@@ -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.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.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 }, { 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 --- // --- Liefergebiet ---
// Steuert dreierlei zugleich: die Laenderauswahl im Checkout, die // Steuert dreierlei zugleich: die Laenderauswahl im Checkout, die
+171
View File
@@ -0,0 +1,171 @@
import { describe, test, expect } from "vitest";
import { amount, buildCiiXml, escapeXml, type EInvoiceInput } from "./cii";
import { vatBreakdown } from "@/lib/vat";
function input(overrides: Partial<EInvoiceInput> = {}): EInvoiceInput {
const items = [
{ name: "Waldhonig 500 g", quantity: 3, totalCents: 2697, vatRatePercent: 7 },
{ name: "Bienenwachskerze", quantity: 1, totalCents: 1290, vatRatePercent: 19 },
{ name: "Blütenhonig 250 g", quantity: 2, totalCents: 1198, vatRatePercent: 7 },
];
return {
kind: "invoice",
number: "RE-2026-0042",
issuedAt: new Date(2026, 9, 12, 10, 0),
currency: "EUR",
orderNumber: "WH-2026-0042",
seller: {
name: "Wabenhain",
street: "Musterweg 1",
zip: "12345",
city: "Musterstadt",
countryCode: "DE",
vatId: "DE123456789",
email: "[email protected]",
},
buyer: { name: "Erika Musterfrau", street: "Musterstraße 1", zip: "80331", city: "München", countryCode: "DE" },
items,
shippingCents: 490,
discountCents: 300,
discountCode: "SOMMER",
totalCents: 2697 + 1290 + 1198 + 490 - 300,
paymentNote: "Bereits bezahlt (Online-Zahlung über Stripe).",
...overrides,
};
}
function values(xml: string, element: string): string[] {
return [...xml.matchAll(new RegExp(`<${element}[^>]*>([^<]*)</${element}>`, "g"))].map((match) => match[1]);
}
function cents(value: string): number {
return Math.round(Number(value) * 100);
}
function single(xml: string, element: string): number {
const found = values(xml, element);
expect(found).toHaveLength(1);
return cents(found[0]);
}
function headerLineTotal(xml: string): number {
const match = /<ram:SpecifiedTradeSettlementHeaderMonetarySummation><ram:LineTotalAmount>([^<]*)</.exec(xml);
if (!match) throw new Error("BT-106 fehlt");
return cents(match[1]);
}
function ok(data: EInvoiceInput): string {
const result = buildCiiXml(data);
if (!result.ok) throw new Error(result.reason);
return result.xml;
}
describe("buildCiiXml", () => {
test("Summen stimmen mit der Steueraufstellung des PDFs auf den Cent ueberein", () => {
// Arrange
const data = input();
const rated = data.items.map((item) => ({ totalCents: item.totalCents, vatRatePercent: item.vatRatePercent ?? 0 }));
const expected = vatBreakdown({ items: rated, shippingCents: 490, discountCents: 300 });
// Act
const xml = ok(data);
// Assert
expect(values(xml, "ram:BasisAmount").map(cents)).toEqual(expected.map((line) => line.netCents));
expect(values(xml, "ram:CalculatedAmount").map(cents)).toEqual(expected.map((line) => line.vatCents));
const basis = single(xml, "ram:TaxBasisTotalAmount");
const tax = single(xml, "ram:TaxTotalAmount");
expect(headerLineTotal(xml) - single(xml, "ram:AllowanceTotalAmount") + single(xml, "ram:ChargeTotalAmount")).toBe(basis);
expect(basis + tax).toBe(data.totalCents);
expect(single(xml, "ram:GrandTotalAmount")).toBe(data.totalCents);
expect(single(xml, "ram:TotalPrepaidAmount")).toBe(data.totalCents);
expect(single(xml, "ram:DuePayableAmount")).toBe(0);
});
test("Positionen ergeben zusammen genau die Positionssumme (BT-106)", () => {
// Act
const xml = ok(input());
// Assert
const lineAmounts = [
...xml.matchAll(/<ram:SpecifiedTradeSettlementLineMonetarySummation><ram:LineTotalAmount>([^<]*)</g),
].map((match) => cents(match[1]));
expect(lineAmounts).toHaveLength(3);
expect(lineAmounts.reduce((sum, value) => sum + value, 0)).toBe(headerLineTotal(xml));
expect(values(xml, "ram:BilledQuantity")).toEqual(["3", "2", "1"]);
});
test("Kopfdaten: Profil EN 16931, Typ 380, Datum, Verkaeufer mit USt-IdNr.", () => {
// Act
const xml = ok(input());
// Assert
expect(xml).toContain("<ram:ID>urn:cen.eu:en16931:2017</ram:ID>");
expect(xml).toContain("<ram:TypeCode>380</ram:TypeCode>");
expect(xml).toContain('<udt:DateTimeString format="102">20261012</udt:DateTimeString>');
expect(xml).toContain('<ram:ID schemeID="VA">DE123456789</ram:ID>');
expect(xml).toContain("<ram:Reason>Rabatt (SOMMER)</ram:Reason>");
expect(xml).toContain("<ram:Reason>Versand</ram:Reason>");
});
test("Stornorechnung ist eine Gutschrift (381) mit Bezug, offen zur Erstattung", () => {
// Arrange
const data = input({
kind: "creditNote",
number: "ST-2026-0001",
precedingInvoice: { number: "RE-2026-0042", issuedAt: new Date(2026, 9, 1, 12, 0) },
paymentNote: "Der Betrag wird über die ursprüngliche Zahlungsart erstattet.",
});
// Act
const xml = ok(data);
// Assert
expect(xml).toContain("<ram:TypeCode>381</ram:TypeCode>");
expect(xml).toMatch(
/<ram:InvoiceReferencedDocument><ram:IssuerAssignedID>RE-2026-0042<\/ram:IssuerAssignedID><ram:FormattedIssueDateTime><qdt:DateTimeString format="102">20261001</
);
expect(single(xml, "ram:TotalPrepaidAmount")).toBe(0);
expect(single(xml, "ram:DuePayableAmount")).toBe(data.totalCents);
});
test("Kleinunternehmer: Kategorie E mit Befreiungsgrund", () => {
// Arrange
const items = [{ name: "Honig", quantity: 1, totalCents: 1000, vatRatePercent: 0 }];
// Act
const xml = ok(input({ items, shippingCents: 0, discountCents: 0, totalCents: 1000 }));
// Assert
expect(values(xml, "ram:CategoryCode").every((code) => code === "E")).toBe(true);
expect(xml).toContain("§ 19 UStG");
expect(single(xml, "ram:TaxTotalAmount")).toBe(0);
});
test("ohne USt-IdNr. oder mit Position ohne Steuersatz keine E-Rechnung", () => {
expect(buildCiiXml(input({ seller: { ...input().seller, vatId: " " } }))).toMatchObject({ ok: false });
const items = [{ name: "Alt", quantity: 1, totalCents: 1000, vatRatePercent: null }];
expect(buildCiiXml(input({ items, shippingCents: 0, discountCents: 0, totalCents: 1000 }))).toMatchObject({ ok: false });
});
test("Sonderzeichen im Namen werden maskiert", () => {
// Act
const xml = ok(input({ buyer: { ...input().buyer, name: 'Müller & Söhne <GmbH> "Bio"' } }));
// Assert
expect(xml).toContain("<ram:Name>Müller &amp; Söhne &lt;GmbH&gt; &quot;Bio&quot;</ram:Name>");
});
});
describe("Hilfsfunktionen", () => {
test("amount formatiert Cent ohne Gleitkommafehler", () => {
expect(amount(0)).toBe("0.00");
expect(amount(5)).toBe("0.05");
expect(amount(123456)).toBe("1234.56");
expect(amount(-250)).toBe("-2.50");
});
test("escapeXml maskiert alle fuenf Sonderzeichen", () => {
expect(escapeXml(`<a href="x">'&'</a>`)).toBe("&lt;a href=&quot;x&quot;&gt;&apos;&amp;&apos;&lt;/a&gt;");
});
});
+317
View File
@@ -0,0 +1,317 @@
import { distributeCents, splitGross, vatLinesWithShares } from "@/lib/vat";
/**
* E-Rechnung nach EN 16931 als UN/CEFACT Cross Industry Invoice (CII D16B),
* Profil "EN 16931" (ZUGFeRD 2.x COMFORT / Factur-X). Das XML wird in das
* Rechnungs-PDF eingebettet (lib/einvoice/facturx-pdf.ts); PDF und XML
* zusammen sind die hybride E-Rechnung, das XML ist der massgebliche Teil.
*
* Die Shop-Preise sind Bruttopreise, EN 16931 rechnet netto. Damit XML und PDF
* auf den Cent dieselben Summen tragen, kommen Netto und Steuer je Satz
* unveraendert aus der Aufstellung des PDFs (lib/vat.ts); nur die Aufteilung
* auf die Positionen wird hier gerechnet:
* Netto der Waren je Satz = Netto des Satzes - Versand netto + Rabatt netto,
* centgenau verteilt nach den Bruttobetraegen der Positionen. Versand und
* Rabatt stehen als Zu- und Abschlag auf Belegebene, je Satz.
*
* Eine Stornorechnung ist eine Gutschrift (Typ 381) mit positiven Betraegen
* und Verweis auf die aufgehobene Rechnung (BT-25/BT-26).
*/
export const EN16931_GUIDELINE_ID = "urn:cen.eu:en16931:2017";
export const FACTURX_FILENAME = "factur-x.xml";
const NS = {
rsm: "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100",
ram: "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100",
qdt: "urn:un:unece:uncefact:data:standard:QualifiedDataType:100",
udt: "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100",
} as const;
const TYPE_CODE = { invoice: "380", creditNote: "381" } as const;
/** UN/ECE Rec. 20: Stueck. */
const UNIT_PIECE = "H87";
const SMALL_BUSINESS_REASON = "Kein Ausweis von Umsatzsteuer, da Kleinunternehmer gemäß § 19 UStG";
export type EInvoiceKind = keyof typeof TYPE_CODE;
export interface EInvoiceParty {
name: string;
street: string;
zip: string;
city: string;
/** ISO 3166-1 alpha-2, z. B. "DE". */
countryCode: string;
}
export interface EInvoiceSeller extends EInvoiceParty {
vatId: string;
email?: string | null;
}
export interface EInvoiceItem {
name: string;
quantity: number;
/** Bruttobetrag der Position in Cent. */
totalCents: number;
vatRatePercent: number | null;
}
export interface EInvoiceInput {
kind: EInvoiceKind;
number: string;
issuedAt: Date;
currency: string;
orderNumber: string;
seller: EInvoiceSeller;
buyer: EInvoiceParty;
items: EInvoiceItem[];
/** Brutto, wie in der Bestellung. */
shippingCents: number;
discountCents: number;
discountCode?: string | null;
/** Rechnungsbetrag brutto; muss der Summe der Steueraufstellung entsprechen. */
totalCents: number;
/** Nur bei der Stornorechnung: die aufgehobene Rechnung. */
precedingInvoice?: { number: string; issuedAt: Date };
/** Zahlungsbedingungen (BT-20), z. B. "Bezahlt über Stripe". */
paymentNote: string;
}
export type EInvoiceResult = { ok: true; xml: string } | { ok: false; reason: string };
export function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/** Cent als Dezimalbetrag mit zwei Stellen, ohne Gleitkomma. */
export function amount(cents: number): string {
const sign = cents < 0 ? "-" : "";
const abs = Math.abs(cents);
return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, "0")}`;
}
/** Nettopreis je Stueck mit vier Nachkommastellen (Zehntausendstel Euro). */
function unitPrice(lineNetCents: number, quantity: number): string {
const tenThousandths = Math.round((lineNetCents * 100) / quantity);
return `${Math.floor(tenThousandths / 10000)}.${String(tenThousandths % 10000).padStart(4, "0")}`;
}
/** Datum im Format 102 (JJJJMMTT), Kalendertag wie auf dem PDF (lib/format.ts). */
function date102(value: Date): string {
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${value.getFullYear()}${month}${day}`;
}
function percent(value: number): string {
return value.toFixed(2);
}
function tag(name: string, content: string, attributes = ""): string {
return `<${name}${attributes}>${content}</${name}>`;
}
function text(name: string, value: string, attributes = ""): string {
return tag(name, escapeXml(value), attributes);
}
interface TaxCategory {
code: "S" | "E";
ratePercent: number;
}
interface RateBlock {
category: TaxCategory;
netCents: number;
vatCents: number;
shippingNetCents: number;
discountNetCents: number;
lines: { item: EInvoiceItem; netCents: number }[];
}
function categoryXml(category: TaxCategory): string {
return (
text("ram:TypeCode", "VAT") +
text("ram:CategoryCode", category.code) +
text("ram:RateApplicablePercent", percent(category.ratePercent))
);
}
function partyXml(party: EInvoiceParty, extra = ""): string {
const address =
text("ram:PostcodeCode", party.zip) +
text("ram:LineOne", party.street) +
text("ram:CityName", party.city) +
text("ram:CountryID", party.countryCode);
return text("ram:Name", party.name) + tag("ram:PostalTradeAddress", address) + extra;
}
function lineXml(index: number, line: RateBlock["lines"][number], category: TaxCategory): string {
return tag(
"ram:IncludedSupplyChainTradeLineItem",
tag("ram:AssociatedDocumentLineDocument", text("ram:LineID", String(index))) +
tag("ram:SpecifiedTradeProduct", text("ram:Name", line.item.name)) +
tag(
"ram:SpecifiedLineTradeAgreement",
tag("ram:NetPriceProductTradePrice", text("ram:ChargeAmount", unitPrice(line.netCents, line.item.quantity)))
) +
tag("ram:SpecifiedLineTradeDelivery", text("ram:BilledQuantity", String(line.item.quantity), ` unitCode="${UNIT_PIECE}"`)) +
tag(
"ram:SpecifiedLineTradeSettlement",
tag("ram:ApplicableTradeTax", categoryXml(category)) +
tag("ram:SpecifiedTradeSettlementLineMonetarySummation", text("ram:LineTotalAmount", amount(line.netCents)))
)
);
}
function allowanceChargeXml(isCharge: boolean, netCents: number, reason: string, category: TaxCategory): string {
return tag(
"ram:SpecifiedTradeAllowanceCharge",
tag("ram:ChargeIndicator", tag("udt:Indicator", isCharge ? "true" : "false")) +
text("ram:ActualAmount", amount(netCents)) +
text("ram:Reason", reason) +
tag("ram:CategoryTradeTax", categoryXml(category))
);
}
function headerTaxXml(block: RateBlock): string {
return tag(
"ram:ApplicableTradeTax",
text("ram:CalculatedAmount", amount(block.vatCents)) +
text("ram:TypeCode", "VAT") +
(block.category.code === "E" ? text("ram:ExemptionReason", SMALL_BUSINESS_REASON) : "") +
text("ram:BasisAmount", amount(block.netCents)) +
text("ram:CategoryCode", block.category.code) +
text("ram:RateApplicablePercent", percent(block.category.ratePercent))
);
}
/**
* Steuerbloecke je Satz, oder die Begruendung, warum sich keine gueltige
* E-Rechnung bilden laesst (Position ohne Satz, steuerfrei gemischt mit
* steuerpflichtig, Summen passen nicht).
*/
function rateBlocks(input: EInvoiceInput): RateBlock[] | string {
const lines = vatLinesWithShares(input);
if (!lines || lines.length === 0) return "Positionen ohne Steuersatz";
const exempt = lines.filter((line) => line.ratePercent === 0);
if (exempt.length > 0 && exempt.length !== lines.length) {
return "Steuerfreie und steuerpflichtige Positionen gemischt";
}
const gross = lines.reduce((sum, line) => sum + line.grossCents, 0);
if (gross !== input.totalCents) return `Steueraufstellung ${gross} weicht vom Rechnungsbetrag ${input.totalCents} ab`;
return lines.map((line) => {
const category: TaxCategory = { code: line.ratePercent > 0 ? "S" : "E", ratePercent: line.ratePercent };
const shippingNetCents = splitGross(line.shippingCents, line.ratePercent).netCents;
const discountNetCents = splitGross(line.discountCents, line.ratePercent).netCents;
const items = input.items.filter((item) => item.vatRatePercent === line.ratePercent);
const goodsNet = line.netCents - shippingNetCents + discountNetCents;
const nets = distributeCents(goodsNet, items.map((item) => item.totalCents));
return {
category,
netCents: line.netCents,
vatCents: line.vatCents,
shippingNetCents,
discountNetCents,
lines: items.map((item, index) => ({ item, netCents: nets[index] })),
};
});
}
function settlementXml(input: EInvoiceInput, blocks: RateBlock[]): string {
const discountReason = input.discountCode ? `Rabatt (${input.discountCode})` : "Rabatt";
const allowancesCharges = blocks
.flatMap((block) => [
block.shippingNetCents ? allowanceChargeXml(true, block.shippingNetCents, "Versand", block.category) : "",
block.discountNetCents ? allowanceChargeXml(false, block.discountNetCents, discountReason, block.category) : "",
])
.join("");
const sum = (pick: (block: RateBlock) => number) => blocks.reduce((total, block) => total + pick(block), 0);
const lineTotal = sum((block) => block.lines.reduce((total, line) => total + line.netCents, 0));
const taxBasis = sum((block) => block.netCents);
const taxTotal = sum((block) => block.vatCents);
const grandTotal = taxBasis + taxTotal;
// Rechnung: per Stripe bereits bezahlt. Stornorechnung: der Betrag geht an die Kundschaft zurueck.
const prepaid = input.kind === "invoice" ? grandTotal : 0;
const reference = input.precedingInvoice
? tag(
"ram:InvoiceReferencedDocument",
text("ram:IssuerAssignedID", input.precedingInvoice.number) +
tag("ram:FormattedIssueDateTime", text("qdt:DateTimeString", date102(input.precedingInvoice.issuedAt), ' format="102"'))
)
: "";
return (
text("ram:InvoiceCurrencyCode", input.currency) +
blocks.map(headerTaxXml).join("") +
allowancesCharges +
tag("ram:SpecifiedTradePaymentTerms", text("ram:Description", input.paymentNote)) +
tag(
"ram:SpecifiedTradeSettlementHeaderMonetarySummation",
text("ram:LineTotalAmount", amount(lineTotal)) +
text("ram:ChargeTotalAmount", amount(sum((block) => block.shippingNetCents))) +
text("ram:AllowanceTotalAmount", amount(sum((block) => block.discountNetCents))) +
text("ram:TaxBasisTotalAmount", amount(taxBasis)) +
text("ram:TaxTotalAmount", amount(taxTotal), ` currencyID="${escapeXml(input.currency)}"`) +
text("ram:GrandTotalAmount", amount(grandTotal)) +
text("ram:TotalPrepaidAmount", amount(prepaid)) +
text("ram:DuePayableAmount", amount(grandTotal - prepaid))
) +
reference
);
}
export function buildCiiXml(input: EInvoiceInput): EInvoiceResult {
if (!input.seller.vatId.trim()) return { ok: false, reason: "USt-IdNr. des Shops fehlt (Einstellungen > Firma)" };
if (input.kind === "creditNote" && !input.precedingInvoice) return { ok: false, reason: "Stornorechnung ohne Bezug" };
const blocks = rateBlocks(input);
if (typeof blocks === "string") return { ok: false, reason: blocks };
let lineIndex = 0;
const lineItems = blocks.flatMap((block) => block.lines.map((line) => lineXml(++lineIndex, line, block.category))).join("");
const seller = input.seller;
const sellerExtra =
(seller.email ? tag("ram:URIUniversalCommunication", text("ram:URIID", seller.email, ' schemeID="EM"')) : "") +
tag("ram:SpecifiedTaxRegistration", text("ram:ID", seller.vatId.trim(), ' schemeID="VA"'));
const note = input.precedingInvoice
? tag("ram:IncludedNote", text("ram:Content", `Stornorechnung zur Rechnung ${input.precedingInvoice.number}`))
: "";
const body =
tag(
"rsm:ExchangedDocumentContext",
tag("ram:GuidelineSpecifiedDocumentContextParameter", text("ram:ID", EN16931_GUIDELINE_ID))
) +
tag(
"rsm:ExchangedDocument",
text("ram:ID", input.number) +
text("ram:TypeCode", TYPE_CODE[input.kind]) +
tag("ram:IssueDateTime", text("udt:DateTimeString", date102(input.issuedAt), ' format="102"')) +
note
) +
tag(
"rsm:SupplyChainTradeTransaction",
lineItems +
tag(
"ram:ApplicableHeaderTradeAgreement",
tag("ram:SellerTradeParty", partyXml(seller, sellerExtra)) +
tag("ram:BuyerTradeParty", partyXml(input.buyer)) +
tag("ram:BuyerOrderReferencedDocument", text("ram:IssuerAssignedID", input.orderNumber))
) +
tag("ram:ApplicableHeaderTradeDelivery", "") +
tag("ram:ApplicableHeaderTradeSettlement", settlementXml(input, blocks))
);
const namespaces = Object.entries(NS)
.map(([prefix, uri]) => ` xmlns:${prefix}="${uri}"`)
.join("");
return { ok: true, xml: `<?xml version="1.0" encoding="UTF-8"?>\n${tag("rsm:CrossIndustryInvoice", body, namespaces)}` };
}
+125
View File
@@ -0,0 +1,125 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { FACTURX_FILENAME } from "@/lib/einvoice/cii";
/**
* PDF/A-3b mit eingebettetem Factur-X-XML (ZUGFeRD 2.x, Profil EN 16931).
*
* PDF/A verlangt eingebettete Schriften; die 14 Standardschriften von pdfkit
* (Helvetica, Times) sind das nicht. Statt ihrer werden Liberation Sans und
* Liberation Serif eingebettet (SIL Open Font License, assets/fonts), die
* dieselben Laufweiten haben — das Layout der Rechnung bleibt unveraendert.
* Sie werden unter den Namen der Standardschriften registriert, damit der
* Zeichencode (font("Helvetica-Bold") usw.) gleich bleibt.
*/
const FONT_DIR = path.join(/*turbopackIgnore: true*/ process.cwd(), "assets", "fonts");
const FONT_FILES = {
Helvetica: "LiberationSans-Regular.ttf",
"Helvetica-Bold": "LiberationSans-Bold.ttf",
"Times-Roman": "LiberationSerif-Regular.ttf",
"Times-Italic": "LiberationSerif-Italic.ttf",
} as const;
type FontName = keyof typeof FONT_FILES;
/** undefined: noch nicht gelesen; null: Schriften fehlen (Rueckfall auf Standardschriften). */
let fontCache: Record<FontName, Buffer> | null | undefined;
function fonts(): Record<FontName, Buffer> | null {
if (fontCache === undefined) {
try {
const entries = Object.entries(FONT_FILES).map(([name, file]) => [name, readFileSync(path.join(FONT_DIR, file))]);
fontCache = Object.fromEntries(entries) as Record<FontName, Buffer>;
} catch (error: unknown) {
// Eine Rechnung darf nie an den Schriften scheitern: ohne sie entsteht
// das PDF mit Standardschriften und eingebettetem XML, nur nicht als
// PDF/A. Laut melden, damit das fehlende Verzeichnis auffaellt.
console.error(`[invoice-pdf] Schriften fuer PDF/A fehlen unter ${FONT_DIR} — Rechnungen ohne PDF/A:`, error);
fontCache = null;
}
}
return fontCache;
}
/**
* Optionen fuer new PDFDocument(): PDF/A-3b und eine eingebettete Grundschrift.
* Ohne "font" laedt pdfkit beim Anlegen die Standard-Helvetica, die dann
* nicht eingebettet im Dokument stuende. PDF 1.7 auch im Rueckfall, sonst
* schreibt pdfkit keine XMP-Metadaten (Factur-X-Angaben).
*/
export function pdfaOptions(): PDFKit.PDFDocumentOptions {
if (!fonts()) return { pdfVersion: "1.7" };
return { pdfVersion: "1.7", subset: "PDF/A-3b", font: path.join(FONT_DIR, FONT_FILES.Helvetica) };
}
/** Registriert die eingebetteten Schriften unter den Namen der Standardschriften. */
export function registerEmbeddedFonts(doc: PDFKit.PDFDocument): void {
for (const [name, data] of Object.entries(fonts() ?? {})) {
doc.registerFont(name, data);
}
}
/** pdfkit schreibt Titel und Autor ungeprueft in die XMP-Metadaten; dort duerfen sie kein XML aufbrechen. */
export function xmpSafe(value: string): string {
return value.replace(/[<>&]/g, " ").replace(/\s+/g, " ").trim();
}
const FX_NAMESPACE = "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#";
function fxProperty(name: string, description: string): string {
return `
<rdf:li rdf:parseType="Resource">
<pdfaProperty:name>${name}</pdfaProperty:name>
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
<pdfaProperty:category>external</pdfaProperty:category>
<pdfaProperty:description>${description}</pdfaProperty:description>
</rdf:li>`;
}
/**
* Bettet das XML als factur-x.xml ein (AFRelationship "Alternative", wie
* ZUGFeRD es fuer Deutschland vorsieht) und traegt die Factur-X-Angaben samt
* PDF/A-Erweiterungsschema in die XMP-Metadaten ein. Vor doc.end() aufrufen.
*/
export function embedFacturX(doc: PDFKit.PDFDocument, xml: string, issuedAt: Date): void {
// "relationship" kennt pdfkit (AFRelationship), die Typdefinition noch nicht.
const options: PDFKit.Mixins.PDFAttachmentOptions & { relationship: string } = {
name: FACTURX_FILENAME,
type: "text/xml",
relationship: "Alternative",
description: "Factur-X / ZUGFeRD invoice (EN 16931)",
creationDate: issuedAt,
modifiedDate: issuedAt,
};
doc.file(Buffer.from(xml, "utf8"), options);
doc.appendXML(`
<rdf:Description xmlns:fx="${FX_NAMESPACE}" rdf:about="">
<fx:DocumentType>INVOICE</fx:DocumentType>
<fx:DocumentFileName>${FACTURX_FILENAME}</fx:DocumentFileName>
<fx:Version>1.0</fx:Version>
<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>
</rdf:Description>
<rdf:Description xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#" rdf:about="">
<pdfaExtension:schemas>
<rdf:Bag>
<rdf:li rdf:parseType="Resource">
<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
<pdfaSchema:namespaceURI>${FX_NAMESPACE}</pdfaSchema:namespaceURI>
<pdfaSchema:prefix>fx</pdfaSchema:prefix>
<pdfaSchema:property>
<rdf:Seq>${[
fxProperty("DocumentFileName", "The name of the embedded XML document"),
fxProperty("DocumentType", "The type of the hybrid document in capital letters, e.g. INVOICE or ORDER"),
fxProperty("Version", "The actual version of the standard applying to the embedded XML document"),
fxProperty("ConformanceLevel", "The conformance level of the embedded XML document"),
].join("")}
</rdf:Seq>
</pdfaSchema:property>
</rdf:li>
</rdf:Bag>
</pdfaExtension:schemas>
</rdf:Description>
`);
}
+20
View File
@@ -101,6 +101,12 @@ interface EmailStrings {
button: string; button: string;
subject: (orderNumber: string) => 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 = { const de: EmailStrings = {
@@ -202,6 +208,13 @@ const de: EmailStrings = {
button: "ERNEUT BESTELLEN", button: "ERNEUT BESTELLEN",
subject: (orderNumber) => `Zahlung zu Bestellung ${orderNumber} fehlgeschlagen — Wabenhain`, 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 = { const en: EmailStrings = {
@@ -303,6 +316,13 @@ const en: EmailStrings = {
button: "ORDER AGAIN", button: "ORDER AGAIN",
subject: (orderNumber) => `Payment for order ${orderNumber} failed — Wabenhain`, 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 }; const emailStrings: Record<Locale, EmailStrings> = { de, en };
+25
View File
@@ -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> { export async function paymentFailedEmail(order: OrderStatusUpdateInput): Promise<EmailContent> {
const s = getEmailStrings(order.locale).paymentFailed; const s = getEmailStrings(order.locale).paymentFailed;
// Der Text (retryNote) fordert bereits zur erneuten Bestellung auf — ohne // Der Text (retryNote) fordert bereits zur erneuten Bestellung auf — ohne
+107
View File
@@ -0,0 +1,107 @@
import { describe, test, expect, vi, beforeEach } from "vitest";
const customerFindMany = vi.fn();
const inquiryFindMany = vi.fn();
vi.mock("@/lib/prisma", () => ({
prisma: {
customer: { findMany: (...args: unknown[]) => customerFindMany(...args) },
businessInquiry: { findMany: (...args: unknown[]) => inquiryFindMany(...args) },
},
}));
vi.mock("@/lib/invoice-pdf", () => ({ generateInvoicePdf: vi.fn() }));
vi.mock("@/lib/invoice-storage", () => ({ readStoredInvoicePdf: vi.fn() }));
const { listErpCustomers, listErpInquiries, parseErpCursor } = await import("./erp-crm-export");
beforeEach(() => vi.clearAllMocks());
describe("parseErpCursor", () => {
test("Cursor optional, Limit begrenzt, nur harmlose IDs", () => {
expect(parseErpCursor(new URLSearchParams(""))).toEqual({ after: null, limit: 200 });
expect(parseErpCursor(new URLSearchParams("nach=cm1abc&limit=50"))).toEqual({ after: "cm1abc", limit: 50 });
expect(parseErpCursor(new URLSearchParams("limit=0"))).toBeNull();
expect(parseErpCursor(new URLSearchParams("nach=a'b"))).toBeNull();
});
});
describe("listErpCustomers", () => {
test("nur Kundschaft, ab dem Cursor, mit Standardadresse und naechstem Cursor", async () => {
// Arrange
customerFindMany.mockResolvedValue([
{
id: "c2",
email: "[email protected]",
firstName: "Erika",
lastName: "Musterfrau",
phone: null,
createdAt: new Date("2026-05-01T08:00:00Z"),
emailVerifiedAt: new Date("2026-05-01T08:05:00Z"),
blockedAt: null,
addresses: [{ street: "Musterstraße 1", zip: "80331", city: "München", country: "DE" }],
},
]);
// Act
const result = await listErpCustomers("c1", 1);
// Assert
expect(customerFindMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { role: "KUNDE", id: { gt: "c1" } }, orderBy: { id: "asc" }, take: 1 })
);
expect(result.next).toBe("c2");
expect(result.customers[0]).toEqual({
shopCustomerId: "c2",
email: "[email protected]",
firstName: "Erika",
lastName: "Musterfrau",
phone: null,
createdAt: "2026-05-01T08:00:00.000Z",
emailVerified: true,
blocked: false,
address: { street: "Musterstraße 1", zip: "80331", city: "München", country: "DE" },
});
// Nie Passwort-Hash oder 2FA-Geheimnis abfragen.
const select = customerFindMany.mock.calls[0][0].select;
expect(select.passwordHash).toBeUndefined();
expect(select.twoFactorSecret).toBeUndefined();
});
test("letzte Seite ohne Cursor", async () => {
customerFindMany.mockResolvedValue([]);
expect((await listErpCustomers(null, 200)).next).toBeNull();
});
});
describe("listErpInquiries", () => {
test("liefert Firma, Ansprechpartner und Wunsch", async () => {
inquiryFindMany.mockResolvedValue([
{
id: "i1",
companyName: "Bäckerei Korn GmbH",
contactPerson: "Max Korn",
email: "[email protected]",
phone: "0821 123",
desiredQuantity: 120,
desiredDeliveryDate: new Date("2026-12-01T00:00:00Z"),
package: "GESCHENK",
message: "Für Weihnachten",
status: "NEU",
createdAt: new Date("2026-10-01T10:00:00Z"),
updatedAt: new Date("2026-10-02T10:00:00Z"),
},
]);
const result = await listErpInquiries(null, 200);
expect(result.next).toBeNull();
expect(result.inquiries[0]).toMatchObject({
inquiryId: "i1",
companyName: "Bäckerei Korn GmbH",
contactPerson: "Max Korn",
desiredQuantity: 120,
desiredDeliveryDate: "2026-12-01T00:00:00.000Z",
package: "GESCHENK",
status: "NEU",
});
});
});
+104
View File
@@ -0,0 +1,104 @@
import { prisma } from "@/lib/prisma";
import { MAX_PAGE_SIZE } from "@/lib/erp-export";
/**
* Nur lesender Export fuer das CRM in ERPNext (collab.vycept.com):
*
* - /api/erp/kunden: Kundenkonten des Shops. ERPNext fuehrt sie als
* Privatkunden (Kundengruppe "Privat") mit Kontakt und Adresse.
* - /api/erp/anfragen: Firmenanfragen von der Geschaeftskunden-Seite.
* ERPNext fuehrt die Firma als B2B-Kunden (Kundengruppe "Gewerblich") mit
* Ansprechpartner und je Anfrage eine Chance (Opportunity).
*
* ERPNext holt bei jedem Lauf die VOLLSTAENDIGE Liste (seitenweise ueber den
* Cursor "nach"). Nur so erkennt es auch geloeschte Konten: ein Kunde, der im
* Shop sein Konto geloescht hat und keine Rechnung hat, wird dort ebenfalls
* entfernt (Art. 17 DSGVO); mit Rechnung bleibt er wegen der
* Aufbewahrungspflicht stehen.
*
* Bewusst nicht im Export: Passwort-Hash, 2FA-Daten, Sitzungen, Wunschliste,
* Bewertungen. Admin-Konten sind keine Kundschaft und fehlen ebenfalls.
* Zugang wie beim Rechnungsexport nur mit ERP_EXPORT_TOKEN.
*/
/** `nach` (letzte id der vorigen Seite, leer = von vorn) und `limit` (1 bis MAX_PAGE_SIZE). */
export function parseErpCursor(params: URLSearchParams): { after: string | null; limit: number } | null {
const after = params.get("nach")?.trim() || null;
const limit = Number(params.get("limit") ?? MAX_PAGE_SIZE);
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_PAGE_SIZE) return null;
if (after !== null && !/^[a-z0-9]{1,64}$/i.test(after)) return null;
return { after, limit };
}
function page<T extends { id: string }>(rows: T[], limit: number): string | null {
return rows.length === limit ? rows[rows.length - 1].id : null;
}
export async function listErpCustomers(after: string | null, limit: number) {
const customers = await prisma.customer.findMany({
where: { role: "KUNDE", ...(after ? { id: { gt: after } } : {}) },
orderBy: { id: "asc" },
take: limit,
select: {
id: true,
email: true,
firstName: true,
lastName: true,
phone: true,
createdAt: true,
emailVerifiedAt: true,
blockedAt: true,
// Standardadresse, sonst die zuletzt geaenderte; Packstationen sind
// keine Anschrift fuer das CRM.
addresses: {
where: { packstationNumber: null },
orderBy: [{ isDefault: "desc" }, { updatedAt: "desc" }],
take: 1,
select: { street: true, zip: true, city: true, country: true },
},
},
});
return {
customers: customers.map((customer) => ({
shopCustomerId: customer.id,
email: customer.email,
firstName: customer.firstName,
lastName: customer.lastName,
phone: customer.phone,
createdAt: customer.createdAt.toISOString(),
emailVerified: customer.emailVerifiedAt !== null,
blocked: customer.blockedAt !== null,
address: customer.addresses[0] ?? null,
})),
next: page(customers, limit),
};
}
export type ErpCustomer = Awaited<ReturnType<typeof listErpCustomers>>["customers"][number];
export async function listErpInquiries(after: string | null, limit: number) {
const inquiries = await prisma.businessInquiry.findMany({
where: after ? { id: { gt: after } } : {},
orderBy: { id: "asc" },
take: limit,
});
return {
inquiries: inquiries.map((inquiry) => ({
inquiryId: inquiry.id,
companyName: inquiry.companyName,
contactPerson: inquiry.contactPerson,
email: inquiry.email,
phone: inquiry.phone,
desiredQuantity: inquiry.desiredQuantity,
desiredDeliveryDate: inquiry.desiredDeliveryDate?.toISOString() ?? null,
package: inquiry.package,
message: inquiry.message,
status: inquiry.status,
createdAt: inquiry.createdAt.toISOString(),
updatedAt: inquiry.updatedAt.toISOString(),
})),
next: page(inquiries, limit),
};
}
export type ErpInquiry = Awaited<ReturnType<typeof listErpInquiries>>["inquiries"][number];
+229
View File
@@ -0,0 +1,229 @@
/**
* 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();
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,
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,
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);
});
});
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 })
);
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);
});
});
+192
View File
@@ -0,0 +1,192 @@
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,
}))
);
}
+56
View File
@@ -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 &lt;Musterfrau&gt;");
expect(text).toContain("EN 16931");
});
});
+67
View File
@@ -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" }],
});
}
+10
View File
@@ -22,3 +22,13 @@ const SEQUENCE_DIGITS = 4;
export function formatInvoiceNumber(year: number, sequence: number): string { export function formatInvoiceNumber(year: number, sequence: number): string {
return `RE-${year}-${String(sequence).padStart(SEQUENCE_DIGITS, "0")}`; 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")}`;
}
+56
View File
@@ -1,3 +1,10 @@
// @vitest-environment node
//
// Abweichend von der globalen jsdom-Umgebung (vitest.config.ts): pdfkit
// erkennt die eingebetteten Schriften an "instanceof Uint8Array", und unter
// jsdom stammt der Buffer aus einem anderen JS-Realm — die Pruefung schluege
// fehl ("Not a supported font format"). Im Server laeuft ohnehin Node.
import { inflateSync } from "node:zlib";
import { describe, test, expect, vi } from "vitest"; import { describe, test, expect, vi } from "vitest";
vi.mock("@/lib/company-info", () => ({ vi.mock("@/lib/company-info", () => ({
@@ -78,4 +85,53 @@ describe("generateInvoicePdf", () => {
expect(pages).toBeGreaterThanOrEqual(2); expect(pages).toBeGreaterThanOrEqual(2);
expect(pages).toBeLessThanOrEqual(3); expect(pages).toBeLessThanOrEqual(3);
}); });
test("ist eine E-Rechnung: PDF/A-3b mit eingebettetem factur-x.xml (EN 16931)", async () => {
// Act
const pdf = await generateInvoicePdf(input([item(1, 7), item(2, 19)]));
// Assert
const raw = pdf.toString("latin1");
expect(raw).toContain("<pdfaid:part>3</pdfaid:part>");
expect(raw).toContain("<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>");
expect(raw).toMatch(/\/AFRelationship \/Alternative/);
expect(raw).toContain("/OutputIntents");
const xml = embeddedXml(pdf);
expect(xml).toContain("<ram:ID>RE-TEST-1</ram:ID>");
expect(xml).toContain("<ram:TypeCode>380</ram:TypeCode>");
expect(xml).toContain("<ram:GrandTotalAmount>24.90</ram:GrandTotalAmount>");
});
test("bettet alle Schriften ein (keine Standardschrift ohne Einbettung)", async () => {
// Act
const raw = (await generateInvoicePdf(input([item(1, 7)]))).toString("latin1");
// Assert: eingebettete Teilschriften tragen ein Praefix wie "ABCDEF+".
const baseFonts = [...raw.matchAll(/\/BaseFont \/([^\s/>]+)/g)].map((match) => match[1]);
expect(baseFonts.length).toBeGreaterThan(0);
expect(baseFonts.every((name) => /^[A-Z]{6}\+Liberation/.test(name))).toBe(true);
expect(raw).toContain("/FontFile2");
});
test("Stornorechnung: Gutschrift mit Bezug auf die Rechnung", async () => {
// Act
const pdf = await generateInvoicePdf({
...input([item(1, 7)]),
cancellation: { number: "ST-TEST-1", issuedAt: new Date("2026-10-05T00:00:00Z") },
});
// Assert
const xml = embeddedXml(pdf);
expect(xml).toContain("<ram:ID>ST-TEST-1</ram:ID>");
expect(xml).toContain("<ram:TypeCode>381</ram:TypeCode>");
expect(xml).toContain("<ram:IssuerAssignedID>RE-TEST-1</ram:IssuerAssignedID>");
expect(pdf.toString("latin1")).toContain("Stornorechnung ST-TEST-1");
});
}); });
/** Das eingebettete factur-x.xml (Stream des EmbeddedFile, deflate-komprimiert). */
function embeddedXml(pdf: Buffer): string {
const match = /\/Type \/EmbeddedFile[\s\S]*?stream\r?\n([\s\S]*?)\r?\nendstream/.exec(pdf.toString("latin1"));
if (!match) throw new Error("kein eingebettetes XML");
return inflateSync(Buffer.from(match[1], "latin1")).toString("utf8");
}
+112 -10
View File
@@ -5,6 +5,8 @@ import { getDictionary } from "@/i18n/get-dictionary";
import { invoiceVatBreakdown, type VatBreakdownLine } from "@/lib/vat"; import { invoiceVatBreakdown, type VatBreakdownLine } from "@/lib/vat";
import { groupInvoiceItemsByVatRate, type InvoiceItemGroup } from "@/lib/invoice-groups"; import { groupInvoiceItemsByVatRate, type InvoiceItemGroup } from "@/lib/invoice-groups";
import { INVOICE_COLORS as C, loadInvoiceLogo, drawInvoiceLogo, type InvoiceLogo } from "@/lib/invoice-pdf-brand"; import { INVOICE_COLORS as C, loadInvoiceLogo, drawInvoiceLogo, type InvoiceLogo } from "@/lib/invoice-pdf-brand";
import { buildCiiXml, type EInvoiceResult } from "@/lib/einvoice/cii";
import { embedFacturX, pdfaOptions, registerEmbeddedFonts, xmpSafe } from "@/lib/einvoice/facturx-pdf";
import type { Dictionary } from "@/i18n/dictionary-type"; import type { Dictionary } from "@/i18n/dictionary-type";
import type { Locale } from "@/i18n/config"; import type { Locale } from "@/i18n/config";
@@ -30,6 +32,8 @@ interface InvoicePdfInput {
street: string; street: string;
zip: string; zip: string;
city: string; city: string;
/** ISO-Laendercode der Rechnungsanschrift; fehlt er, gilt Deutschland. */
country?: string;
currency: string; currency: string;
subtotalCents: number; subtotalCents: number;
shippingCents: number; shippingCents: number;
@@ -37,6 +41,11 @@ interface InvoicePdfInput {
discountCode?: { code: string } | null; discountCode?: { code: string } | null;
items: InvoiceItem[]; items: InvoiceItem[];
}; };
/**
* Gesetzt bei der Stornorechnung: sie hebt die Rechnung invoiceNumber vom
* issuedAt auf und traegt alle Betraege mit umgekehrtem Vorzeichen.
*/
cancellation?: { number: string; issuedAt: Date };
} }
type Company = Awaited<ReturnType<typeof getCompanyInfo>>; type Company = Awaited<ReturnType<typeof getCompanyInfo>>;
@@ -44,9 +53,16 @@ type Company = Awaited<ReturnType<typeof getCompanyInfo>>;
interface Ctx { interface Ctx {
doc: PDFKit.PDFDocument; doc: PDFKit.PDFDocument;
t: Dictionary["invoicePage"]; t: Dictionary["invoicePage"];
/** Betrag mit dem Vorzeichen des Belegs (Stornorechnung negativ). */
money: (cents: number) => string; money: (cents: number) => string;
/** Betrag ohne Vorzeichenumkehr. */
plain: (cents: number) => string;
isCancellation: boolean;
} }
/** Sitz des Shops; die Firmenanschrift in den Einstellungen hat kein Land. */
const SELLER_COUNTRY_CODE = "DE";
// A4 in Punkt. Ab FOOTER_TOP beginnt die Fusszeile; was oberhalb von // A4 in Punkt. Ab FOOTER_TOP beginnt die Fusszeile; was oberhalb von
// CONTENT_BOTTOM nicht mehr hinpasst, wandert auf die naechste Seite. // CONTENT_BOTTOM nicht mehr hinpasst, wandert auf die naechste Seite.
const PAGE_WIDTH = 595.28; const PAGE_WIDTH = 595.28;
@@ -84,12 +100,17 @@ const COL = {
export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer> { export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer> {
const t = getDictionary(input.locale).invoicePage; const t = getDictionary(input.locale).invoicePage;
const [company, logo] = await Promise.all([getCompanyInfo(), loadInvoiceLogo()]); const [company, logo] = await Promise.all([getCompanyInfo(), loadInvoiceLogo()]);
const title = input.cancellation
? `${t.cancellationTitle} ${input.cancellation.number}`
: `${t.documentTitle} ${input.invoiceNumber}`;
const doc = new PDFDocument({ const doc = new PDFDocument({
...pdfaOptions(),
margin: MARGIN, margin: MARGIN,
size: "A4", size: "A4",
bufferPages: true, bufferPages: true,
info: { Title: `${t.documentTitle} ${input.invoiceNumber}`, Author: company.name }, info: { Title: xmpSafe(title), Author: xmpSafe(company.name) },
}); });
registerEmbeddedFonts(doc);
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
doc.on("data", (chunk: Buffer) => chunks.push(chunk)); doc.on("data", (chunk: Buffer) => chunks.push(chunk));
const done = new Promise<Buffer>((resolve, reject) => { const done = new Promise<Buffer>((resolve, reject) => {
@@ -97,7 +118,9 @@ export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer
doc.on("error", reject); doc.on("error", reject);
}); });
const ctx: Ctx = { doc, t, money: (cents) => formatPrice(cents, input.order.currency) }; const sign = input.cancellation ? -1 : 1;
const plain = (cents: number) => formatPrice(cents, input.order.currency);
const ctx: Ctx = { doc, t, money: (cents) => plain(sign * cents), plain, isCancellation: Boolean(input.cancellation) };
const breakdown = invoiceVatBreakdown(input.order); const breakdown = invoiceVatBreakdown(input.order);
drawHeader(ctx, company, logo); drawHeader(ctx, company, logo);
@@ -106,13 +129,65 @@ export async function generateInvoicePdf(input: InvoicePdfInput): Promise<Buffer
let y = drawTableHeader(ctx, Math.max(addressBottom, metaBottom) + 30); let y = drawTableHeader(ctx, Math.max(addressBottom, metaBottom) + 30);
y = drawItems(ctx, groupInvoiceItemsByVatRate(input.order.items), y); y = drawItems(ctx, groupInvoiceItemsByVatRate(input.order.items), y);
y = drawSummary(ctx, input, breakdown, y + 22); y = drawSummary(ctx, input, breakdown, y + 22);
drawClosing(ctx, breakdown, y + 26); drawClosing(ctx, input, breakdown, y + 26);
drawFooters(ctx, company); drawFooters(ctx, company);
const einvoice = eInvoiceXml(input, company);
if (einvoice.ok) {
embedFacturX(doc, einvoice.xml, input.cancellation?.issuedAt ?? input.issuedAt);
} else {
console.warn(`[invoice-pdf] ${input.cancellation?.number ?? input.invoiceNumber} ohne E-Rechnung (nur PDF): ${einvoice.reason}`);
}
doc.end(); doc.end();
return done; return done;
} }
/**
* Das XML der E-Rechnung (EN 16931) aus denselben Daten wie das PDF. Die
* Stornorechnung ist eine Gutschrift mit Bezug auf die aufgehobene Rechnung.
*/
function eInvoiceXml(input: InvoicePdfInput, company: Company): EInvoiceResult {
const { order, cancellation } = input;
return buildCiiXml({
kind: cancellation ? "creditNote" : "invoice",
number: cancellation?.number ?? input.invoiceNumber,
issuedAt: cancellation?.issuedAt ?? input.issuedAt,
currency: order.currency,
orderNumber: order.orderNumber,
seller: {
name: company.name,
street: company.address.street,
zip: company.address.zip,
city: company.address.city,
countryCode: SELLER_COUNTRY_CODE,
vatId: company.vatId,
email: company.email,
},
buyer: {
name: `${order.firstName} ${order.lastName}`.trim(),
street: order.street,
zip: order.zip,
city: order.city,
countryCode: (order.country || SELLER_COUNTRY_CODE).toUpperCase(),
},
items: order.items.map((item) => ({
name: item.productName,
quantity: item.quantity,
totalCents: item.totalCents,
vatRatePercent: item.vatRatePercent,
})),
shippingCents: order.shippingCents,
discountCents: order.discountCents,
discountCode: order.discountCode?.code ?? null,
totalCents: input.totalCents,
precedingInvoice: cancellation ? { number: input.invoiceNumber, issuedAt: input.issuedAt } : undefined,
paymentNote: cancellation
? "Der Betrag wird über die ursprüngliche Zahlungsart erstattet."
: "Bereits bezahlt (Online-Zahlung über Stripe).",
});
}
function label(ctx: Ctx, text: string, x: number, y: number): void { function label(ctx: Ctx, text: string, x: number, y: number): void {
ctx.doc ctx.doc
.font("Helvetica-Bold") .font("Helvetica-Bold")
@@ -139,11 +214,19 @@ function drawHeader(ctx: Ctx, company: Company, logo: InvoiceLogo | null): void
doc.font("Helvetica").fontSize(8.5).fillColor(C.honeyDark).text(company.claim, textX, 74, { width: 240, lineBreak: false }); doc.font("Helvetica").fontSize(8.5).fillColor(C.honeyDark).text(company.claim, textX, 74, { width: 240, lineBreak: false });
} }
// "STORNORECHNUNG" ist laenger als "RECHNUNG": die Schrift wird so weit
// verkleinert, dass der Titel in einer Zeile rechts oben bleibt.
const title = (ctx.isCancellation ? t.cancellationTitle : t.documentTitle).toUpperCase();
const titleX = 300;
const titleOptions = { characterSpacing: 2 };
let titleSize = 26;
doc.font("Times-Roman").fontSize(titleSize);
while (titleSize > 14 && doc.widthOfString(title, titleOptions) > RIGHT - titleX) {
doc.fontSize(--titleSize);
}
doc doc
.font("Times-Roman")
.fontSize(26)
.fillColor(C.forest) .fillColor(C.forest)
.text(t.documentTitle.toUpperCase(), 360, 48, { width: RIGHT - 360, align: "right", characterSpacing: 2, lineBreak: false }); .text(title, titleX, 48 + (26 - titleSize) / 2, { ...titleOptions, width: RIGHT - titleX, align: "right", lineBreak: false });
hairline(ctx, MARGIN, RIGHT, 112, C.honey); hairline(ctx, MARGIN, RIGHT, 112, C.honey);
} }
@@ -165,9 +248,18 @@ function drawRecipient(ctx: Ctx, input: InvoicePdfInput, company: Company): numb
/** Rechnungsdaten rechts oben, inklusive USt-IdNr. (§ 14 Abs. 4 Nr. 2 UStG). */ /** Rechnungsdaten rechts oben, inklusive USt-IdNr. (§ 14 Abs. 4 Nr. 2 UStG). */
function drawMetaBox(ctx: Ctx, input: InvoicePdfInput, company: Company): number { function drawMetaBox(ctx: Ctx, input: InvoicePdfInput, company: Company): number {
const { doc, t } = ctx; const { doc, t } = ctx;
const rows: [string, string][] = [ const documentRows: [string, string][] = input.cancellation
? [
[t.cancellationNumberLabel, input.cancellation.number],
[t.cancellationDateLabel, formatDate(input.cancellation.issuedAt)],
[t.cancelsInvoiceLabel, input.invoiceNumber],
]
: [
[t.invoiceNumberLabel, input.invoiceNumber], [t.invoiceNumberLabel, input.invoiceNumber],
[t.invoiceDateLabel, formatDate(input.issuedAt)], [t.invoiceDateLabel, formatDate(input.issuedAt)],
];
const rows: [string, string][] = [
...documentRows,
[t.orderNumberLabel, input.order.orderNumber], [t.orderNumberLabel, input.order.orderNumber],
...(company.vatId ? ([[t.vatIdLabel, company.vatId]] as [string, string][]) : []), ...(company.vatId ? ([[t.vatIdLabel, company.vatId]] as [string, string][]) : []),
]; ];
@@ -287,14 +379,16 @@ function drawSummary(ctx: Ctx, input: InvoicePdfInput, breakdown: VatBreakdownLi
} }
function drawTotals(ctx: Ctx, input: InvoicePdfInput, top: number): number { function drawTotals(ctx: Ctx, input: InvoicePdfInput, top: number): number {
const { doc, t, money } = ctx; const { doc, t, money, plain } = ctx;
const { order } = input; const { order } = input;
const x = 330; const x = 330;
const discountName = order.discountCode ? `${t.discountLabel} (${order.discountCode.code})` : t.discountLabel; const discountName = order.discountCode ? `${t.discountLabel} (${order.discountCode.code})` : t.discountLabel;
const rows: [string, string][] = [ const rows: [string, string][] = [
[t.subtotalLabel, money(order.subtotalCents)], [t.subtotalLabel, money(order.subtotalCents)],
[t.shippingLabel, order.shippingCents === 0 ? t.freeShipping : money(order.shippingCents)], [t.shippingLabel, order.shippingCents === 0 ? t.freeShipping : money(order.shippingCents)],
...(order.discountCents > 0 ? ([[discountName, `– ${money(order.discountCents)}`]] as [string, string][]) : []), ...(order.discountCents > 0
? ([[discountName, `${ctx.isCancellation ? "+" : "–"} ${plain(order.discountCents)}`]] as [string, string][])
: []),
]; ];
let y = top; let y = top;
@@ -371,12 +465,20 @@ function drawTaxSummary(ctx: Ctx, breakdown: VatBreakdownLine[], top: number): n
return y; return y;
} }
function drawClosing(ctx: Ctx, breakdown: VatBreakdownLine[], startY: number): void { function drawClosing(ctx: Ctx, input: InvoicePdfInput, breakdown: VatBreakdownLine[], startY: number): void {
const { doc, t } = ctx; const { doc, t } = ctx;
const y = ensureSpace(ctx, startY, 40, false); const y = ensureSpace(ctx, startY, 40, false);
if (breakdown.some((line) => line.ratePercent > 0)) { if (breakdown.some((line) => line.ratePercent > 0)) {
doc.font("Helvetica").fontSize(8).fillColor(C.sage).text(t.pricesIncludeVat, MARGIN, y, { width: CONTENT_WIDTH }); doc.font("Helvetica").fontSize(8).fillColor(C.sage).text(t.pricesIncludeVat, MARGIN, y, { width: CONTENT_WIDTH });
} }
if (input.cancellation) {
doc
.font("Helvetica")
.fontSize(9.5)
.fillColor(C.ink)
.text(t.cancellationNote(input.invoiceNumber, formatDate(input.issuedAt)), MARGIN, y + 16, { width: CONTENT_WIDTH });
return;
}
doc.font("Times-Italic").fontSize(13).fillColor(C.honeyDark).text(t.thankYou, MARGIN, y + 16, { width: CONTENT_WIDTH }); doc.font("Times-Italic").fontSize(13).fillColor(C.honeyDark).text(t.thankYou, MARGIN, y + 16, { width: CONTENT_WIDTH });
} }
+43
View File
@@ -252,3 +252,46 @@ export function invoiceVatBreakdown(order: {
discountCents: order.discountCents, discountCents: order.discountCents,
}); });
} }
export interface VatLineWithShares extends VatBreakdownLine {
/** Darin enthaltener Versandanteil (brutto). */
shippingCents: number;
/** Davon abgezogener Rabattanteil (brutto). */
discountCents: number;
}
/**
* Umsatzsteuer je Satz wie auf der Rechnung, dazu der Versand- und der
* Rabattanteil je Satz. Beide Anteile ergeben sich aus derselben Rechnung
* ohne den jeweils anderen Betrag, also ohne eigene Verteilungslogik — so
* stimmen sie mit der Aufstellung auf dem PDF auf den Cent ueberein.
* Gebraucht vom ERP-Export und von der E-Rechnung (lib/einvoice).
*
* null, wenn eine Position keinen Steuersatz hat (Bestellungen aus der Zeit
* vor der Steuerausweisung).
*/
export function vatLinesWithShares(order: {
items: ReadonlyArray<{ totalCents: number; vatRatePercent: number | null }>;
shippingCents: number;
discountCents: number;
}): VatLineWithShares[] | 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,
}));
}
/** Verteilt ganze Cents im Verhaeltnis der Gewichte (Hare-Niemeyer, siehe distributeProportionally). */
export function distributeCents(amountCents: number, weights: readonly number[]): number[] {
return distributeProportionally(amountCents, weights);
}