Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b15ca0b8d | ||
|
|
051eedaf7f | ||
|
|
4d1bcbbdb7 | ||
|
|
508ff24aa7 | ||
|
|
f28e5e9d84 | ||
|
|
b246c9ff4e | ||
|
|
7698d4cef3 |
No files matched your search
@@ -107,6 +107,13 @@ APICORE_API_KEY=
|
|||||||
# abgelehnt; der CronJob gleicht dann alle 10 Minuten ab.
|
# abgelehnt; der CronJob gleicht dann alle 10 Minuten ab.
|
||||||
APICORE_WEBHOOK_SECRET=
|
APICORE_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# Rechnungsexport fuer die Buchhaltung (ERPNext, collab.vycept.com): ERPNext
|
||||||
|
# holt damit stuendlich alle Rechnungen samt PDF ab und ordnet sie der
|
||||||
|
# Stripe-Zahlung zu (GET /api/erp/rechnungen, nur lesend, siehe
|
||||||
|
# src/lib/erp-export.ts). Mindestens 32 Zeichen, z. B. `openssl rand -hex 32`;
|
||||||
|
# derselbe Wert steht in ERPNext als WABENHAIN_ERP_TOKEN. Leer = Export aus.
|
||||||
|
ERP_EXPORT_TOKEN=
|
||||||
|
|
||||||
# DHL Parcel DE Shipping API ("Post & Parcel Germany", developer.dhl.com) —
|
# DHL Parcel DE Shipping API ("Post & Parcel Germany", developer.dhl.com) —
|
||||||
# ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im
|
# ermoeglicht das Erstellen von DHL-Versandmarken per Knopfdruck im
|
||||||
# Admin-Bestelldetail (siehe src/lib/dhl.ts). Ohne diese fuenf Werte zeigt
|
# Admin-Bestelldetail (siehe src/lib/dhl.ts). Ohne diese fuenf Werte zeigt
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|
||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" } });
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Tests fuer die Stornorechnung: hoechstens eine je Rechnung, auch wenn
|
||||||
|
* Admin-Stornierung und Stripe-Webhook derselben Erstattung gleichzeitig
|
||||||
|
* ankommen, und sie geht an die Kundschaft und als Kopie hinaus.
|
||||||
|
*/
|
||||||
|
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const tx = {
|
||||||
|
cancellationCounter: { upsert: vi.fn() },
|
||||||
|
cancellationInvoice: { create: vi.fn() },
|
||||||
|
};
|
||||||
|
const db = {
|
||||||
|
invoice: { findUnique: vi.fn() },
|
||||||
|
cancellationInvoice: { findUnique: vi.fn(), update: vi.fn() },
|
||||||
|
$transaction: vi.fn(async (run: (client: typeof tx) => Promise<unknown>) => run(tx)),
|
||||||
|
};
|
||||||
|
vi.mock("@/lib/prisma", () => ({ prisma: db }));
|
||||||
|
|
||||||
|
const generateInvoicePdf = vi.fn(async () => Buffer.from("storno-pdf"));
|
||||||
|
vi.mock("@/lib/invoice-pdf", () => ({ generateInvoicePdf: (...args: unknown[]) => generateInvoicePdf(...(args as [])) }));
|
||||||
|
const storeInvoicePdf = vi.fn(async () => undefined);
|
||||||
|
vi.mock("@/lib/invoice-storage", () => ({
|
||||||
|
invoiceRelativePath: vi.fn(() => "2026/musterfrau-erika/ST-2026-0003.pdf"),
|
||||||
|
storeInvoicePdf: (...args: unknown[]) => storeInvoicePdf(...(args as [])),
|
||||||
|
readStoredInvoicePdf: vi.fn(async () => null),
|
||||||
|
}));
|
||||||
|
const sendMail = vi.fn(async () => true);
|
||||||
|
vi.mock("@/lib/mail", () => ({ sendMail: (...args: unknown[]) => sendMail(...(args as [])) }));
|
||||||
|
vi.mock("@/lib/email-templates", () => ({
|
||||||
|
cancellationInvoiceEmail: vi.fn(async () => ({ subject: "Stornorechnung", html: "<p>x</p>", text: "x" })),
|
||||||
|
}));
|
||||||
|
const sendInvoiceCopy = vi.fn(async () => true);
|
||||||
|
vi.mock("@/lib/invoice-mail", () => ({ sendInvoiceCopy: (...args: unknown[]) => sendInvoiceCopy(...(args as [])) }));
|
||||||
|
|
||||||
|
const { issueCancellationInvoice, isCancellationNumber } = await import("./cancellation-invoice");
|
||||||
|
|
||||||
|
const order = {
|
||||||
|
orderNumber: "WH-2026-0042",
|
||||||
|
email: "[email protected]",
|
||||||
|
firstName: "Erika",
|
||||||
|
lastName: "Musterfrau",
|
||||||
|
locale: "de",
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
const loaded = {
|
||||||
|
number: "ST-2026-0003",
|
||||||
|
issuedAt: new Date("2026-10-14T09:00:00Z"),
|
||||||
|
pdfPath: null,
|
||||||
|
invoice: { invoiceNumber: "RE-2026-0042", issuedAt: new Date("2026-10-12T10:00:00Z"), totalCents: 5375, order },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
db.invoice.findUnique.mockResolvedValue({ id: "inv1", totalCents: 5375, cancellation: null });
|
||||||
|
tx.cancellationCounter.upsert.mockResolvedValue({ lastNumber: 3 });
|
||||||
|
tx.cancellationInvoice.create.mockImplementation(async ({ data }: { data: { number: string } }) => ({ number: data.number }));
|
||||||
|
db.cancellationInvoice.findUnique.mockResolvedValue(loaded);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("issueCancellationInvoice", () => {
|
||||||
|
test("ohne Rechnung gibt es nichts aufzuheben", async () => {
|
||||||
|
db.invoice.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
expect(await issueCancellationInvoice("order1", "STORNIERUNG")).toBeNull();
|
||||||
|
expect(db.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legt die Stornorechnung mit eigener Nummer an und verschickt sie mit Kopie", async () => {
|
||||||
|
// Act
|
||||||
|
const result = await issueCancellationInvoice("order1", "STORNIERUNG");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
expect(result).toEqual({ number: `ST-${year}-0003`, created: true });
|
||||||
|
expect(tx.cancellationInvoice.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ data: { invoiceId: "inv1", number: `ST-${year}-0003`, totalCents: 5375, reason: "STORNIERUNG" } })
|
||||||
|
);
|
||||||
|
expect(generateInvoicePdf).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ invoiceNumber: "RE-2026-0042", cancellation: { number: "ST-2026-0003", issuedAt: loaded.issuedAt } })
|
||||||
|
);
|
||||||
|
expect(storeInvoicePdf).toHaveBeenCalledOnce();
|
||||||
|
expect(sendMail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ to: "[email protected]", attachments: [expect.objectContaining({ filename: `ST-${year}-0003.pdf` })] })
|
||||||
|
);
|
||||||
|
expect(sendInvoiceCopy).toHaveBeenCalledWith(expect.objectContaining({ title: "Stornorechnung", orderNumber: "WH-2026-0042" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zweiter Ausloeser findet die vorhandene Stornorechnung, ohne erneut zu verschicken", async () => {
|
||||||
|
db.invoice.findUnique.mockResolvedValue({ id: "inv1", totalCents: 5375, cancellation: { number: "ST-2026-0001" } });
|
||||||
|
|
||||||
|
expect(await issueCancellationInvoice("order1", "ERSTATTUNG")).toEqual({ number: "ST-2026-0001", created: false });
|
||||||
|
expect(db.$transaction).not.toHaveBeenCalled();
|
||||||
|
expect(sendMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gleichzeitig angelegt: die zweite Transaktion scheitert eindeutig und liefert die erste", async () => {
|
||||||
|
// Arrange: die Datenbank lehnt die zweite Stornorechnung zur selben Rechnung ab.
|
||||||
|
tx.cancellationInvoice.create.mockRejectedValue(Object.assign(new Error("Unique constraint failed"), { code: "P2002" }));
|
||||||
|
db.cancellationInvoice.findUnique.mockResolvedValue({ number: "ST-2026-0001" });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await issueCancellationInvoice("order1", "ERSTATTUNG");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result).toEqual({ number: "ST-2026-0001", created: false });
|
||||||
|
expect(sendMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ein Fehler beim Verschicken laesst die Stornorechnung bestehen", async () => {
|
||||||
|
generateInvoicePdf.mockRejectedValueOnce(new Error("Schrift fehlt"));
|
||||||
|
const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const result = await issueCancellationInvoice("order1", "RETOURE");
|
||||||
|
|
||||||
|
expect(result?.created).toBe(true);
|
||||||
|
expect(errors).toHaveBeenCalled();
|
||||||
|
errors.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isCancellationNumber erkennt nur ST-Nummern", () => {
|
||||||
|
expect(isCancellationNumber("ST-2026-0001")).toBe(true);
|
||||||
|
expect(isCancellationNumber("RE-2026-0001")).toBe(false);
|
||||||
|
expect(isCancellationNumber("ST-2026-0001/../x")).toBe(false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { formatCancellationNumber } from "@/lib/invoice-number";
|
||||||
|
import { generateInvoicePdf } from "@/lib/invoice-pdf";
|
||||||
|
import { invoiceRelativePath, readStoredInvoicePdf, storeInvoicePdf } from "@/lib/invoice-storage";
|
||||||
|
import { isUniqueConstraintError } from "@/lib/harvest-slug";
|
||||||
|
import { sendMail } from "@/lib/mail";
|
||||||
|
import { cancellationInvoiceEmail } from "@/lib/email-templates";
|
||||||
|
import { sendInvoiceCopy } from "@/lib/invoice-mail";
|
||||||
|
import { isLocale, defaultLocale } from "@/i18n/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stornorechnung: der Gegenbeleg, wenn eine Bestellung mit Rechnung
|
||||||
|
* storniert oder voll erstattet wird.
|
||||||
|
*
|
||||||
|
* Eine ausgestellte Rechnung wird nie geloescht oder geaendert (§ 14 UStG,
|
||||||
|
* GoBD). Stattdessen entsteht EINE Stornorechnung je Rechnung, mit eigener
|
||||||
|
* fortlaufender Nummer (ST-JJJJ-NNNN), allen Positionen der Rechnung und
|
||||||
|
* Bezug auf sie. Sie geht als E-Rechnung an die Kundschaft und als Kopie an
|
||||||
|
* die Admin-Adresse; ERPNext holt sie ueber den ERP-Export ab und bucht sie
|
||||||
|
* als Rechnungskorrektur gegen die Ausgangsrechnung.
|
||||||
|
*
|
||||||
|
* Ausloeser (alle idempotent ueber denselben Weg):
|
||||||
|
* - Stornierung im Admin (STORNIERUNG)
|
||||||
|
* - Erstattung einer Retoure (RETOURE)
|
||||||
|
* - volle Erstattung direkt bei Stripe (ERSTATTUNG, Webhook charge.refunded)
|
||||||
|
* Eine Stornierung im Admin loest selbst eine Stripe-Erstattung aus, deren
|
||||||
|
* Webhook gleichzeitig hier ankommen kann. Beide Wege fuehren zu derselben
|
||||||
|
* einen Stornorechnung: die zweite scheitert am eindeutigen invoiceId und
|
||||||
|
* rollt samt Zaehler zurueck, es entsteht also auch keine Nummernluecke.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CancellationReason = "STORNIERUNG" | "RETOURE" | "ERSTATTUNG";
|
||||||
|
|
||||||
|
export interface CancellationResult {
|
||||||
|
number: string;
|
||||||
|
/** false: es gab sie schon (zweiter Ausloeser derselben Erstattung). */
|
||||||
|
created: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CANCELLATION_NUMBER_PATTERN = /^ST-\d{4}-\d{4,}$/;
|
||||||
|
|
||||||
|
export function isCancellationNumber(value: string): boolean {
|
||||||
|
return CANCELLATION_NUMBER_PATTERN.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt die Stornorechnung zur Rechnung der Bestellung an und verschickt sie.
|
||||||
|
* null, wenn die Bestellung keine Rechnung hat (nie bezahlt) — dann gibt es
|
||||||
|
* nichts aufzuheben.
|
||||||
|
*/
|
||||||
|
export async function issueCancellationInvoice(orderId: string, reason: CancellationReason): Promise<CancellationResult | null> {
|
||||||
|
const invoice = await prisma.invoice.findUnique({
|
||||||
|
where: { orderId },
|
||||||
|
select: { id: true, totalCents: true, cancellation: { select: { number: true } } },
|
||||||
|
});
|
||||||
|
if (!invoice) return null;
|
||||||
|
if (invoice.cancellation) return { number: invoice.cancellation.number, created: false };
|
||||||
|
|
||||||
|
let number: string;
|
||||||
|
try {
|
||||||
|
number = await prisma.$transaction(async (tx) => {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const counter = await tx.cancellationCounter.upsert({
|
||||||
|
where: { year },
|
||||||
|
create: { year, lastNumber: 1 },
|
||||||
|
update: { lastNumber: { increment: 1 } },
|
||||||
|
select: { lastNumber: true },
|
||||||
|
});
|
||||||
|
const created = await tx.cancellationInvoice.create({
|
||||||
|
data: {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
number: formatCancellationNumber(year, counter.lastNumber),
|
||||||
|
totalCents: invoice.totalCents,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
select: { number: true },
|
||||||
|
});
|
||||||
|
return created.number;
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!isUniqueConstraintError(error)) throw error;
|
||||||
|
const existing = await prisma.cancellationInvoice.findUnique({ where: { invoiceId: invoice.id }, select: { number: true } });
|
||||||
|
if (!existing) throw error;
|
||||||
|
return { number: existing.number, created: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await deliverCancellationInvoice(number);
|
||||||
|
return { number, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCancellation(number: string) {
|
||||||
|
return prisma.cancellationInvoice.findUnique({
|
||||||
|
where: { number },
|
||||||
|
include: { invoice: { include: { order: { include: { items: true, discountCode: { select: { code: true } } } } } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadedCancellation = NonNullable<Awaited<ReturnType<typeof loadCancellation>>>;
|
||||||
|
|
||||||
|
function renderCancellationPdf(cancellation: LoadedCancellation): Promise<Buffer> {
|
||||||
|
const { invoice } = cancellation;
|
||||||
|
return generateInvoicePdf({
|
||||||
|
locale: isLocale(invoice.order.locale) ? invoice.order.locale : defaultLocale,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
issuedAt: invoice.issuedAt,
|
||||||
|
totalCents: invoice.totalCents,
|
||||||
|
order: invoice.order,
|
||||||
|
cancellation: { number: cancellation.number, issuedAt: cancellation.issuedAt },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PDF erzeugen und ablegen, an die Kundschaft und als Kopie verschicken.
|
||||||
|
* Wirft nicht: die Stornorechnung steht bereits fest, und ihr PDF laesst sich
|
||||||
|
* jederzeit ueber cancellationPdf() neu erzeugen. Ein Fehler hier darf die
|
||||||
|
* Stornierung nicht als gescheitert erscheinen lassen.
|
||||||
|
*/
|
||||||
|
async function deliverCancellationInvoice(number: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const cancellation = await loadCancellation(number);
|
||||||
|
if (!cancellation) return;
|
||||||
|
const { order } = cancellation.invoice;
|
||||||
|
const pdf = await renderCancellationPdf(cancellation);
|
||||||
|
try {
|
||||||
|
const relativePath = invoiceRelativePath({
|
||||||
|
year: cancellation.issuedAt.getFullYear(),
|
||||||
|
lastName: order.lastName,
|
||||||
|
firstName: order.firstName,
|
||||||
|
invoiceNumber: number,
|
||||||
|
});
|
||||||
|
await storeInvoicePdf(relativePath, pdf);
|
||||||
|
await prisma.cancellationInvoice.update({ where: { number }, data: { pdfPath: relativePath } });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error(`[cancellation] ${number} konnte nicht abgelegt werden:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachment = { filename: `${number}.pdf`, content: pdf, contentType: "application/pdf" };
|
||||||
|
const { subject, html, text } = await cancellationInvoiceEmail({
|
||||||
|
locale: isLocale(order.locale) ? order.locale : defaultLocale,
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
firstName: order.firstName,
|
||||||
|
cancellationNumber: number,
|
||||||
|
invoiceNumber: cancellation.invoice.invoiceNumber,
|
||||||
|
});
|
||||||
|
await sendMail({ to: order.email, subject, html, text, attachments: [attachment] });
|
||||||
|
await sendInvoiceCopy({
|
||||||
|
title: "Stornorechnung",
|
||||||
|
number,
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
customerName: `${order.firstName} ${order.lastName}`.trim(),
|
||||||
|
customerEmail: order.email,
|
||||||
|
pdf,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error(`[cancellation] ${number} konnte nicht erzeugt oder verschickt werden:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Das PDF der Stornorechnung: abgelegt, sonst neu erzeugt. null, wenn es die Nummer nicht gibt. */
|
||||||
|
export async function cancellationPdf(number: string): Promise<Buffer | null> {
|
||||||
|
const cancellation = await loadCancellation(number);
|
||||||
|
if (!cancellation) return null;
|
||||||
|
const stored = cancellation.pdfPath ? await readStoredInvoicePdf(cancellation.pdfPath) : null;
|
||||||
|
return stored ?? (await renderCancellationPdf(cancellation));
|
||||||
|
}
|
||||||
@@ -74,6 +74,8 @@ export const CONTENT_REGISTRY: ContentFieldDef[] = [
|
|||||||
{ key: "company.street", group: "company", labelDe: "Straße", labelEn: "Street", type: "text", localized: false, getDefault: () => siteConfig.address.street },
|
{ key: "company.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
|
||||||
|
|||||||
@@ -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 & Söhne <GmbH> "Bio"</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("<a href="x">'&'</a>");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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)}` };
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const values: Record<string, string> = {};
|
||||||
|
vi.mock("@/lib/content", () => ({
|
||||||
|
getContentValues: vi.fn(async (keys: string[]) => Object.fromEntries(keys.map((key) => [key, values[key] ?? ""]))),
|
||||||
|
}));
|
||||||
|
const sendMail = vi.fn(async () => true);
|
||||||
|
vi.mock("@/lib/mail", () => ({ sendMail: (...args: unknown[]) => sendMail(...(args as [])) }));
|
||||||
|
|
||||||
|
const { INVOICE_COPY_KEY, getInvoiceCopyAddress, invoiceCopyContent, sendInvoiceCopy } = await import("./invoice-mail");
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
title: "Rechnung",
|
||||||
|
number: "RE-2026-0042",
|
||||||
|
orderNumber: "WH-2026-0042",
|
||||||
|
customerName: "Erika <Musterfrau>",
|
||||||
|
customerEmail: "[email protected]",
|
||||||
|
pdf: Buffer.from("pdf"),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
delete values[INVOICE_COPY_KEY];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Rechnungskopie", () => {
|
||||||
|
test("ohne hinterlegte Adresse keine Kopie", async () => {
|
||||||
|
expect(await getInvoiceCopyAddress()).toBeNull();
|
||||||
|
expect(await sendInvoiceCopy(copy)).toBeNull();
|
||||||
|
expect(sendMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("schickt die E-Rechnung als Anhang an die hinterlegte Adresse", async () => {
|
||||||
|
// Arrange: verschleiert eingetragen wie die Firmen-E-Mail.
|
||||||
|
values[INVOICE_COPY_KEY] = " buchhaltung [at] wabenhain.com ";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const sent = await sendInvoiceCopy(copy);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(sent).toBe(true);
|
||||||
|
expect(sendMail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "[email protected]",
|
||||||
|
subject: "Kopie: Rechnung RE-2026-0042 (Bestellung WH-2026-0042)",
|
||||||
|
attachments: [expect.objectContaining({ filename: "RE-2026-0042.pdf", contentType: "application/pdf" })],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maskiert Kundendaten im HTML-Teil", () => {
|
||||||
|
const { html, text } = invoiceCopyContent(copy);
|
||||||
|
expect(html).toContain("Erika <Musterfrau>");
|
||||||
|
expect(text).toContain("EN 16931");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { getContentValues } from "@/lib/content";
|
||||||
|
import { normalizeContactEmail } from "@/lib/contact-email";
|
||||||
|
import { escapeHtml } from "@/lib/escape-html";
|
||||||
|
import { sendMail } from "@/lib/mail";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kopie jeder Rechnung und Stornorechnung an eine im Admin hinterlegte
|
||||||
|
* Adresse (Einstellungen > Firma > "Rechnungskopie an"), etwa fuer die
|
||||||
|
* Buchhaltung. Die Kopie ist eine eigene Mail mit demselben PDF (E-Rechnung
|
||||||
|
* mit eingebettetem XML), nicht ein BCC der Kundenmail: so steht im Betreff,
|
||||||
|
* welcher Beleg es ist, und die Kundenmail bleibt unveraendert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const INVOICE_COPY_KEY = "invoice.copyEmail";
|
||||||
|
|
||||||
|
/** Die hinterlegte Adresse, oder null, wenn keine (gueltige) eingetragen ist. */
|
||||||
|
export async function getInvoiceCopyAddress(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const values = await getContentValues([INVOICE_COPY_KEY], "de");
|
||||||
|
const raw = values[INVOICE_COPY_KEY]?.trim() ?? "";
|
||||||
|
return raw ? normalizeContactEmail(raw) : null;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error("[invoice-mail] Adresse fuer die Rechnungskopie nicht lesbar:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceCopy {
|
||||||
|
/** "Rechnung" oder "Stornorechnung". */
|
||||||
|
title: string;
|
||||||
|
number: string;
|
||||||
|
orderNumber: string;
|
||||||
|
customerName: string;
|
||||||
|
customerEmail: string;
|
||||||
|
pdf: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Betreff und Text der Kopie (ohne Versand, testbar). */
|
||||||
|
export function invoiceCopyContent(copy: Omit<InvoiceCopy, "pdf">): { subject: string; text: string; html: string } {
|
||||||
|
const subject = `Kopie: ${copy.title} ${copy.number} (Bestellung ${copy.orderNumber})`;
|
||||||
|
const text =
|
||||||
|
`${copy.title} ${copy.number} zur Bestellung ${copy.orderNumber} an ${copy.customerName} <${copy.customerEmail}>.\n` +
|
||||||
|
"Anbei die E-Rechnung (PDF/A-3 mit eingebettetem XML nach EN 16931, ZUGFeRD/Factur-X).";
|
||||||
|
const html = text
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => `<p style="margin:0 0 8px;line-height:1.5;">${escapeHtml(line)}</p>`)
|
||||||
|
.join("");
|
||||||
|
return { subject, text, html };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schickt die Kopie. null: keine Adresse hinterlegt; sonst, ob der
|
||||||
|
* Mailserver die Mail angenommen hat. Wirft nie (wie sendMail): eine
|
||||||
|
* fehlende Kopie darf keine Bestellung oder Stornierung aufhalten.
|
||||||
|
*/
|
||||||
|
export async function sendInvoiceCopy(copy: InvoiceCopy): Promise<boolean | null> {
|
||||||
|
const to = await getInvoiceCopyAddress();
|
||||||
|
if (!to) return null;
|
||||||
|
const { subject, text, html } = invoiceCopyContent(copy);
|
||||||
|
return sendMail({
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
attachments: [{ filename: `${copy.number}.pdf`, content: copy.pdf, contentType: "application/pdf" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -22,3 +22,13 @@ const SEQUENCE_DIGITS = 4;
|
|||||||
export function formatInvoiceNumber(year: number, sequence: number): string {
|
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")}`;
|
||||||
|
}
|
||||||
@@ -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
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user