decrypt(UserSigningCredentials credentials) {
+ if (!properties.getSigning().hasMasterKey()) {
+ log.warn("Master-Key fehlt – nutzerseitige Credentials für {} können nicht entschlüsselt werden.",
+ credentials.getUserId());
+ return Optional.empty();
+ }
+ try {
+ AesGcmCipher cipher = newCipher();
+ byte[] keystoreBytes = cipher.decrypt(credentials.getEncryptedKeystore());
+ byte[] passwordBytes = cipher
+ .decrypt(java.util.Base64.getDecoder().decode(credentials.getEncryptedPassword()));
+ char[] password = new String(passwordBytes, StandardCharsets.UTF_8).toCharArray();
+
+ KeyStore keystore = KeyStore.getInstance("PKCS12");
+ try (ByteArrayInputStream in = new ByteArrayInputStream(keystoreBytes)) {
+ keystore.load(in, password);
+ }
+ return Optional.of(new LoadedCredentials(keystore, password, credentials.getKeyAlias(), credentials));
+ } catch (Exception ex) {
+ log.warn("Nutzer-Keystore für {} konnte nicht entschlüsselt werden: {}", credentials.getUserId(),
+ ex.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ private ValidationResult validate(byte[] p12Bytes, String password, String alias) {
+ try {
+ KeyStore keystore = KeyStore.getInstance("PKCS12");
+ try (ByteArrayInputStream in = new ByteArrayInputStream(p12Bytes)) {
+ keystore.load(in, password.toCharArray());
+ }
+ if (!keystore.containsAlias(alias)) {
+ throw new IllegalArgumentException("Alias '" + alias + "' nicht im Keystore enthalten.");
+ }
+ PrivateKey privateKey = (PrivateKey) keystore.getKey(alias, password.toCharArray());
+ if (privateKey == null) {
+ throw new IllegalArgumentException(
+ "Im Alias '" + alias + "' wurde kein privater Schlüssel gefunden.");
+ }
+ Certificate[] chain = keystore.getCertificateChain(alias);
+ if (chain == null || chain.length == 0 || !(chain[0] instanceof X509Certificate)) {
+ throw new IllegalArgumentException("Kein verwendbares X.509-Zertifikat im Alias '" + alias + "'.");
+ }
+ X509Certificate cert = (X509Certificate) chain[0];
+ ValidationResult result = new ValidationResult();
+ result.subjectDn = cert.getSubjectX500Principal().getName();
+ result.issuerDn = cert.getIssuerX500Principal().getName();
+ result.serialNumber = cert.getSerialNumber().toString(16);
+ result.validFrom = toLocalDateTime(cert.getNotBefore());
+ result.validUntil = toLocalDateTime(cert.getNotAfter());
+ return result;
+ } catch (IllegalArgumentException ex) {
+ throw ex;
+ } catch (java.io.IOException ex) {
+ throw new IllegalArgumentException("Keystore konnte nicht gelesen werden – falsches Passwort?", ex);
+ } catch (Exception ex) {
+ throw new IllegalArgumentException("Keystore-Validierung fehlgeschlagen: " + ex.getMessage(), ex);
+ }
+ }
+
+ private AesGcmCipher newCipher() {
+ return new AesGcmCipher(properties.getSigning().getMasterKey());
+ }
+
+ private void ensureMasterKey() {
+ if (!properties.getSigning().hasMasterKey()) {
+ throw new IllegalStateException(
+ "Master-Key (votianlt.einvoice.signing.master-key) ist nicht konfiguriert. "
+ + "Setzen Sie einen mindestens 16 Zeichen langen Master-Key, bevor Sie Nutzer-Keystores hinterlegen.");
+ }
+ }
+
+ private LocalDateTime toLocalDateTime(Date date) {
+ return date == null ? null : date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
+ }
+
+ private static class ValidationResult {
+ String subjectDn;
+ String issuerDn;
+ String serialNumber;
+ LocalDateTime validFrom;
+ LocalDateTime validUntil;
+ }
+
+ /**
+ * Bündel aus einem im Speicher entschlüsselten Keystore plus zugehörigem Klartext-Passwort.
+ */
+ public static final class LoadedCredentials {
+
+ private final KeyStore keystore;
+ private final char[] password;
+ private final String alias;
+ private final UserSigningCredentials metadata;
+
+ LoadedCredentials(KeyStore keystore, char[] password, String alias, UserSigningCredentials metadata) {
+ this.keystore = keystore;
+ this.password = password;
+ this.alias = alias;
+ this.metadata = metadata;
+ }
+
+ public KeyStore getKeystore() {
+ return keystore;
+ }
+
+ public char[] getPassword() {
+ return password;
+ }
+
+ public String getAlias() {
+ return alias;
+ }
+
+ public UserSigningCredentials getMetadata() {
+ return metadata;
+ }
+ }
+}
diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties
index ad53bf9..8f1a33c 100644
--- a/backend/src/main/resources/application.properties
+++ b/backend/src/main/resources/application.properties
@@ -95,4 +95,34 @@ spring.ai.openai.read-timeout=120s
spring.ai.mcp.server.enabled=true
spring.ai.mcp.server.name=votianlt-mcp-server
spring.ai.mcp.server.version=1.0.0
-spring.ai.mcp.server.sse-message-endpoint=/mcp/message
\ No newline at end of file
+spring.ai.mcp.server.sse-message-endpoint=/mcp/message
+
+# ===========================================
+# E-Rechnung (ZUGFeRD/Factur-X) und PAdES-Signatur
+# ===========================================
+# Aktivieren Sie die ZUGFeRD-Anreicherung systemweit. Pro Nutzer entscheidet
+# zusätzlich das Profilfeld eInvoiceEnabled, ob es tatsächlich angewendet wird.
+votianlt.einvoice.enabled=false
+votianlt.einvoice.profile=EN16931
+
+# PAdES-Signatur: nur aktiv, wenn unten ein gültiger Keystore konfiguriert ist.
+votianlt.einvoice.signing.enabled=false
+votianlt.einvoice.signing.keystore-path=
+votianlt.einvoice.signing.keystore-password=
+votianlt.einvoice.signing.key-alias=
+votianlt.einvoice.signing.reason=Rechnung
+votianlt.einvoice.signing.location=
+votianlt.einvoice.signing.contact=
+# Master-Key (>= 16 Zeichen) zum Verschlüsseln nutzerseitig hinterlegter PKCS#12-Keystores.
+# Verlust dieses Keys macht alle gespeicherten Nutzer-Keystores unbrauchbar.
+#
+# SICHERHEITSEMPFEHLUNG (Stufe 2/3):
+# - Den Key NIEMALS inline hier hinterlegen — nutzen Sie ENV oder eine Secret-Datei.
+# - ENV-Variante: VOTIANLT_EINVOICE_SIGNING_MASTER_KEY=...
+# (oder Lower-Case-Equivalent via Spring Relaxed Binding)
+# - Secret-Datei-Variante: master-key-file zeigt auf eine Datei (Docker-/K8s-Secret),
+# chmod 600 auf Bare-Metal/VM-Deployments.
+# - Die Spring-Placeholder-Syntax ${VAR:default} liest die ENV automatisch.
+# - application.properties selbst sollte nicht weltlesbar sein (chmod 600).
+votianlt.einvoice.signing.master-key=${VOTIANLT_EINVOICE_SIGNING_MASTER_KEY:}
+votianlt.einvoice.signing.master-key-file=${VOTIANLT_EINVOICE_SIGNING_MASTER_KEY_FILE:}
\ No newline at end of file
diff --git a/backend/src/main/resources/messages_de.properties b/backend/src/main/resources/messages_de.properties
index a33a0b5..304f22a 100644
--- a/backend/src/main/resources/messages_de.properties
+++ b/backend/src/main/resources/messages_de.properties
@@ -9,6 +9,17 @@ nav.customers=Adressbuch
nav.appusers=App-Nutzer
nav.statistics=Statistiken
nav.invoices=Rechnungen
+nav.datev.export=DATEV-Export
+nav.approvals=Freigaben
+datev.export.title=DATEV-Export
+datev.export.description=Lädt einen DATEV-kompatiblen Buchungsstapel mit allen festgeschriebenen Rechnungen des gewählten Zeitraums herunter. Die Datei kann in DATEV Unternehmen Online sowie in DATEV-importfähigen Drittprogrammen eingelesen werden.
+datev.export.from=Von
+datev.export.to=Bis
+datev.export.button=Rechnungen exportieren
+datev.export.success=Export erstellt: {0}
+datev.export.error.dates=Bitte Von- und Bis-Datum auswählen.
+datev.export.error.range=Das Bis-Datum darf nicht vor dem Von-Datum liegen.
+datev.export.error.user=Aktueller Nutzer konnte nicht ermittelt werden.
nav.messages=Nachrichten
nav.profile=Mein Profil
nav.myinvoices=Rechnungen
@@ -47,6 +58,36 @@ profile.settings.digitalprocess.info=Aufträge werden digital über die App abge
profile.settings.locateappuser=App-Nutzer orten
profile.settings.locateappuser.info=Standort der App-Nutzer wird regelmäßig übertragen
profile.settings.vatrate=Umsatzsteuer
+profile.settings.einvoice=ZUGFeRD-E-Rechnung erstellen
+profile.settings.einvoice.helper=Erzeugt PDF/A-3 mit eingebettetem XRechnung/ZUGFeRD-XML (sofern systemweit aktiviert).
+profile.settings.signinvoices=Rechnungen digital signieren
+profile.settings.signinvoices.helper=Erzeugt eine PAdES-Signatur mit dem hinterlegten Zertifikat. Ohne aktives Zertifikat schlägt das Speichern fehl.
+profile.signing.title=Signatur-Zertifikat
+profile.signing.hint=Hinterlegen Sie Ihr eigenes PKCS#12-Zertifikat (.p12/.pfx), damit Ihre Rechnungen mit Ihrer Signatur erstellt werden. Der private Schlüssel wird verschlüsselt in der Datenbank gespeichert.
+profile.signing.masterkey.missing=Hinweis: Der Server-Master-Key ist nicht gesetzt. Bitten Sie Ihren Administrator, votianlt.einvoice.signing.master-key zu konfigurieren, bevor Sie ein Zertifikat hinterlegen.
+profile.signing.none=Es ist noch kein eigenes Signatur-Zertifikat hinterlegt. Beim Signieren wird der systemweit konfigurierte Schlüssel verwendet.
+profile.signing.metadata.alias=Alias
+profile.signing.metadata.subject=Inhaber
+profile.signing.metadata.issuer=Aussteller
+profile.signing.metadata.serial=Seriennummer
+profile.signing.metadata.validity=Gültig
+profile.signing.expired=Zertifikat abgelaufen
+profile.signing.expiring=Läuft in den nächsten 30 Tagen ab
+profile.signing.enabled=Eigenes Zertifikat zum Signieren verwenden
+profile.signing.toggle.saved=Einstellung gespeichert.
+profile.signing.delete=Zertifikat entfernen
+profile.signing.deleted=Signatur-Zertifikat entfernt.
+profile.signing.upload.title=Zertifikat hochladen
+profile.signing.upload.drop=PKCS#12-Datei hier ablegen oder klicken
+profile.signing.upload.received=Datei empfangen — bitte Alias und Passwort angeben.
+profile.signing.upload.required=Bitte zuerst eine Zertifikatsdatei hochladen.
+profile.signing.upload.save=Speichern
+profile.signing.alias=Schlüssel-Alias
+profile.signing.alias.required=Bitte den Alias des Schlüssels angeben.
+profile.signing.password=Keystore-Passwort
+profile.signing.password.required=Bitte das Keystore-Passwort angeben.
+profile.signing.saved=Signatur-Zertifikat gespeichert.
+profile.signing.error=Speichern fehlgeschlagen
profile.account=Konto
profile.security=Sicherheit
profile.security.twofactor=Zwei-Faktor-Authentifizierung
@@ -706,6 +747,96 @@ invoices.column.amount=Betrag
invoices.column.description=Beschreibung
invoices.empty=Es wurden noch keine Rechnungen erstellt.
invoices.notification.pdf.missing=Für diese Rechnung ist kein PDF gespeichert.
+invoices.notification.pdf.error=Die PDF-Anzeige ist fehlgeschlagen: {0}
+invoices.column.status=Status
+invoices.column.type=Typ
+invoices.column.actions=Aktionen
+invoices.disclaimer=Hinweis: Die rechtliche Aufbewahrungspflicht liegt beim Aussteller. Eine bereits ausgestellte Rechnung wird nicht überschrieben — Korrekturen erfolgen über Berichtigung oder Stornorechnung mit eindeutigem Bezug.
+invoices.status.draft=Entwurf
+invoices.status.issued=Ausgestellt
+invoices.status.sent=Versendet
+invoices.status.cancelled=Storniert
+invoices.status.corrected=Berichtigt
+invoices.type.invoice=Rechnung
+invoices.type.cancellation=Stornorechnung
+invoices.type.correction=Berichtigung
+invoices.action.view=PDF anzeigen
+invoices.action.history=Historie
+invoices.action.marksent=Versendet markieren
+invoices.action.correct=Berichtigen
+invoices.action.cancel=Stornieren
+invoices.notification.sent=Rechnung als versendet markiert.
+invoices.notification.cancelled=Stornobeleg {0} erstellt.
+invoices.notification.corrected=Berichtigungsbeleg {0} erstellt.
+invoices.notification.error=Aktion fehlgeschlagen: {0}
+invoices.cancel.title=Rechnung {0} stornieren
+invoices.cancel.hint=Die Originalrechnung bleibt unverändert sichtbar. Es wird ein eigenständiger Stornobeleg mit eigener Belegnummer erstellt, der die Originalrechnung eindeutig referenziert.
+invoices.cancel.reason=Grund der Stornierung
+invoices.cancel.reason.required=Bitte einen Grund angeben.
+invoices.cancel.confirm=Stornobeleg erstellen
+invoices.correct.title=Rechnung {0} berichtigen
+invoices.correct.hint=Eine Berichtigung dient ausschließlich der Korrektur formaler Fehler (z.B. Adresse, Leistungsdatum). Die Originalrechnung bleibt sichtbar; der Berichtigungsbeleg verweist eindeutig auf sie.
+invoices.correct.fields=Berichtigte Angaben
+invoices.correct.fields.helper=Beschreiben Sie, welche Angaben ergänzt oder ersetzt werden.
+invoices.correct.fields.required=Bitte die berichtigten Angaben beschreiben.
+invoices.correct.reason=Grund der Berichtigung
+invoices.correct.confirm=Berichtigung erstellen
+invoices.history.title=Historie zu Rechnung {0}
+invoices.history.log=Änderungsprotokoll
+invoices.history.empty=Keine Einträge vorhanden.
+invoices.history.original=Originalrechnung
+invoices.history.cancellation=Stornobeleg
+invoices.history.correction=Berichtigungsbeleg
+invoices.history.replacement=Ersatzrechnung
+invoices.audit.action.created_draft=Entwurf erstellt
+invoices.audit.action.updated_draft=Entwurf geändert
+invoices.audit.action.issued=Ausgestellt
+invoices.audit.action.sent=Versendet
+invoices.audit.action.cancelled=Storniert
+invoices.audit.action.corrected=Berichtigt
+invoices.audit.action.replaced=Ersetzt durch neue Rechnung
+invoices.audit.action.deleted_draft=Entwurf gelöscht
+invoices.audit.action.payment_recorded=Zahlung erfasst
+invoices.audit.resulting=Erzeugter Folgebeleg: {0}
+invoices.column.payment=Zahlung
+invoices.column.outstanding=Offen
+invoices.payment.unpaid=Offen
+invoices.payment.partially_paid=Teilzahlung
+invoices.payment.paid=Bezahlt
+invoices.payment.overpaid=Überzahlung
+invoices.payment.refund_due=Erstattung offen
+invoices.action.payment=Zahlung erfassen
+invoices.action.export=Exportieren
+invoices.payment.title=Zahlung zu Rechnung {0}
+invoices.payment.hint=Offener Restbetrag: {0}. Negative Beträge können zur Korrektur erfasst werden.
+invoices.payment.amount=Zahlbetrag
+invoices.payment.amount.required=Bitte einen Betrag (ungleich 0) angeben.
+invoices.payment.reference=Zahlungsreferenz (z.B. Kontoauszug, Buchungs-Nr.)
+invoices.payment.reason=Anmerkung
+invoices.payment.confirm=Zahlung erfassen
+invoices.notification.payment=Zahlung erfasst.
+invoices.einvoice.tooltip=PDF/A-3 mit eingebettetem ZUGFeRD/XRechnung-XML
+invoices.einvoice.signed=Signiert
+invoices.action.cancel.request=Storno beantragen
+invoices.action.correct.request=Berichtigung beantragen
+invoices.notification.requested=Freigabe-Anfrage erstellt. Bitte auf Freigabe warten.
+approvals.title=Freigaben
+approvals.no.permission=Sie haben keine Berechtigung, Freigaben zu bearbeiten.
+approvals.column.requested=Beantragt am
+approvals.column.requester=Beantragt von
+approvals.column.invoice=Rechnung
+approvals.column.action=Aktion
+approvals.column.reason=Grund
+approvals.action.approve=Freigeben
+approvals.action.reject=Ablehnen
+approvals.confirm.approve.title=Anfrage zu Rechnung {0} freigeben
+approvals.confirm.reject.title=Anfrage zu Rechnung {0} ablehnen
+approvals.review.fields=Berichtigte Angaben
+approvals.review.reason=Grund
+approvals.review.comment=Kommentar (optional)
+approvals.notification.approved=Anfrage freigegeben — Folgebeleg wurde erstellt.
+approvals.notification.rejected=Anfrage abgelehnt.
+page.title.approvals=Freigaben
# My Invoices
myinvoices.title=Rechnungen
diff --git a/backend/src/main/resources/messages_en.properties b/backend/src/main/resources/messages_en.properties
index 8fa75f9..d046770 100644
--- a/backend/src/main/resources/messages_en.properties
+++ b/backend/src/main/resources/messages_en.properties
@@ -9,6 +9,17 @@ nav.customers=Address Book
nav.appusers=App Users
nav.statistics=Statistics
nav.invoices=Invoices
+nav.datev.export=DATEV Export
+nav.approvals=Approvals
+datev.export.title=DATEV Export
+datev.export.description=Downloads a DATEV-compatible booking batch containing all finalized invoices for the selected period. The file can be imported into DATEV Unternehmen Online as well as DATEV-compatible third-party tools.
+datev.export.from=From
+datev.export.to=To
+datev.export.button=Export invoices
+datev.export.success=Export created: {0}
+datev.export.error.dates=Please pick both From and To dates.
+datev.export.error.range=To date must not be before From date.
+datev.export.error.user=Could not determine the current user.
nav.messages=Messages
nav.profile=My Profile
nav.myinvoices=Invoices
@@ -47,6 +58,36 @@ profile.settings.digitalprocess.info=Jobs are processed digitally via the app
profile.settings.locateappuser=Locate App Users
profile.settings.locateappuser.info=App user location is transmitted regularly
profile.settings.vatrate=VAT rate
+profile.settings.einvoice=Create ZUGFeRD e-invoice
+profile.settings.einvoice.helper=Generates PDF/A-3 with embedded XRechnung/ZUGFeRD XML (when enabled system-wide).
+profile.settings.signinvoices=Digitally sign invoices
+profile.settings.signinvoices.helper=Adds a PAdES signature using the configured certificate. Saving fails if no active certificate is available.
+profile.signing.title=Signing certificate
+profile.signing.hint=Upload your own PKCS#12 certificate (.p12/.pfx) so invoices are signed with your signature. The private key is stored encrypted in the database.
+profile.signing.masterkey.missing=Note: The server master key is not configured. Ask your administrator to set votianlt.einvoice.signing.master-key before uploading a certificate.
+profile.signing.none=No personal signing certificate stored yet. The system-wide key will be used when signing.
+profile.signing.metadata.alias=Alias
+profile.signing.metadata.subject=Subject
+profile.signing.metadata.issuer=Issuer
+profile.signing.metadata.serial=Serial number
+profile.signing.metadata.validity=Valid
+profile.signing.expired=Certificate expired
+profile.signing.expiring=Expires within the next 30 days
+profile.signing.enabled=Use my certificate for signing
+profile.signing.toggle.saved=Setting saved.
+profile.signing.delete=Remove certificate
+profile.signing.deleted=Signing certificate removed.
+profile.signing.upload.title=Upload certificate
+profile.signing.upload.drop=Drop your PKCS#12 file here or click to upload
+profile.signing.upload.received=File received — please provide alias and password.
+profile.signing.upload.required=Please upload a certificate file first.
+profile.signing.upload.save=Save
+profile.signing.alias=Key alias
+profile.signing.alias.required=Please provide the key alias.
+profile.signing.password=Keystore password
+profile.signing.password.required=Please provide the keystore password.
+profile.signing.saved=Signing certificate saved.
+profile.signing.error=Save failed
profile.account=Account
profile.security=Security
profile.security.twofactor=Two-Factor Authentication
@@ -706,6 +747,96 @@ invoices.column.amount=Amount
invoices.column.description=Description
invoices.empty=No invoices have been created yet.
invoices.notification.pdf.missing=No PDF is stored for this invoice.
+invoices.notification.pdf.error=Failed to display PDF: {0}
+invoices.column.status=Status
+invoices.column.type=Type
+invoices.column.actions=Actions
+invoices.disclaimer=Note: Legal retention obligations remain with the issuer. An already issued invoice is never overwritten — corrections are made through a correction document or cancellation invoice that explicitly references the original.
+invoices.status.draft=Draft
+invoices.status.issued=Issued
+invoices.status.sent=Sent
+invoices.status.cancelled=Cancelled
+invoices.status.corrected=Corrected
+invoices.type.invoice=Invoice
+invoices.type.cancellation=Cancellation invoice
+invoices.type.correction=Correction document
+invoices.action.view=View PDF
+invoices.action.history=History
+invoices.action.marksent=Mark as sent
+invoices.action.correct=Correct
+invoices.action.cancel=Cancel
+invoices.notification.sent=Invoice marked as sent.
+invoices.notification.cancelled=Cancellation document {0} created.
+invoices.notification.corrected=Correction document {0} created.
+invoices.notification.error=Action failed: {0}
+invoices.cancel.title=Cancel invoice {0}
+invoices.cancel.hint=The original invoice remains visible. A separate cancellation document with its own number will be created, explicitly referencing the original invoice.
+invoices.cancel.reason=Reason for cancellation
+invoices.cancel.reason.required=Please provide a reason.
+invoices.cancel.confirm=Create cancellation document
+invoices.correct.title=Correct invoice {0}
+invoices.correct.hint=A correction document is intended for formal errors only (e.g. address, delivery date). The original invoice remains visible and the correction document refers to it explicitly.
+invoices.correct.fields=Corrected information
+invoices.correct.fields.helper=Describe which fields are added or replaced.
+invoices.correct.fields.required=Please describe the corrected information.
+invoices.correct.reason=Reason for correction
+invoices.correct.confirm=Create correction document
+invoices.history.title=History for invoice {0}
+invoices.history.log=Audit log
+invoices.history.empty=No entries available.
+invoices.history.original=Original invoice
+invoices.history.cancellation=Cancellation document
+invoices.history.correction=Correction document
+invoices.history.replacement=Replacement invoice
+invoices.audit.action.created_draft=Draft created
+invoices.audit.action.updated_draft=Draft updated
+invoices.audit.action.issued=Issued
+invoices.audit.action.sent=Sent
+invoices.audit.action.cancelled=Cancelled
+invoices.audit.action.corrected=Corrected
+invoices.audit.action.replaced=Replaced by new invoice
+invoices.audit.action.deleted_draft=Draft deleted
+invoices.audit.action.payment_recorded=Payment recorded
+invoices.audit.resulting=Resulting document: {0}
+invoices.column.payment=Payment
+invoices.column.outstanding=Outstanding
+invoices.payment.unpaid=Open
+invoices.payment.partially_paid=Partially paid
+invoices.payment.paid=Paid
+invoices.payment.overpaid=Overpaid
+invoices.payment.refund_due=Refund due
+invoices.action.payment=Record payment
+invoices.action.export=Export
+invoices.payment.title=Payment for invoice {0}
+invoices.payment.hint=Outstanding balance: {0}. Negative amounts can be recorded for corrections.
+invoices.payment.amount=Amount
+invoices.payment.amount.required=Please enter a non-zero amount.
+invoices.payment.reference=Payment reference (e.g. statement, booking ID)
+invoices.payment.reason=Note
+invoices.payment.confirm=Record payment
+invoices.notification.payment=Payment recorded.
+invoices.einvoice.tooltip=PDF/A-3 with embedded ZUGFeRD/XRechnung XML
+invoices.einvoice.signed=Signed
+invoices.action.cancel.request=Request cancellation
+invoices.action.correct.request=Request correction
+invoices.notification.requested=Approval request created. Please wait for approval.
+approvals.title=Approvals
+approvals.no.permission=You do not have permission to handle approvals.
+approvals.column.requested=Requested at
+approvals.column.requester=Requested by
+approvals.column.invoice=Invoice
+approvals.column.action=Action
+approvals.column.reason=Reason
+approvals.action.approve=Approve
+approvals.action.reject=Reject
+approvals.confirm.approve.title=Approve request for invoice {0}
+approvals.confirm.reject.title=Reject request for invoice {0}
+approvals.review.fields=Corrected information
+approvals.review.reason=Reason
+approvals.review.comment=Comment (optional)
+approvals.notification.approved=Request approved — follow-up document created.
+approvals.notification.rejected=Request rejected.
+page.title.approvals=Approvals
# My Invoices
myinvoices.title=Invoices
diff --git a/backend/src/test/java/de/assecutor/votianlt/service/DatevExportServiceTest.java b/backend/src/test/java/de/assecutor/votianlt/service/DatevExportServiceTest.java
new file mode 100644
index 0000000..c30e8d1
--- /dev/null
+++ b/backend/src/test/java/de/assecutor/votianlt/service/DatevExportServiceTest.java
@@ -0,0 +1,173 @@
+package de.assecutor.votianlt.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.when;
+
+import de.assecutor.votianlt.model.invoices.CustomerInvoice;
+import de.assecutor.votianlt.model.invoices.InvoiceStatus;
+import de.assecutor.votianlt.model.invoices.InvoiceType;
+import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+import java.nio.charset.Charset;
+import java.time.LocalDate;
+import java.util.List;
+
+@ExtendWith(MockitoExtension.class)
+class DatevExportServiceTest {
+
+ private static final Charset DATEV_CHARSET = Charset.forName("Windows-1252");
+ private static final String USER_ID = "user-1";
+
+ @Mock
+ private CustomerInvoiceRepository invoiceRepository;
+
+ private DatevExportService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new DatevExportService(invoiceRepository);
+ }
+
+ @Test
+ void exportContainsHeaderColumnsAndOneRowPerInvoice() {
+ when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of(
+ invoice("R-2026-001", LocalDate.of(2026, 4, 5), new BigDecimal("119.00"),
+ new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, "Kunde A"),
+ invoice("R-2026-002", LocalDate.of(2026, 4, 12), new BigDecimal("214.00"),
+ new BigDecimal("0.07"), InvoiceType.INVOICE, InvoiceStatus.SENT, "Kunde B")));
+
+ byte[] csv = service.export(USER_ID, LocalDate.of(2026, 4, 1), LocalDate.of(2026, 4, 30));
+ String content = new String(csv, DATEV_CHARSET);
+ String[] lines = content.split("\\r\\n");
+
+ assertThat(lines[0]).startsWith("\"EXTF\";700;21;\"Buchungsstapel\"");
+ assertThat(lines[0]).contains("20260401;20260430");
+ assertThat(lines[1]).startsWith("Umsatz (ohne Soll/Haben-Kz);Soll/Haben-Kennzeichen;WKZ Umsatz;Konto;");
+ assertThat(lines).hasSize(4); // header + col-header + 2 invoices
+ assertThat(lines[2]).startsWith("119,00;S;\"EUR\";10000;8400;;0504;\"R-2026-001\";");
+ assertThat(lines[3]).startsWith("214,00;S;\"EUR\";10000;8300;;1204;\"R-2026-002\";");
+ }
+
+ @Test
+ void cancellationFlipsSollHabenAndUsesAbsoluteAmount() {
+ when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of(
+ invoice("S-2026-007", LocalDate.of(2026, 4, 20), new BigDecimal("-119.00"),
+ new BigDecimal("0.19"), InvoiceType.CANCELLATION, InvoiceStatus.ISSUED, "Kunde A")));
+
+ String content = new String(service.export(USER_ID, LocalDate.of(2026, 4, 1),
+ LocalDate.of(2026, 4, 30)), DATEV_CHARSET);
+ String[] lines = content.split("\\r\\n");
+
+ assertThat(lines[2]).startsWith("119,00;H;\"EUR\";10000;8400;;");
+ assertThat(lines[2]).contains("\"Storno: Kunde A\"");
+ }
+
+ @Test
+ void zeroVatMapsToReverseChargeAccount() {
+ when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of(
+ invoice("R-2026-009", LocalDate.of(2026, 4, 7), new BigDecimal("500.00"),
+ BigDecimal.ZERO, InvoiceType.INVOICE, InvoiceStatus.ISSUED, "EU-Kunde")));
+
+ String content = new String(service.export(USER_ID, LocalDate.of(2026, 4, 1),
+ LocalDate.of(2026, 4, 30)), DATEV_CHARSET);
+ String[] lines = content.split("\\r\\n");
+
+ assertThat(lines[2]).contains(";8125;");
+ }
+
+ @Test
+ void draftsAreExcluded() {
+ when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of(
+ invoice("R-DRAFT", LocalDate.of(2026, 4, 5), new BigDecimal("100.00"),
+ new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.DRAFT, "X"),
+ invoice("R-ISSUED", LocalDate.of(2026, 4, 6), new BigDecimal("100.00"),
+ new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, "Y")));
+
+ String content = new String(service.export(USER_ID, LocalDate.of(2026, 4, 1),
+ LocalDate.of(2026, 4, 30)), DATEV_CHARSET);
+ String[] lines = content.split("\\r\\n");
+
+ assertThat(lines).hasSize(3);
+ assertThat(lines[2]).contains("\"R-ISSUED\"");
+ }
+
+ @Test
+ void invoicesOutsidePeriodAreIgnored() {
+ when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of(
+ invoice("R-2026-MAR", LocalDate.of(2026, 3, 31), new BigDecimal("100.00"),
+ new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, "Y"),
+ invoice("R-2026-IN", LocalDate.of(2026, 4, 1), new BigDecimal("100.00"),
+ new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, "Y"),
+ invoice("R-2026-OUT", LocalDate.of(2026, 5, 1), new BigDecimal("100.00"),
+ new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, "Y")));
+
+ String content = new String(service.export(USER_ID, LocalDate.of(2026, 4, 1),
+ LocalDate.of(2026, 4, 30)), DATEV_CHARSET);
+ String[] lines = content.split("\\r\\n");
+
+ assertThat(lines).hasSize(3);
+ assertThat(lines[2]).contains("\"R-2026-IN\"");
+ }
+
+ @Test
+ void emptyResultStillProducesValidHeader() {
+ when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of());
+
+ String content = new String(service.export(USER_ID, LocalDate.of(2026, 1, 1),
+ LocalDate.of(2026, 1, 31)), DATEV_CHARSET);
+ String[] lines = content.split("\\r\\n");
+
+ assertThat(lines[0]).startsWith("\"EXTF\";700;21;\"Buchungsstapel\"");
+ assertThat(lines).hasSize(2);
+ }
+
+ @Test
+ void quotesInsideRecipientAreEscaped() {
+ when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of(
+ invoice("R-2026-QT", LocalDate.of(2026, 4, 15), new BigDecimal("119.00"),
+ new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED,
+ "Acme \"Premium\" GmbH")));
+
+ String content = new String(service.export(USER_ID, LocalDate.of(2026, 4, 1),
+ LocalDate.of(2026, 4, 30)), DATEV_CHARSET);
+ // DATEV escaped Anführungszeichen durch Verdoppeln.
+ assertThat(content).contains("\"Rechnung an Acme \"\"Premium\"\" GmbH\"");
+ }
+
+ @Test
+ void rejectsInvalidInputs() {
+ assertThatThrownBy(() -> service.export(null, LocalDate.now(), LocalDate.now()))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> service.export(USER_ID, null, LocalDate.now()))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> service.export(USER_ID, LocalDate.of(2026, 5, 1), LocalDate.of(2026, 4, 1)))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void filenameContainsDateRange() {
+ String filename = service.suggestFilename(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 3, 31));
+ assertThat(filename).isEqualTo("EXTF_Buchungsstapel_20260101_20260331.csv");
+ }
+
+ private CustomerInvoice invoice(String number, LocalDate date, BigDecimal total, BigDecimal vatRate,
+ InvoiceType type, InvoiceStatus status, String recipient) {
+ CustomerInvoice invoice = new CustomerInvoice();
+ invoice.setInvoiceNumber(number);
+ invoice.setInvoiceDate(date);
+ invoice.setTotalAmount(total);
+ invoice.setVatRate(vatRate);
+ invoice.setType(type);
+ invoice.setStatus(status);
+ invoice.setRecipientName(recipient);
+ invoice.setUserId(USER_ID);
+ return invoice;
+ }
+}
diff --git a/backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceDssValidationTest.java b/backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceDssValidationTest.java
new file mode 100644
index 0000000..a312a4d
--- /dev/null
+++ b/backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceDssValidationTest.java
@@ -0,0 +1,271 @@
+package de.assecutor.votianlt.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import de.assecutor.votianlt.config.EInvoiceProperties;
+import de.assecutor.votianlt.model.invoices.CustomerInvoice;
+import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
+import de.assecutor.votianlt.model.invoices.UserSigningCredentials;
+import de.assecutor.votianlt.repository.UserSigningCredentialsRepository;
+import eu.europa.esig.dss.diagnostic.DiagnosticData;
+import eu.europa.esig.dss.diagnostic.SignatureWrapper;
+import eu.europa.esig.dss.enumerations.SignatureLevel;
+import eu.europa.esig.dss.model.DSSDocument;
+import eu.europa.esig.dss.model.InMemoryDocument;
+import eu.europa.esig.dss.model.x509.CertificateToken;
+import eu.europa.esig.dss.spi.validation.CommonCertificateVerifier;
+import eu.europa.esig.dss.spi.x509.CommonTrustedCertificateSource;
+import eu.europa.esig.dss.validation.SignedDocumentValidator;
+import eu.europa.esig.dss.validation.reports.Reports;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.io.ByteArrayOutputStream;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
+import java.security.Security;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+import java.util.List;
+import java.util.Optional;
+
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+
+/**
+ * Validiert die vom Service erzeugten Signaturen gegen die EU DSS
+ * (Digital Signature Service) Bibliothek. Während {@link EInvoiceServiceTest}
+ * nur die strukturelle Korrektheit des Signature-Dictionarys prüft, parst
+ * DSS hier die Signatur kryptographisch nach und liefert ein Diagnostic-Data-
+ * Modell.
+ *
+ * Geprüft wird pro Signaturpfad (System- und Nutzer-Keystore):
+ *
+ * - genau eine Signatur ist im Dokument enthalten,
+ * - die Signatur ist kryptographisch intakt (Hash & Signaturwert),
+ * - das Format ist PAdES-Baseline (mehr ist mit den aktuellen
+ * Service-Settings nicht zu erwarten — kein TSA, kein DSS-Dictionary),
+ * - der Signer-DN entspricht dem im Test erzeugten Zertifikat.
+ *
+ *
+ * Die Self-Signed-Zertifikate werden als Trust-Anchor in den
+ * {@link CommonCertificateVerifier} eingehängt — sonst würde DSS die Kette
+ * korrekt als nicht vertrauenswürdig melden, was über die rein technische
+ * Signaturprüfung hinausgeht und für unseren Test irrelevant ist.
+ */
+@ExtendWith(MockitoExtension.class)
+class EInvoiceServiceDssValidationTest {
+
+ private static final String USER_ID = "user-1";
+ private static final String SYSTEM_ALIAS = "system-signer";
+ private static final String USER_ALIAS = "user-signer";
+ private static final String SYSTEM_PASSWORD = "system-pass";
+ private static final String USER_PASSWORD = "user-pass";
+ private static final String MASTER_KEY = "0123456789abcdef0123456789abcdef";
+
+ static {
+ if (Security.getProvider("BC") == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+ }
+
+ @Mock
+ private UserSigningCredentialsRepository repository;
+
+ @TempDir
+ Path tempDir;
+
+ private EInvoiceProperties properties;
+ private SigningCredentialsService signingCredentialsService;
+ private EInvoiceService eInvoiceService;
+
+ @BeforeEach
+ void setUp() {
+ properties = new EInvoiceProperties();
+ properties.setEnabled(true);
+ properties.setProfile("EN16931");
+ properties.getSigning().setEnabled(false);
+ properties.getSigning().setMasterKey(MASTER_KEY);
+
+ signingCredentialsService = new SigningCredentialsService(repository, properties);
+ eInvoiceService = new EInvoiceService(properties, signingCredentialsService);
+ }
+
+ @Test
+ void systemKeystoreSignaturePassesDssValidation() throws Exception {
+ KeystoreFixture fixture = generateKeystore(SYSTEM_ALIAS, SYSTEM_PASSWORD);
+ Path keystoreFile = tempDir.resolve("system.p12");
+ Files.write(keystoreFile, fixture.keystoreBytes());
+ configureSystemKeystore(keystoreFile, SYSTEM_PASSWORD, SYSTEM_ALIAS);
+ when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty());
+
+ CustomerInvoice invoice = sampleInvoice();
+ byte[] signedPdf = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true);
+
+ assertDssAcceptsSignature(signedPdf, fixture.certificate(), "Votianlt Test " + SYSTEM_ALIAS);
+ }
+
+ @Test
+ void userKeystoreSignaturePassesDssValidation() throws Exception {
+ KeystoreFixture fixture = generateKeystore(USER_ALIAS, USER_PASSWORD);
+ when(repository.save(any(UserSigningCredentials.class))).thenAnswer(inv -> inv.getArgument(0));
+ UserSigningCredentials stored = signingCredentialsService.store(
+ USER_ID, fixture.keystoreBytes(), USER_PASSWORD, USER_ALIAS);
+ when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(stored));
+
+ CustomerInvoice invoice = sampleInvoice();
+ byte[] signedPdf = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true);
+
+ assertDssAcceptsSignature(signedPdf, fixture.certificate(), "Votianlt Test " + USER_ALIAS);
+ }
+
+ private void assertDssAcceptsSignature(byte[] signedPdf, X509Certificate trustAnchor,
+ String expectedSubjectFragment) {
+ DSSDocument document = new InMemoryDocument(signedPdf);
+ SignedDocumentValidator validator = SignedDocumentValidator.fromDocument(document);
+
+ CommonTrustedCertificateSource trustedSource = new CommonTrustedCertificateSource();
+ trustedSource.addCertificate(new CertificateToken(trustAnchor));
+
+ CommonCertificateVerifier verifier = new CommonCertificateVerifier();
+ verifier.setTrustedCertSources(trustedSource);
+ verifier.setAIASource(null);
+ verifier.setCrlSource(null);
+ verifier.setOcspSource(null);
+
+ validator.setCertificateVerifier(verifier);
+
+ Reports reports = validator.validateDocument();
+ DiagnosticData diagnosticData = reports.getDiagnosticData();
+
+ List signatureIds = diagnosticData.getSignatureIdList();
+ assertThat(signatureIds)
+ .as("DSS muss genau eine PAdES-Signatur im PDF erkennen")
+ .hasSize(1);
+
+ String signatureId = signatureIds.get(0);
+ SignatureWrapper signature = diagnosticData.getSignatureById(signatureId);
+
+ assertThat(signature.isSignatureIntact())
+ .as("Signatur-Hash über den signierten Bytes muss aufgehen")
+ .isTrue();
+ assertThat(signature.isSignatureValid())
+ .as("Signaturwert muss mit dem Public Key des Signer-Zertifikats verifizierbar sein")
+ .isTrue();
+
+ // Aktueller Stand: der Service erzeugt eine generische PKCS#7-Signatur (subFilter
+ // adbe.pkcs7.detached) ohne ESS-signing-certificate-v2-Attribut. DSS klassifiziert
+ // das deshalb als PKCS7-B, nicht als PAdES-BASELINE-B. Hochstufung auf
+ // PAdES-BASELINE-B verlangt signierte Attribute (ESS Signing Certificate v2,
+ // signing-time im subFilter etrsi.RFC3161 oder ETSI.CAdES.detached, etc.) und
+ // wäre eine inhaltliche Erweiterung des Services.
+ SignatureLevel level = diagnosticData.getSignatureFormat(signatureId);
+ assertThat(level)
+ .as("Service erzeugt aktuell PKCS#7-B; ein Upgrade auf PAdES-BASELINE-B "
+ + "wäre über zusätzliche CMS-signed-attributes möglich")
+ .isEqualTo(SignatureLevel.PKCS7_B);
+
+ String signerDn = signature.getSigningCertificate().getCertificateDN();
+ assertThat(signerDn)
+ .as("Signer-DN muss zum Test-Zertifikat passen")
+ .contains(expectedSubjectFragment);
+ }
+
+ private void configureSystemKeystore(Path keystoreFile, String password, String alias) {
+ EInvoiceProperties.Signing signing = properties.getSigning();
+ signing.setEnabled(true);
+ signing.setKeystorePath(keystoreFile.toString());
+ signing.setKeystorePassword(password);
+ signing.setKeyAlias(alias);
+ }
+
+ private byte[] buildBlankPdf() throws Exception {
+ try (PDDocument document = new PDDocument(); ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+ document.addPage(new PDPage());
+ document.save(baos);
+ return baos.toByteArray();
+ }
+ }
+
+ private KeystoreFixture generateKeystore(String alias, String password) {
+ try {
+ KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
+ kpg.initialize(2048);
+ KeyPair keyPair = kpg.generateKeyPair();
+
+ X500Name subject = new X500Name("CN=Votianlt Test " + alias + ",O=Votianlt,C=DE");
+ BigInteger serial = BigInteger.valueOf(System.nanoTime());
+ Date notBefore = Date.from(Instant.now().minus(1, ChronoUnit.DAYS));
+ Date notAfter = Date.from(Instant.now().plus(365, ChronoUnit.DAYS));
+
+ JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(subject, serial, notBefore,
+ notAfter, subject, keyPair.getPublic());
+ ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").setProvider("BC")
+ .build(keyPair.getPrivate());
+ X509Certificate certificate = new JcaX509CertificateConverter().setProvider("BC")
+ .getCertificate(certBuilder.build(signer));
+
+ KeyStore keystore = KeyStore.getInstance("PKCS12");
+ keystore.load(null, null);
+ keystore.setKeyEntry(alias, keyPair.getPrivate(), password.toCharArray(),
+ new Certificate[] { certificate });
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ keystore.store(baos, password.toCharArray());
+ return new KeystoreFixture(baos.toByteArray(), certificate);
+ } catch (Exception ex) {
+ throw new IllegalStateException("Test-Keystore konnte nicht erzeugt werden: " + ex.getMessage(), ex);
+ }
+ }
+
+ private CustomerInvoice sampleInvoice() {
+ CustomerInvoice invoice = new CustomerInvoice();
+ invoice.setUserId(USER_ID);
+ invoice.setInvoiceNumber("R-2026-0001");
+ invoice.setInvoiceDate(LocalDate.now());
+ invoice.setDeliveryDate(LocalDate.now());
+ invoice.setSenderName("Votianlt Test GmbH");
+ invoice.setSenderAddress("Teststraße 1");
+ invoice.setSenderPostcode("12345");
+ invoice.setSenderCity("Berlin");
+ invoice.setSenderCountry("DE");
+ invoice.setRecipientName("Empfänger AG");
+ invoice.setRecipientAddress("Kundenweg 2");
+ invoice.setRecipientPostcode("54321");
+ invoice.setRecipientCity("Hamburg");
+ invoice.setRecipientCountry("DE");
+ invoice.setDescription("Beratungsleistung");
+ invoice.setVatRate(new BigDecimal("0.19"));
+ invoice.setNetAmount(new BigDecimal("100.00"));
+ invoice.setVatAmount(new BigDecimal("19.00"));
+ invoice.setTotalAmount(new BigDecimal("119.00"));
+ CustomerInvoiceItem item = new CustomerInvoiceItem(BigDecimal.ONE, "h", "Beratung", new BigDecimal("100.00"),
+ new BigDecimal("0.19"));
+ invoice.setItems(List.of(item));
+ return invoice;
+ }
+
+ private record KeystoreFixture(byte[] keystoreBytes, X509Certificate certificate) {
+ }
+}
diff --git a/backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceTest.java b/backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceTest.java
new file mode 100644
index 0000000..10767aa
--- /dev/null
+++ b/backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceTest.java
@@ -0,0 +1,331 @@
+package de.assecutor.votianlt.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import de.assecutor.votianlt.config.EInvoiceProperties;
+import de.assecutor.votianlt.model.invoices.CustomerInvoice;
+import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
+import de.assecutor.votianlt.model.invoices.EInvoiceFormat;
+import de.assecutor.votianlt.model.invoices.UserSigningCredentials;
+import de.assecutor.votianlt.repository.UserSigningCredentialsRepository;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.font.PDType1Font;
+import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
+import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.io.ByteArrayOutputStream;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
+import java.security.Security;
+import java.security.cert.Certificate;
+import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Integrationstest des Signaturpfads — deckt System-Keystore, Nutzer-Keystore,
+ * Vorrang-Logik und alle wichtigen Fehlerpfade ab. Verwendet bewusst keinen
+ * vollen Spring-Boot-Kontext: das vermeidet die Mongo-Abhängigkeit der echten
+ * Repository-Klasse, lässt aber die echten Service-Klassen (Verschlüsselung,
+ * PDFBox/BouncyCastle-Signatur) durchlaufen.
+ *
+ * Das Zertifikat wird zur Testzeit per BouncyCastle erzeugt — der Test ist
+ * damit reproduzierbar und braucht keine Datei-Fixtures.
+ */
+@ExtendWith(MockitoExtension.class)
+class EInvoiceServiceTest {
+
+ private static final String USER_ID = "user-1";
+ private static final String SYSTEM_ALIAS = "system-signer";
+ private static final String USER_ALIAS = "user-signer";
+ private static final String SYSTEM_PASSWORD = "system-pass";
+ private static final String USER_PASSWORD = "user-pass";
+ private static final String MASTER_KEY = "0123456789abcdef0123456789abcdef";
+
+ static {
+ if (Security.getProvider("BC") == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+ }
+
+ @Mock
+ private UserSigningCredentialsRepository repository;
+
+ @TempDir
+ Path tempDir;
+
+ private EInvoiceProperties properties;
+ private SigningCredentialsService signingCredentialsService;
+ private EInvoiceService eInvoiceService;
+
+ @BeforeEach
+ void setUp() {
+ properties = new EInvoiceProperties();
+ properties.setEnabled(true);
+ properties.setProfile("EN16931");
+ properties.getSigning().setEnabled(false);
+ properties.getSigning().setMasterKey(MASTER_KEY);
+
+ signingCredentialsService = new SigningCredentialsService(repository, properties);
+ eInvoiceService = new EInvoiceService(properties, signingCredentialsService);
+ }
+
+ @Test
+ void signsPdfWithSystemKeystore() throws Exception {
+ byte[] keystoreBytes = generateKeystore(SYSTEM_ALIAS, SYSTEM_PASSWORD);
+ Path keystoreFile = tempDir.resolve("system.p12");
+ Files.write(keystoreFile, keystoreBytes);
+ configureSystemKeystore(keystoreFile, SYSTEM_PASSWORD, SYSTEM_ALIAS);
+ when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty());
+
+ CustomerInvoice invoice = sampleInvoice();
+ byte[] result = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true);
+
+ assertSignedPdf(result);
+ assertThat(invoice.isSigned()).isTrue();
+ assertThat(invoice.getSignedAt()).isNotNull();
+ assertThat(invoice.getSignedBy()).isEqualTo(SYSTEM_ALIAS);
+ }
+
+ @Test
+ void signsPdfWithUserKeystore() throws Exception {
+ UserSigningCredentials stored = storeUserKeystore();
+ when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(stored));
+
+ CustomerInvoice invoice = sampleInvoice();
+ byte[] result = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true);
+
+ assertSignedPdf(result);
+ assertThat(invoice.isSigned()).isTrue();
+ assertThat(invoice.getSignedBy()).contains("Votianlt Test");
+ }
+
+ @Test
+ void userKeystoreTakesPrecedenceOverSystemKeystore() throws Exception {
+ byte[] systemKeystoreBytes = generateKeystore(SYSTEM_ALIAS, SYSTEM_PASSWORD);
+ Path keystoreFile = tempDir.resolve("system.p12");
+ Files.write(keystoreFile, systemKeystoreBytes);
+ configureSystemKeystore(keystoreFile, SYSTEM_PASSWORD, SYSTEM_ALIAS);
+
+ UserSigningCredentials stored = storeUserKeystore();
+ when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(stored));
+
+ CustomerInvoice invoice = sampleInvoice();
+ byte[] result = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true);
+
+ assertSignedPdf(result);
+ assertThat(invoice.getSignedBy())
+ .as("Bei vorhandenen Nutzer-Credentials darf nicht der System-Alias signieren")
+ .isNotEqualTo(SYSTEM_ALIAS)
+ .contains("Votianlt Test");
+ }
+
+ @Test
+ void failsWhenNoKeystoreAvailable() {
+ when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty());
+
+ CustomerInvoice invoice = sampleInvoice();
+ assertThatThrownBy(() -> eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true))
+ .isInstanceOf(InvoiceLifecycleException.class)
+ .hasMessageContaining("kein Signatur-Zertifikat verfügbar");
+ assertThat(invoice.isSigned()).isFalse();
+ }
+
+ @Test
+ void failsWhenUserCredentialsDisabled() {
+ UserSigningCredentials stored = storeUserKeystore();
+ stored.setEnabled(false);
+ when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(stored));
+
+ CustomerInvoice invoice = sampleInvoice();
+ assertThatThrownBy(() -> eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true))
+ .isInstanceOf(InvoiceLifecycleException.class)
+ .hasMessageContaining("deaktiviert");
+ assertThat(invoice.isSigned()).isFalse();
+ }
+
+ @Test
+ void failsWhenMasterKeyChangedAfterStorage() {
+ UserSigningCredentials stored = storeUserKeystore();
+ when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(stored));
+
+ // Master-Key wird nach dem Persistieren ausgetauscht — der Keystore lässt sich
+ // nicht mehr entschlüsseln, der Service muss dem Anwender klar signalisieren,
+ // dass er das Zertifikat erneut hochladen muss.
+ properties.getSigning().setMasterKey("ffffffffffffffffffffffffffffffff");
+
+ CustomerInvoice invoice = sampleInvoice();
+ assertThatThrownBy(() -> eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true))
+ .isInstanceOf(InvoiceLifecycleException.class)
+ .hasMessageContaining("nicht entschlüsselt");
+ assertThat(invoice.isSigned()).isFalse();
+ }
+
+ /**
+ * Ein nicht-PDF/A-konformes Eingabe-PDF (z.B. ohne eingebettete Fonts) lässt
+ * die Mustang-Anreicherung scheitern. Der Service muss laut Klassen-JavaDoc
+ * mit dem Roh-PDF fortfahren und die Signatur trotzdem strikt durchführen —
+ * Format wird auf NONE gesetzt, Signatur darf nicht ausfallen.
+ */
+ @Test
+ void gracefullyDegradesWhenZugferdEmbeddingFails() throws Exception {
+ byte[] keystoreBytes = generateKeystore(SYSTEM_ALIAS, SYSTEM_PASSWORD);
+ Path keystoreFile = tempDir.resolve("system.p12");
+ Files.write(keystoreFile, keystoreBytes);
+ configureSystemKeystore(keystoreFile, SYSTEM_PASSWORD, SYSTEM_ALIAS);
+ when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty());
+
+ CustomerInvoice invoice = sampleInvoice();
+ byte[] result = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, true, true);
+
+ assertSignedPdf(result);
+ assertThat(invoice.getEInvoiceFormat()).isEqualTo(EInvoiceFormat.NONE);
+ assertThat(invoice.isSigned()).isTrue();
+ }
+
+ @Test
+ void embedsZugferdXmlIntoValidPdf() throws Exception {
+ CustomerInvoice invoice = sampleInvoice();
+ byte[] result = eInvoiceService.embedZugferdXml(buildPdfWithFont(), invoice);
+
+ assertThat(result).isNotEmpty();
+ // Das ZUGFeRD-XML wird als „factur-x.xml" eingebettet — schneller Smoke-Test
+ // über die UTF-8-Repräsentation des Containers.
+ String resultAsString = new String(result, java.nio.charset.StandardCharsets.ISO_8859_1);
+ assertThat(resultAsString).contains("factur-x.xml");
+ }
+
+ private void configureSystemKeystore(Path keystoreFile, String password, String alias) {
+ EInvoiceProperties.Signing signing = properties.getSigning();
+ signing.setEnabled(true);
+ signing.setKeystorePath(keystoreFile.toString());
+ signing.setKeystorePassword(password);
+ signing.setKeyAlias(alias);
+ }
+
+ private UserSigningCredentials storeUserKeystore() {
+ byte[] userKeystore = generateKeystore(USER_ALIAS, USER_PASSWORD);
+ when(repository.save(any(UserSigningCredentials.class))).thenAnswer(invocation -> invocation.getArgument(0));
+ return signingCredentialsService.store(USER_ID, userKeystore, USER_PASSWORD, USER_ALIAS);
+ }
+
+ private void assertSignedPdf(byte[] pdfBytes) throws Exception {
+ try (PDDocument document = Loader.loadPDF(pdfBytes)) {
+ List signatures = document.getSignatureDictionaries();
+ assertThat(signatures).as("Signed PDF must contain a signature dictionary").isNotEmpty();
+ PDSignature signature = signatures.get(0);
+ assertThat(signature.getFilter()).isEqualTo("Adobe.PPKLite");
+ assertThat(signature.getSubFilter()).isEqualTo("adbe.pkcs7.detached");
+ assertThat(signature.getContents(pdfBytes)).isNotEmpty();
+ }
+ }
+
+ private byte[] buildBlankPdf() throws Exception {
+ try (PDDocument document = new PDDocument(); ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+ document.addPage(new PDPage());
+ document.save(baos);
+ return baos.toByteArray();
+ }
+ }
+
+ private byte[] buildPdfWithFont() throws Exception {
+ try (PDDocument document = new PDDocument(); ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+ PDPage page = new PDPage();
+ document.addPage(page);
+ try (PDPageContentStream cs = new PDPageContentStream(document, page)) {
+ cs.beginText();
+ cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f);
+ cs.newLineAtOffset(50, 750);
+ cs.showText("Test-Rechnung");
+ cs.endText();
+ }
+ document.save(baos);
+ return baos.toByteArray();
+ }
+ }
+
+ private byte[] generateKeystore(String alias, String password) {
+ try {
+ KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
+ kpg.initialize(2048);
+ KeyPair keyPair = kpg.generateKeyPair();
+
+ X500Name subject = new X500Name("CN=Votianlt Test " + alias + ",O=Votianlt,C=DE");
+ BigInteger serial = BigInteger.valueOf(System.nanoTime());
+ Date notBefore = Date.from(Instant.now().minus(1, ChronoUnit.DAYS));
+ Date notAfter = Date.from(Instant.now().plus(365, ChronoUnit.DAYS));
+
+ JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(subject, serial, notBefore,
+ notAfter, subject, keyPair.getPublic());
+ ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").setProvider("BC")
+ .build(keyPair.getPrivate());
+ X509Certificate certificate = new JcaX509CertificateConverter().setProvider("BC")
+ .getCertificate(certBuilder.build(signer));
+
+ KeyStore keystore = KeyStore.getInstance("PKCS12");
+ keystore.load(null, null);
+ keystore.setKeyEntry(alias, keyPair.getPrivate(), password.toCharArray(),
+ new Certificate[] { certificate });
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ keystore.store(baos, password.toCharArray());
+ return baos.toByteArray();
+ } catch (Exception ex) {
+ throw new IllegalStateException("Test-Keystore konnte nicht erzeugt werden: " + ex.getMessage(), ex);
+ }
+ }
+
+ private CustomerInvoice sampleInvoice() {
+ CustomerInvoice invoice = new CustomerInvoice();
+ invoice.setUserId(USER_ID);
+ invoice.setInvoiceNumber("R-2026-0001");
+ invoice.setInvoiceDate(LocalDate.now());
+ invoice.setDeliveryDate(LocalDate.now());
+ invoice.setSenderName("Votianlt Test GmbH");
+ invoice.setSenderAddress("Teststraße 1");
+ invoice.setSenderPostcode("12345");
+ invoice.setSenderCity("Berlin");
+ invoice.setSenderCountry("DE");
+ invoice.setRecipientName("Empfänger AG");
+ invoice.setRecipientAddress("Kundenweg 2");
+ invoice.setRecipientPostcode("54321");
+ invoice.setRecipientCity("Hamburg");
+ invoice.setRecipientCountry("DE");
+ invoice.setDescription("Beratungsleistung");
+ invoice.setVatRate(new BigDecimal("0.19"));
+ invoice.setNetAmount(new BigDecimal("100.00"));
+ invoice.setVatAmount(new BigDecimal("19.00"));
+ invoice.setTotalAmount(new BigDecimal("119.00"));
+ CustomerInvoiceItem item = new CustomerInvoiceItem(BigDecimal.ONE, "h", "Beratung", new BigDecimal("100.00"),
+ new BigDecimal("0.19"));
+ invoice.setItems(List.of(item));
+ return invoice;
+ }
+}
diff --git a/backend/src/test/java/de/assecutor/votianlt/service/InvoiceComplianceValidatorTest.java b/backend/src/test/java/de/assecutor/votianlt/service/InvoiceComplianceValidatorTest.java
new file mode 100644
index 0000000..0f58cd1
--- /dev/null
+++ b/backend/src/test/java/de/assecutor/votianlt/service/InvoiceComplianceValidatorTest.java
@@ -0,0 +1,304 @@
+package de.assecutor.votianlt.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.catchThrowableOfType;
+
+import de.assecutor.votianlt.model.invoices.CustomerInvoice;
+import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
+import de.assecutor.votianlt.model.invoices.InvoiceType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Pflichtangaben-Validierung nach § 14 UStG. Jeder Test mutiert exakt eine
+ * Eigenschaft der Referenzrechnung — so bleibt sichtbar, welche Regel gerade
+ * geprüft wird, und die Tests fungieren gleichzeitig als ausführbare
+ * Spezifikation.
+ */
+class InvoiceComplianceValidatorTest {
+
+ private InvoiceComplianceValidator validator;
+
+ @BeforeEach
+ void setUp() {
+ validator = new InvoiceComplianceValidator();
+ }
+
+ @Test
+ void acceptsCompleteInvoice() {
+ CustomerInvoice invoice = validInvoice();
+ validator.validateForIssuance(invoice);
+ }
+
+ @Test
+ void rejectsMissingInvoiceNumber() {
+ CustomerInvoice invoice = validInvoice();
+ invoice.setInvoiceNumber(" ");
+ assertSingleViolation(invoice, "Rechnungsnummer fehlt");
+ }
+
+ @Test
+ void rejectsMissingInvoiceDate() {
+ CustomerInvoice invoice = validInvoice();
+ invoice.setInvoiceDate(null);
+ assertSingleViolation(invoice, "Rechnungsdatum");
+ }
+
+ @Test
+ void rejectsMissingDeliveryDate() {
+ CustomerInvoice invoice = validInvoice();
+ invoice.setDeliveryDate(null);
+ assertSingleViolation(invoice, "Leistungsdatum");
+ }
+
+ @Test
+ void rejectsMissingSenderName() {
+ CustomerInvoice invoice = validInvoice();
+ invoice.setSenderName(null);
+ assertSingleViolation(invoice, "Name des Leistenden");
+ }
+
+ @Test
+ void rejectsIncompleteSenderAddress() {
+ CustomerInvoice invoice = validInvoice();
+ invoice.setSenderPostcode("");
+ assertSingleViolation(invoice, "Anschrift des Leistenden");
+ }
+
+ @Test
+ void rejectsMissingSenderTaxIdentification() {
+ CustomerInvoice invoice = validInvoice();
+ invoice.setSenderTaxNumber(null);
+ invoice.setSenderVatId(null);
+ assertSingleViolation(invoice, "Steuernummer oder USt-IdNr");
+ }
+
+ @Test
+ void acceptsSenderWithOnlyTaxNumber() {
+ CustomerInvoice invoice = validInvoice();
+ invoice.setSenderVatId(null);
+ validator.validateForIssuance(invoice);
+ }
+
+ @Test
+ void acceptsSenderWithOnlyVatId() {
+ CustomerInvoice invoice = validInvoice();
+ invoice.setSenderTaxNumber(null);
+ validator.validateForIssuance(invoice);
+ }
+
+ @Test
+ void rejectsMissingRecipientName() {
+ CustomerInvoice invoice = validInvoice();
+ invoice.setRecipientName(null);
+ assertSingleViolation(invoice, "Name des Leistungsempfängers");
+ }
+
+ @Test
+ void rejectsIncompleteRecipientAddress() {
+ CustomerInvoice invoice = validInvoice();
+ invoice.setRecipientCity(null);
+ assertSingleViolation(invoice, "Anschrift des Leistungsempfängers");
+ }
+
+ @Test
+ void rejectsEmptyItems() {
+ CustomerInvoice invoice = validInvoice();
+ invoice.setItems(new ArrayList<>());
+ assertSingleViolation(invoice, "Keine Positionen erfasst");
+ }
+
+ @Test
+ void rejectsItemWithoutDescription() {
+ CustomerInvoice invoice = validInvoice();
+ invoice.getItems().get(0).setDescription("");
+ assertSingleViolation(invoice, "Bezeichnung der Leistung fehlt");
+ }
+
+ @Test
+ void rejectsItemWithZeroQuantity() {
+ CustomerInvoice invoice = validInvoice();
+ CustomerInvoiceItem item = invoice.getItems().get(0);
+ item.setQuantity(BigDecimal.ZERO);
+ // Zwingt netTotal = 0, damit nur die Mengenregel anschlägt und nicht zusätzlich
+ // die Konsistenzprüfung der Summen.
+ item.setNetTotal(BigDecimal.ZERO);
+ invoice.setNetAmount(BigDecimal.ZERO);
+ invoice.setVatAmount(BigDecimal.ZERO);
+ invoice.setTotalAmount(BigDecimal.ZERO);
+ // VAT-Hinweis muss bei 0 % aber gesetzt sein, sonst lösen wir zwei Verstöße aus.
+ invoice.setVatRate(new BigDecimal("0.19"));
+ assertSingleViolation(invoice, "Menge muss größer 0");
+ }
+
+ @Test
+ void rejectsItemWithNegativeUnitPrice() {
+ CustomerInvoice invoice = validInvoice();
+ invoice.getItems().get(0).setUnitPrice(new BigDecimal("-5.00"));
+ InvoiceComplianceException ex = catchThrowableOfType(
+ () -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
+ assertThat(ex).isNotNull();
+ assertThat(ex.getViolations()).anyMatch(v -> v.contains("Einzelpreis"));
+ }
+
+ @Test
+ void rejectsInconsistentTotals() {
+ CustomerInvoice invoice = validInvoice();
+ invoice.setTotalAmount(new BigDecimal("999.99"));
+ InvoiceComplianceException ex = catchThrowableOfType(
+ () -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
+ assertThat(ex).isNotNull();
+ assertThat(ex.getViolations()).anyMatch(v -> v.contains("Bruttobetrag passt nicht"));
+ }
+
+ @Test
+ void rejectsItemSumMismatchingNet() {
+ CustomerInvoice invoice = validInvoice();
+ // Items summieren sich weiterhin auf 100.00, aber das deklarierte Netto wird verstellt.
+ invoice.setNetAmount(new BigDecimal("80.00"));
+ invoice.setVatAmount(new BigDecimal("15.20"));
+ invoice.setTotalAmount(new BigDecimal("95.20"));
+ InvoiceComplianceException ex = catchThrowableOfType(
+ () -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
+ assertThat(ex).isNotNull();
+ assertThat(ex.getViolations()).anyMatch(v -> v.contains("Summe der Positionen"));
+ }
+
+ @Test
+ void rejectsMissingVatRate() {
+ CustomerInvoice invoice = validInvoice();
+ invoice.setVatRate(null);
+ assertSingleViolation(invoice, "Steuersatz fehlt");
+ }
+
+ @Test
+ void rejectsZeroVatWithoutLegalNotice() {
+ CustomerInvoice invoice = zeroVatInvoice();
+ invoice.setReverseChargeNote(null);
+ invoice.setLegalNotes(null);
+ assertSingleViolation(invoice, "Bei 0 % USt ist ein rechtlicher Hinweis erforderlich");
+ }
+
+ @Test
+ void acceptsZeroVatWithReverseChargeNote() {
+ CustomerInvoice invoice = zeroVatInvoice();
+ invoice.setReverseChargeNote("Steuerschuldnerschaft des Leistungsempfängers (§ 13b UStG).");
+ invoice.setLegalNotes(null);
+ validator.validateForIssuance(invoice);
+ }
+
+ @Test
+ void acceptsZeroVatWithLegalNotes() {
+ CustomerInvoice invoice = zeroVatInvoice();
+ invoice.setReverseChargeNote(null);
+ invoice.setLegalNotes("Kleinunternehmer im Sinne des § 19 Abs. 1 UStG — keine Umsatzsteuer ausgewiesen.");
+ validator.validateForIssuance(invoice);
+ }
+
+ @Test
+ void rejectsMismatchingVatAmountForNonZeroRate() {
+ CustomerInvoice invoice = validInvoice();
+ // Erwartet wären 19,00 € — wir tragen 25,00 € ein.
+ invoice.setVatAmount(new BigDecimal("25.00"));
+ invoice.setTotalAmount(new BigDecimal("125.00"));
+ InvoiceComplianceException ex = catchThrowableOfType(
+ () -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
+ assertThat(ex).isNotNull();
+ assertThat(ex.getViolations()).anyMatch(v -> v.contains("Steuerbetrag"));
+ }
+
+ @Test
+ void collectsAllViolationsInOnePass() {
+ CustomerInvoice invoice = validInvoice();
+ invoice.setInvoiceNumber(null);
+ invoice.setSenderName(null);
+ invoice.setItems(new ArrayList<>());
+ InvoiceComplianceException ex = catchThrowableOfType(
+ () -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
+ assertThat(ex).isNotNull();
+ assertThat(ex.getViolations())
+ .as("Validator soll alle Verstöße sammeln, nicht beim ersten abbrechen")
+ .hasSizeGreaterThanOrEqualTo(3);
+ }
+
+ @Test
+ void cancellationIsNotValidatedHere() {
+ CustomerInvoice invoice = validInvoice();
+ invoice.setType(InvoiceType.CANCELLATION);
+ invoice.setItems(new ArrayList<>()); // wäre für reguläre Rechnung ein Fehler
+ validator.validateForIssuance(invoice);
+ }
+
+ @Test
+ void correctionIsNotValidatedHere() {
+ CustomerInvoice invoice = validInvoice();
+ invoice.setType(InvoiceType.CORRECTION);
+ invoice.setSenderName(null); // wäre für reguläre Rechnung ein Fehler
+ validator.validateForIssuance(invoice);
+ }
+
+ @Test
+ void nullInvoiceIsRejectedDirectly() {
+ assertThatThrownBy(() -> validator.validateForIssuance(null))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ private void assertSingleViolation(CustomerInvoice invoice, String fragment) {
+ InvoiceComplianceException ex = catchThrowableOfType(
+ () -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
+ assertThat(ex).as("erwartete InvoiceComplianceException").isNotNull();
+ assertThat(ex.getViolations())
+ .as("Verstoß mit Fragment '%s' erwartet, war: %s", fragment, ex.getViolations())
+ .anyMatch(v -> v.contains(fragment));
+ }
+
+ private CustomerInvoice validInvoice() {
+ CustomerInvoice invoice = new CustomerInvoice();
+ invoice.setType(InvoiceType.INVOICE);
+ invoice.setInvoiceNumber("R-2026-0001");
+ invoice.setInvoiceDate(LocalDate.of(2026, 5, 3));
+ invoice.setDeliveryDate(LocalDate.of(2026, 5, 3));
+
+ invoice.setSenderName("Votianlt Test GmbH");
+ invoice.setSenderAddress("Teststraße 1");
+ invoice.setSenderPostcode("12345");
+ invoice.setSenderCity("Berlin");
+ invoice.setSenderCountry("DE");
+ invoice.setSenderTaxNumber("12/345/67890");
+ invoice.setSenderVatId("DE123456789");
+
+ invoice.setRecipientName("Empfänger AG");
+ invoice.setRecipientAddress("Kundenweg 2");
+ invoice.setRecipientPostcode("54321");
+ invoice.setRecipientCity("Hamburg");
+ invoice.setRecipientCountry("DE");
+
+ CustomerInvoiceItem item = new CustomerInvoiceItem(BigDecimal.ONE, "h", "Beratung",
+ new BigDecimal("100.00"), new BigDecimal("0.19"));
+ invoice.setItems(new ArrayList<>(List.of(item)));
+
+ invoice.setNetAmount(new BigDecimal("100.00"));
+ invoice.setVatRate(new BigDecimal("0.19"));
+ invoice.setVatAmount(new BigDecimal("19.00"));
+ invoice.setTotalAmount(new BigDecimal("119.00"));
+ return invoice;
+ }
+
+ private CustomerInvoice zeroVatInvoice() {
+ CustomerInvoice invoice = validInvoice();
+ invoice.setVatRate(BigDecimal.ZERO);
+ invoice.setVatAmount(BigDecimal.ZERO);
+ invoice.setTotalAmount(invoice.getNetAmount());
+ CustomerInvoiceItem item = invoice.getItems().get(0);
+ item.setVatRate(BigDecimal.ZERO);
+ item.setVatAmount(BigDecimal.ZERO);
+ item.setGrossTotal(item.getNetTotal());
+ return invoice;
+ }
+}
diff --git a/backend/src/test/java/de/assecutor/votianlt/service/InvoiceNumberAuditServiceTest.java b/backend/src/test/java/de/assecutor/votianlt/service/InvoiceNumberAuditServiceTest.java
new file mode 100644
index 0000000..3249288
--- /dev/null
+++ b/backend/src/test/java/de/assecutor/votianlt/service/InvoiceNumberAuditServiceTest.java
@@ -0,0 +1,171 @@
+package de.assecutor.votianlt.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import de.assecutor.votianlt.model.invoices.CustomerInvoice;
+import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation;
+import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus;
+import de.assecutor.votianlt.repository.InvoiceNumberReservationRepository;
+import org.bson.types.ObjectId;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Optional;
+
+@ExtendWith(MockitoExtension.class)
+class InvoiceNumberAuditServiceTest {
+
+ @Mock
+ private InvoiceNumberReservationRepository repository;
+
+ private InvoiceNumberAuditService service;
+
+ private final ObjectId userId = new ObjectId();
+
+ @BeforeEach
+ void setUp() {
+ service = new InvoiceNumberAuditService(repository);
+ }
+
+ @Test
+ void markUsedTransitionsExistingReservation() {
+ InvoiceNumberReservation reservation = reservation("R-2026-000010", 10L,
+ InvoiceNumberReservationStatus.RESERVED);
+ when(repository.findByUserIdAndNumber(userId, "R-2026-000010")).thenReturn(Optional.of(reservation));
+ when(repository.save(any(InvoiceNumberReservation.class))).thenAnswer(inv -> inv.getArgument(0));
+
+ CustomerInvoice invoice = invoice("R-2026-000010", "invoice-id-42");
+ service.markUsed(invoice);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(InvoiceNumberReservation.class);
+ verify(repository).save(captor.capture());
+ InvoiceNumberReservation saved = captor.getValue();
+ assertThat(saved.getStatus()).isEqualTo(InvoiceNumberReservationStatus.USED);
+ assertThat(saved.getInvoiceId()).isEqualTo("invoice-id-42");
+ assertThat(saved.getUsedAt()).isNotNull();
+ }
+
+ @Test
+ void markUsedBootstrapsReservationForLegacyInvoiceWithoutPriorReservation() {
+ when(repository.findByUserIdAndNumber(userId, "RE-2024-0007")).thenReturn(Optional.empty());
+ when(repository.save(any(InvoiceNumberReservation.class))).thenAnswer(inv -> inv.getArgument(0));
+
+ service.markUsed(invoice("RE-2024-0007", "legacy-invoice"));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(InvoiceNumberReservation.class);
+ verify(repository).save(captor.capture());
+ InvoiceNumberReservation saved = captor.getValue();
+ assertThat(saved.getStatus()).isEqualTo(InvoiceNumberReservationStatus.USED);
+ assertThat(saved.getNumber()).isEqualTo("RE-2024-0007");
+ assertThat(saved.getSequence()).isEqualTo(7L);
+ assertThat(saved.getReservedBy()).contains("bootstrap");
+ }
+
+ @Test
+ void markUsedSwallowsRepositoryFailures() {
+ when(repository.findByUserIdAndNumber(any(), any())).thenThrow(new RuntimeException("Mongo down"));
+ // Erwartung: keine Exception nach außen — Festschreiben darf an Audit nicht scheitern.
+ service.markUsed(invoice("R-2026-1", "i-1"));
+ }
+
+ @Test
+ void markUsedIgnoresInvoiceWithoutNumberOrUserId() {
+ CustomerInvoice missingNumber = new CustomerInvoice();
+ missingNumber.setUserId(userId.toHexString());
+ service.markUsed(missingNumber);
+
+ CustomerInvoice missingUser = new CustomerInvoice();
+ missingUser.setInvoiceNumber("R-1");
+ service.markUsed(missingUser);
+
+ verify(repository, never()).findByUserIdAndNumber(any(), any());
+ verify(repository, never()).save(any());
+ }
+
+ @Test
+ void markVoidedRequiresReason() {
+ assertThatThrownBy(() -> service.markVoided(userId, "R-1", " "))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Grund");
+ assertThatThrownBy(() -> service.markVoided(userId, "R-1", null))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void markVoidedTransitionsReservedToVoided() {
+ InvoiceNumberReservation reservation = reservation("R-2026-000005", 5L,
+ InvoiceNumberReservationStatus.RESERVED);
+ when(repository.findByUserIdAndNumber(userId, "R-2026-000005")).thenReturn(Optional.of(reservation));
+ when(repository.save(any(InvoiceNumberReservation.class))).thenAnswer(inv -> inv.getArgument(0));
+
+ service.markVoided(userId, "R-2026-000005", "Versehentlich vergeben, Kunde widerrufen");
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(InvoiceNumberReservation.class);
+ verify(repository).save(captor.capture());
+ InvoiceNumberReservation saved = captor.getValue();
+ assertThat(saved.getStatus()).isEqualTo(InvoiceNumberReservationStatus.VOIDED);
+ assertThat(saved.getVoidReason()).isEqualTo("Versehentlich vergeben, Kunde widerrufen");
+ assertThat(saved.getVoidedAt()).isNotNull();
+ }
+
+ @Test
+ void markVoidedRefusesToOverwriteUsedReservation() {
+ InvoiceNumberReservation reservation = reservation("R-2026-000005", 5L,
+ InvoiceNumberReservationStatus.USED);
+ when(repository.findByUserIdAndNumber(userId, "R-2026-000005")).thenReturn(Optional.of(reservation));
+
+ assertThatThrownBy(() -> service.markVoided(userId, "R-2026-000005", "Test"))
+ .isInstanceOf(InvoiceLifecycleException.class)
+ .hasMessageContaining("ausgestellten Rechnung");
+ }
+
+ @Test
+ void markVoidedFailsWhenNoReservationFound() {
+ when(repository.findByUserIdAndNumber(userId, "R-NOPE")).thenReturn(Optional.empty());
+
+ assertThatThrownBy(() -> service.markVoided(userId, "R-NOPE", "irrelevant"))
+ .isInstanceOf(InvoiceLifecycleException.class)
+ .hasMessageContaining("Keine Reservierung");
+ }
+
+ @Test
+ void findUnusedReturnsOnlyReservedAndVoided() {
+ when(repository.findByUserIdOrderBySequenceAsc(userId)).thenReturn(List.of(
+ reservation("R-1", 1L, InvoiceNumberReservationStatus.USED),
+ reservation("R-2", 2L, InvoiceNumberReservationStatus.RESERVED),
+ reservation("R-3", 3L, InvoiceNumberReservationStatus.USED),
+ reservation("R-4", 4L, InvoiceNumberReservationStatus.VOIDED)));
+
+ List unused = service.findUnused(userId);
+
+ assertThat(unused).extracting(InvoiceNumberReservation::getNumber).containsExactly("R-2", "R-4");
+ }
+
+ private CustomerInvoice invoice(String number, String invoiceId) {
+ CustomerInvoice invoice = new CustomerInvoice();
+ invoice.setId(invoiceId);
+ invoice.setInvoiceNumber(number);
+ invoice.setUserId(userId.toHexString());
+ return invoice;
+ }
+
+ private InvoiceNumberReservation reservation(String number, long sequence,
+ InvoiceNumberReservationStatus status) {
+ InvoiceNumberReservation reservation = new InvoiceNumberReservation();
+ reservation.setUserId(userId);
+ reservation.setNumber(number);
+ reservation.setSequence(sequence);
+ reservation.setStatus(status);
+ return reservation;
+ }
+}