From 31b18e1f5295411181345614da9c61908407162c Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Fri, 29 May 2026 08:43:13 +0200 Subject: [PATCH] refactor: E-Rechnungs-Modul, Freigabe-Workflow und DATEV-Export entfernt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entfernt ZUGFeRD/Factur-X-Anreicherung (Mustangproject), PAdES-Signatur (BouncyCastle/DSS) inkl. nutzerseitiger Keystore-Verwaltung, den Approval-Workflow für Storno-/Berichtigungsbelege sowie den DATEV-CSV-Export. Navigation kehrt zur klassischen Rechnungsansicht zurück; Version auf 0.9.17. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/pom.xml | 42 +- .../votianlt/config/EInvoiceProperties.java | 181 ------- .../config/EInvoiceSecurityInitializer.java | 118 ----- .../de/assecutor/votianlt/model/User.java | 25 - .../model/invoices/CustomerInvoice.java | 38 -- .../model/invoices/EInvoiceFormat.java | 12 - .../model/invoices/InvoiceApprovalAction.java | 11 - .../invoices/InvoiceApprovalRequest.java | 179 ------- .../model/invoices/InvoiceApprovalStatus.java | 5 - .../invoices/UserSigningCredentials.java | 163 ------ .../ui/component/SigningCredentialsPanel.java | 255 ---------- .../pages/base/ui/view/MainLayout.java | 6 +- .../votianlt/pages/view/ApprovalsView.java | 205 -------- .../pages/view/CreateInvoiceView.java | 26 +- .../votianlt/pages/view/DatevExportView.java | 117 ----- .../votianlt/pages/view/EditProfileView.java | 33 +- .../votianlt/pages/view/InvoicesView.java | 91 +--- .../votianlt/pages/view/MyInvoicesView.java | 9 +- .../votianlt/pages/view/ShowJobsView.java | 38 +- .../InvoiceApprovalRequestRepository.java | 19 - .../UserSigningCredentialsRepository.java | 15 - .../votianlt/service/DatevExportService.java | 230 --------- .../votianlt/service/EInvoiceService.java | 472 ------------------ .../service/InvoiceApprovalService.java | 219 -------- .../service/InvoiceLifecycleService.java | 42 +- .../service/InvoicePermissionService.java | 24 - .../service/SigningCredentialsService.java | 237 --------- .../src/main/resources/application.properties | 32 +- .../src/main/resources/messages_de.properties | 65 --- .../src/main/resources/messages_en.properties | 65 --- .../service/DatevExportServiceTest.java | 173 ------- .../EInvoiceServiceDssValidationTest.java | 271 ---------- .../votianlt/service/EInvoiceServiceTest.java | 331 ------------ 33 files changed, 56 insertions(+), 3693 deletions(-) delete mode 100644 backend/src/main/java/de/assecutor/votianlt/config/EInvoiceProperties.java delete mode 100644 backend/src/main/java/de/assecutor/votianlt/config/EInvoiceSecurityInitializer.java delete mode 100644 backend/src/main/java/de/assecutor/votianlt/model/invoices/EInvoiceFormat.java delete mode 100644 backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceApprovalAction.java delete mode 100644 backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceApprovalRequest.java delete mode 100644 backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceApprovalStatus.java delete mode 100644 backend/src/main/java/de/assecutor/votianlt/model/invoices/UserSigningCredentials.java delete mode 100644 backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/SigningCredentialsPanel.java delete mode 100644 backend/src/main/java/de/assecutor/votianlt/pages/view/ApprovalsView.java delete mode 100644 backend/src/main/java/de/assecutor/votianlt/pages/view/DatevExportView.java delete mode 100644 backend/src/main/java/de/assecutor/votianlt/repository/InvoiceApprovalRequestRepository.java delete mode 100644 backend/src/main/java/de/assecutor/votianlt/repository/UserSigningCredentialsRepository.java delete mode 100644 backend/src/main/java/de/assecutor/votianlt/service/DatevExportService.java delete mode 100644 backend/src/main/java/de/assecutor/votianlt/service/EInvoiceService.java delete mode 100644 backend/src/main/java/de/assecutor/votianlt/service/InvoiceApprovalService.java delete mode 100644 backend/src/main/java/de/assecutor/votianlt/service/SigningCredentialsService.java delete mode 100644 backend/src/test/java/de/assecutor/votianlt/service/DatevExportServiceTest.java delete mode 100644 backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceDssValidationTest.java delete mode 100644 backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceTest.java diff --git a/backend/pom.xml b/backend/pom.xml index 0d872ec..72b829f 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -11,7 +11,7 @@ jar - 0.9.16 + 0.9.17 21 21 21 @@ -184,20 +184,6 @@ 5.0.5 - - - org.bouncycastle - bcpkix-jdk18on - 1.78 - - - - - org.mustangproject - library - 2.16.0 - - org.springframework.ai @@ -222,32 +208,6 @@ test - - - eu.europa.ec.joinup.sd-dss - dss-pades-pdfbox - 6.2 - test - - - eu.europa.ec.joinup.sd-dss - dss-validation - 6.2 - test - - - eu.europa.ec.joinup.sd-dss - dss-utils-apache-commons - 6.2 - test - - - eu.europa.ec.joinup.sd-dss - dss-crl-parser-x509crl - 6.2 - test - - diff --git a/backend/src/main/java/de/assecutor/votianlt/config/EInvoiceProperties.java b/backend/src/main/java/de/assecutor/votianlt/config/EInvoiceProperties.java deleted file mode 100644 index 6d76c9b..0000000 --- a/backend/src/main/java/de/assecutor/votianlt/config/EInvoiceProperties.java +++ /dev/null @@ -1,181 +0,0 @@ -package de.assecutor.votianlt.config; - -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -/** - * Konfiguration für ZUGFeRD-Anreicherung und PAdES-Signatur. - * - * Wird über {@code application.properties} mit dem Präfix - * {@code votianlt.einvoice} gesetzt. Beispiel: - * - *
- * votianlt.einvoice.enabled=true
- * votianlt.einvoice.profile=EN16931
- * votianlt.einvoice.signing.enabled=true
- * votianlt.einvoice.signing.keystore-path=/etc/votianlt/keystore.p12
- * votianlt.einvoice.signing.keystore-password=changeit
- * votianlt.einvoice.signing.key-alias=invoice-signer
- * votianlt.einvoice.signing.reason=Rechnung
- * votianlt.einvoice.signing.location=Berlin
- * 
- * - * Sind die Werte nicht gesetzt, bleibt die Funktion deaktiviert. Die Nutzer- - * Einstellung {@code User.eInvoiceEnabled} entscheidet zusätzlich pro Konto, - * ob die Anreicherung tatsächlich angewendet wird. - */ -@Component -@ConfigurationProperties(prefix = "votianlt.einvoice") -public class EInvoiceProperties { - - /** Globale Aktivierung der ZUGFeRD-Anreicherung. */ - private boolean enabled = false; - - /** ZUGFeRD-Profil (BASIC, COMFORT/EN16931, EXTENDED, XRECHNUNG). Default: EN16931. */ - private String profile = "EN16931"; - - private final Signing signing = new Signing(); - - public boolean isEnabled() { - return enabled; - } - - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } - - public String getProfile() { - return profile; - } - - public void setProfile(String profile) { - this.profile = profile; - } - - public Signing getSigning() { - return signing; - } - - public static class Signing { - - /** Aktivierung der PAdES-Signatur. */ - private boolean enabled = false; - - /** Pfad zum PKCS#12-Keystore. */ - private String keystorePath; - - /** Passwort des Keystores (und des Schlüssels). */ - private String keystorePassword; - - /** Alias des zu verwendenden Schlüssels im Keystore. */ - private String keyAlias; - - /** Optionaler Anzeigegrund der Signatur. */ - private String reason = "Rechnung"; - - /** Optionaler Anzeigeort der Signatur. */ - private String location = ""; - - /** Optionaler Kontakt der Signatur. */ - private String contact = ""; - - /** - * Master-Key (mind. 16 Zeichen) zum Verschlüsseln nutzerseitig hinterlegter - * PKCS#12-Keystores in der Datenbank. Wird via SHA-256 zu einem 256-Bit-AES-Schlüssel - * abgeleitet. Verlust dieses Keys macht alle gespeicherten Nutzer-Keystores unbrauchbar. - * - * Empfehlung: Wert per ENV oder via {@link #masterKeyFile} bereitstellen — niemals - * inline in der ausgelieferten {@code application.properties}. - */ - private String masterKey = ""; - - /** - * Optionaler Pfad zu einer Datei, deren Inhalt als Master-Key verwendet wird - * (Docker-/K8s-Secret-Style). Wenn gesetzt und lesbar, hat diese Datei Vorrang - * vor {@link #masterKey}. Empfohlen für produktive Deployments. - */ - private String masterKeyFile = ""; - - public boolean isEnabled() { - return enabled; - } - - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } - - public String getKeystorePath() { - return keystorePath; - } - - public void setKeystorePath(String keystorePath) { - this.keystorePath = keystorePath; - } - - public String getKeystorePassword() { - return keystorePassword; - } - - public void setKeystorePassword(String keystorePassword) { - this.keystorePassword = keystorePassword; - } - - public String getKeyAlias() { - return keyAlias; - } - - public void setKeyAlias(String keyAlias) { - this.keyAlias = keyAlias; - } - - public String getReason() { - return reason; - } - - public void setReason(String reason) { - this.reason = reason; - } - - public String getLocation() { - return location; - } - - public void setLocation(String location) { - this.location = location; - } - - public String getContact() { - return contact; - } - - public void setContact(String contact) { - this.contact = contact; - } - - public String getMasterKey() { - return masterKey; - } - - public void setMasterKey(String masterKey) { - this.masterKey = masterKey; - } - - public String getMasterKeyFile() { - return masterKeyFile; - } - - public void setMasterKeyFile(String masterKeyFile) { - this.masterKeyFile = masterKeyFile; - } - - public boolean isFullyConfigured() { - return enabled && keystorePath != null && !keystorePath.isBlank() - && keystorePassword != null && keyAlias != null && !keyAlias.isBlank(); - } - - /** Liefert {@code true}, wenn ein nutzbarer Master-Key gesetzt ist. */ - public boolean hasMasterKey() { - return masterKey != null && masterKey.length() >= 16; - } - } -} diff --git a/backend/src/main/java/de/assecutor/votianlt/config/EInvoiceSecurityInitializer.java b/backend/src/main/java/de/assecutor/votianlt/config/EInvoiceSecurityInitializer.java deleted file mode 100644 index ed3f91e..0000000 --- a/backend/src/main/java/de/assecutor/votianlt/config/EInvoiceSecurityInitializer.java +++ /dev/null @@ -1,118 +0,0 @@ -package de.assecutor.votianlt.config; - -import jakarta.annotation.PostConstruct; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Component; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.attribute.PosixFilePermission; -import java.nio.file.attribute.PosixFilePermissions; -import java.util.Set; - -/** - * Stufe 2/3 der E-Rechnungs-Sicherheit: - * - *
    - *
  • Lädt den Master-Key beim Startup aus einer optional konfigurierten Secret-Datei - * (Docker-/K8s-Secrets-Style). Inhalt wird einmalig in {@code Signing.masterKey} - * gespiegelt – die Datei wird zur Laufzeit nicht erneut gelesen.
  • - *
  • Prüft die Dateiberechtigungen und warnt, wenn die Datei für andere Nutzer lesbar - * oder schreibbar ist (POSIX-Filesystems).
  • - *
  • Loggt einen kompakten Sicherheits-Banner mit Konfigurations-Quelle, ohne den - * Inhalt des Keys zu offenbaren.
  • - *
- * - * Der Master-Key kann auf drei Arten gesetzt werden, in Vorrang-Reihenfolge: - *
    - *
  1. {@code votianlt.einvoice.signing.master-key-file} — Pfad zu einer Secret-Datei
  2. - *
  3. {@code votianlt.einvoice.signing.master-key} — direkt gesetzte Property - * (z.B. via Spring-Placeholder {@code ${VOTIANLT_EINVOICE_MASTER_KEY}})
  4. - *
  5. nichts gesetzt — Funktion deaktiviert, gespeicherte Keystores bleiben unentschlüsselbar
  6. - *
- */ -@Component -public class EInvoiceSecurityInitializer { - - private static final Logger log = LoggerFactory.getLogger(EInvoiceSecurityInitializer.class); - - private final EInvoiceProperties properties; - - public EInvoiceSecurityInitializer(EInvoiceProperties properties) { - this.properties = properties; - } - - @PostConstruct - void initialize() { - EInvoiceProperties.Signing signing = properties.getSigning(); - String source = "(nicht gesetzt)"; - - String fileFromConfig = signing.getMasterKeyFile(); - if (fileFromConfig != null && !fileFromConfig.isBlank()) { - Path path = Path.of(fileFromConfig); - if (!Files.exists(path)) { - log.error("E-Invoice: Master-Key-Datei nicht gefunden: {}", path); - } else if (!Files.isReadable(path)) { - log.error("E-Invoice: Master-Key-Datei nicht lesbar: {}", path); - } else { - try { - String content = Files.readString(path).trim(); - if (content.isBlank()) { - log.error("E-Invoice: Master-Key-Datei {} ist leer.", path); - } else { - signing.setMasterKey(content); - source = "Datei " + path; - warnIfPermissionsTooOpen(path); - } - } catch (IOException ex) { - log.error("E-Invoice: Master-Key-Datei {} konnte nicht gelesen werden: {}", path, - ex.getMessage()); - } - } - } else if (signing.getMasterKey() != null && !signing.getMasterKey().isBlank()) { - source = "Property/ENV"; - } - - if (signing.hasMasterKey()) { - log.info("E-Invoice Master-Key aktiv (Quelle: {}, Länge: {} Zeichen).", source, - signing.getMasterKey().length()); - } else { - log.warn("E-Invoice Master-Key nicht konfiguriert — nutzerseitig hinterlegte Signatur-Keystores " - + "können nicht entschlüsselt werden. Setzen Sie votianlt.einvoice.signing.master-key oder " - + "votianlt.einvoice.signing.master-key-file."); - } - } - - /** - * Warnt, wenn die Secret-Datei für Gruppe oder Andere lesbar/schreibbar ist. - * In Container-Deployments (typische Docker/K8s-Secrets, Mode 0444) sind - * group/other-readable Berechtigungen nicht zwingend riskant – der Hinweis - * dient als Auf­merksamkeitssignal für Bare-Metal- oder VM-Setups. - */ - private void warnIfPermissionsTooOpen(Path path) { - try { - Set perms = Files.getPosixFilePermissions(path); - boolean groupReadable = perms.contains(PosixFilePermission.GROUP_READ); - boolean otherReadable = perms.contains(PosixFilePermission.OTHERS_READ); - boolean otherWritable = perms.contains(PosixFilePermission.OTHERS_WRITE); - boolean groupWritable = perms.contains(PosixFilePermission.GROUP_WRITE); - - if (otherWritable || groupWritable) { - log.error("E-Invoice: Master-Key-Datei {} ist für andere Nutzer SCHREIBBAR ({}). " - + "Sofort die Berechtigungen härten (chmod 600).", path, - PosixFilePermissions.toString(perms)); - } else if (otherReadable || groupReadable) { - log.warn("E-Invoice: Master-Key-Datei {} ist für andere Nutzer lesbar ({}). " - + "Empfehlung für Bare-Metal/VM-Deployments: chmod 600. " - + "Im Container ist dies meist akzeptabel.", path, PosixFilePermissions.toString(perms)); - } - } catch (UnsupportedOperationException ex) { - // POSIX-Permissions auf Nicht-Unix (z.B. Windows) nicht verfügbar — Check überspringen. - } catch (IOException ex) { - log.warn("E-Invoice: Berechtigungsprüfung der Master-Key-Datei {} fehlgeschlagen: {}", path, - ex.getMessage()); - } - } -} diff --git a/backend/src/main/java/de/assecutor/votianlt/model/User.java b/backend/src/main/java/de/assecutor/votianlt/model/User.java index aae3077..3f5a5f0 100644 --- a/backend/src/main/java/de/assecutor/votianlt/model/User.java +++ b/backend/src/main/java/de/assecutor/votianlt/model/User.java @@ -72,29 +72,4 @@ public class User { // Umsatzsteuer-Satz (als Dezimalwert, z.B. 0.19 für 19 %) private BigDecimal vatRate = new BigDecimal("0.19"); - - /** - * Optionaler Freigabe-Workflow für kritische Rechnungsvorgänge (R-42). - * - * Ist das Flag aktiv, müssen Storno- und Berichtigungsbelege durch einen Nutzer mit - * {@link de.assecutor.votianlt.security.InvoiceRoles#APPROVER}-Rolle freigegeben werden, - * bevor sie tatsächlich erzeugt werden. - */ - private boolean requireApprovalForCriticalInvoiceActions = false; - - /** - * Aktiviert ZUGFeRD/Factur-X-Anreicherung beim Speichern der Rechnungs-PDFs. - * Greift nur, wenn auch systemweit über - * {@link de.assecutor.votianlt.config.EInvoiceProperties} aktiviert ist. - */ - private boolean einvoiceEnabled = false; - - /** - * Aktiviert die digitale PAdES-Signatur der erzeugten Rechnungs-PDFs. - * Funktioniert unabhängig von {@link #einvoiceEnabled}: signierte Standard-PDFs - * sind ebenso möglich wie signierte ZUGFeRD-PDFs. - * Voraussetzung: ein hinterlegtes Nutzer-Zertifikat oder ein systemweit - * konfigurierter Keystore. - */ - private boolean signInvoicesEnabled = false; } diff --git a/backend/src/main/java/de/assecutor/votianlt/model/invoices/CustomerInvoice.java b/backend/src/main/java/de/assecutor/votianlt/model/invoices/CustomerInvoice.java index 33ca4e9..f919d53 100644 --- a/backend/src/main/java/de/assecutor/votianlt/model/invoices/CustomerInvoice.java +++ b/backend/src/main/java/de/assecutor/votianlt/model/invoices/CustomerInvoice.java @@ -41,12 +41,6 @@ public class CustomerInvoice { private BigDecimal paidAmount; private LocalDateTime lastPaymentAt; - // E-Rechnung / Signatur-Marker (Mustangproject + iText sign) - private EInvoiceFormat eInvoiceFormat = EInvoiceFormat.NONE; - private boolean signed = false; - private LocalDateTime signedAt; - private String signedBy; - // Pflichtangaben nach §14 UStG (German VAT law) private String invoiceNumber; // Fortlaufende Rechnungsnummer private LocalDate invoiceDate; // Rechnungsdatum @@ -537,36 +531,4 @@ public class CustomerInvoice { public void setLastPaymentAt(LocalDateTime lastPaymentAt) { this.lastPaymentAt = lastPaymentAt; } - - public EInvoiceFormat getEInvoiceFormat() { - return eInvoiceFormat; - } - - public void setEInvoiceFormat(EInvoiceFormat eInvoiceFormat) { - this.eInvoiceFormat = eInvoiceFormat; - } - - public boolean isSigned() { - return signed; - } - - public void setSigned(boolean signed) { - this.signed = signed; - } - - public LocalDateTime getSignedAt() { - return signedAt; - } - - public void setSignedAt(LocalDateTime signedAt) { - this.signedAt = signedAt; - } - - public String getSignedBy() { - return signedBy; - } - - public void setSignedBy(String signedBy) { - this.signedBy = signedBy; - } } diff --git a/backend/src/main/java/de/assecutor/votianlt/model/invoices/EInvoiceFormat.java b/backend/src/main/java/de/assecutor/votianlt/model/invoices/EInvoiceFormat.java deleted file mode 100644 index 2ab77f2..0000000 --- a/backend/src/main/java/de/assecutor/votianlt/model/invoices/EInvoiceFormat.java +++ /dev/null @@ -1,12 +0,0 @@ -package de.assecutor.votianlt.model.invoices; - -/** - * Markiert, in welchem E-Rechnung-Format eine gespeicherte Rechnung vorliegt. - * - * NONE – reines, nicht maschinenlesbares PDF. - * ZUGFERD – PDF/A-3 mit eingebetteter ZUGFeRD/Factur-X/EN16931-XML. - * XRECHNUNG – PDF/A-3 mit eingebetteter XRechnung-XML (UN/CEFACT-Profil). - */ -public enum EInvoiceFormat { - NONE, ZUGFERD, XRECHNUNG -} diff --git a/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceApprovalAction.java b/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceApprovalAction.java deleted file mode 100644 index 1290e9b..0000000 --- a/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceApprovalAction.java +++ /dev/null @@ -1,11 +0,0 @@ -package de.assecutor.votianlt.model.invoices; - -/** - * Aktionstyp eines {@link InvoiceApprovalRequest}. - * - * CANCEL_INVOICE – Storno einer ausgestellten Rechnung (R-17 ff.). - * CORRECT_INVOICE – Berichtigungsbeleg zu einer ausgestellten Rechnung (R-12 ff.). - */ -public enum InvoiceApprovalAction { - CANCEL_INVOICE, CORRECT_INVOICE -} diff --git a/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceApprovalRequest.java b/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceApprovalRequest.java deleted file mode 100644 index d8b00ab..0000000 --- a/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceApprovalRequest.java +++ /dev/null @@ -1,179 +0,0 @@ -package de.assecutor.votianlt.model.invoices; - -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; - -import java.time.LocalDateTime; - -/** - * Freigabe-Anfrage für eine kritische Rechnungsaktion gemäß R-42. - * - * Wird vom Lifecycle-Service angelegt, sobald ein Nutzer ohne - * Approver-Berechtigung mit aktivem {@code requireApprovalForCriticalInvoiceActions}-Flag - * einen Storno oder eine Berichtigung anstößt. - * - * Der Approver führt die Aktion über {@code approve} oder lehnt sie über {@code reject} ab. - * Die eigentliche Erzeugung des Storno-/Berichtigungsbelegs erfolgt erst nach Freigabe. - */ -@Document(collection = "invoiceApprovalRequests") -public class InvoiceApprovalRequest { - - @Id - private String id; - - private InvoiceApprovalAction action; - private InvoiceApprovalStatus status = InvoiceApprovalStatus.PENDING; - - private String targetInvoiceId; - private String targetInvoiceNumber; - - private String requestedByUserId; - private String requestedByDisplayName; - private LocalDateTime requestedAt; - - private String reviewedByUserId; - private String reviewedByDisplayName; - private LocalDateTime reviewedAt; - private String reviewerComment; - - /** Eingegebener Grund (R-36). */ - private String reason; - - /** Bei Berichtigung: Beschreibung der ergänzten/korrigierten Angaben (R-14). */ - private String correctedFields; - - /** Verweis auf den nach Freigabe tatsächlich erzeugten Folgebeleg. */ - private String resultingInvoiceId; - private String resultingInvoiceNumber; - - public InvoiceApprovalRequest() { - } - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public InvoiceApprovalAction getAction() { - return action; - } - - public void setAction(InvoiceApprovalAction action) { - this.action = action; - } - - public InvoiceApprovalStatus getStatus() { - return status; - } - - public void setStatus(InvoiceApprovalStatus status) { - this.status = status; - } - - public String getTargetInvoiceId() { - return targetInvoiceId; - } - - public void setTargetInvoiceId(String targetInvoiceId) { - this.targetInvoiceId = targetInvoiceId; - } - - public String getTargetInvoiceNumber() { - return targetInvoiceNumber; - } - - public void setTargetInvoiceNumber(String targetInvoiceNumber) { - this.targetInvoiceNumber = targetInvoiceNumber; - } - - public String getRequestedByUserId() { - return requestedByUserId; - } - - public void setRequestedByUserId(String requestedByUserId) { - this.requestedByUserId = requestedByUserId; - } - - public String getRequestedByDisplayName() { - return requestedByDisplayName; - } - - public void setRequestedByDisplayName(String requestedByDisplayName) { - this.requestedByDisplayName = requestedByDisplayName; - } - - public LocalDateTime getRequestedAt() { - return requestedAt; - } - - public void setRequestedAt(LocalDateTime requestedAt) { - this.requestedAt = requestedAt; - } - - public String getReviewedByUserId() { - return reviewedByUserId; - } - - public void setReviewedByUserId(String reviewedByUserId) { - this.reviewedByUserId = reviewedByUserId; - } - - public String getReviewedByDisplayName() { - return reviewedByDisplayName; - } - - public void setReviewedByDisplayName(String reviewedByDisplayName) { - this.reviewedByDisplayName = reviewedByDisplayName; - } - - public LocalDateTime getReviewedAt() { - return reviewedAt; - } - - public void setReviewedAt(LocalDateTime reviewedAt) { - this.reviewedAt = reviewedAt; - } - - public String getReviewerComment() { - return reviewerComment; - } - - public void setReviewerComment(String reviewerComment) { - this.reviewerComment = reviewerComment; - } - - public String getReason() { - return reason; - } - - public void setReason(String reason) { - this.reason = reason; - } - - public String getCorrectedFields() { - return correctedFields; - } - - public void setCorrectedFields(String correctedFields) { - this.correctedFields = correctedFields; - } - - public String getResultingInvoiceId() { - return resultingInvoiceId; - } - - public void setResultingInvoiceId(String resultingInvoiceId) { - this.resultingInvoiceId = resultingInvoiceId; - } - - public String getResultingInvoiceNumber() { - return resultingInvoiceNumber; - } - - public void setResultingInvoiceNumber(String resultingInvoiceNumber) { - this.resultingInvoiceNumber = resultingInvoiceNumber; - } -} diff --git a/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceApprovalStatus.java b/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceApprovalStatus.java deleted file mode 100644 index 85fbec1..0000000 --- a/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceApprovalStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package de.assecutor.votianlt.model.invoices; - -public enum InvoiceApprovalStatus { - PENDING, APPROVED, REJECTED -} diff --git a/backend/src/main/java/de/assecutor/votianlt/model/invoices/UserSigningCredentials.java b/backend/src/main/java/de/assecutor/votianlt/model/invoices/UserSigningCredentials.java deleted file mode 100644 index 04c27ed..0000000 --- a/backend/src/main/java/de/assecutor/votianlt/model/invoices/UserSigningCredentials.java +++ /dev/null @@ -1,163 +0,0 @@ -package de.assecutor.votianlt.model.invoices; - -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.index.Indexed; -import org.springframework.data.mongodb.core.mapping.Document; - -import java.time.LocalDateTime; - -/** - * Pro Nutzer hinterlegte Signatur-Credentials für die Erzeugung von signierten - * (PAdES) E-Rechnungen. - * - * Sicherheitshinweise: - *
    - *
  • Der private Schlüssel (komplette PKCS#12-Datei) wird vor dem Speichern - * mit AES-256-GCM unter dem systemweiten Master-Key - * ({@code votianlt.einvoice.signing.master-key}) verschlüsselt.
  • - *
  • Das Keystore-Passwort wird ebenfalls AES-GCM-verschlüsselt persistiert.
  • - *
  • Beide Felder enthalten die Initialisierungs-IV als Präfix - * (12 Byte IV + Ciphertext+Tag).
  • - *
  • Der Master-Key darf nicht verloren gehen — andernfalls sind alle - * hinterlegten Keystores nicht mehr verwendbar und müssen vom Nutzer - * neu hochgeladen werden.
  • - *
- * - * Die Klar-Metadaten (Subject/Issuer/Gültigkeit/Alias) werden unverschlüsselt - * abgelegt, damit das Profil sie ohne Master-Key anzeigen kann (Statussicht - * funktioniert auch nach Schlüsselverlust). - */ -@Document(collection = "userSigningCredentials") -public class UserSigningCredentials { - - @Id - private String id; - - @Indexed(unique = true) - private String userId; - - /** Verschlüsselter PKCS#12-Keystore (AES-GCM, IV-präfiziert). */ - private byte[] encryptedKeystore; - - /** Verschlüsseltes Keystore-Passwort (AES-GCM, IV-präfiziert, Base64). */ - private String encryptedPassword; - - private String keyAlias; - - // Klartext-Metadaten zum Zertifikat (Anzeige im Profil ohne Entschlüsselung) - private String subjectDn; - private String issuerDn; - private String serialNumber; - private LocalDateTime validFrom; - private LocalDateTime validUntil; - - private LocalDateTime createdAt; - private LocalDateTime updatedAt; - - /** Nutzerseitiger Schalter, der die Verwendung der eigenen Credentials aktiviert. */ - private boolean enabled = true; - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getUserId() { - return userId; - } - - public void setUserId(String userId) { - this.userId = userId; - } - - public byte[] getEncryptedKeystore() { - return encryptedKeystore; - } - - public void setEncryptedKeystore(byte[] encryptedKeystore) { - this.encryptedKeystore = encryptedKeystore; - } - - public String getEncryptedPassword() { - return encryptedPassword; - } - - public void setEncryptedPassword(String encryptedPassword) { - this.encryptedPassword = encryptedPassword; - } - - public String getKeyAlias() { - return keyAlias; - } - - public void setKeyAlias(String keyAlias) { - this.keyAlias = keyAlias; - } - - public String getSubjectDn() { - return subjectDn; - } - - public void setSubjectDn(String subjectDn) { - this.subjectDn = subjectDn; - } - - public String getIssuerDn() { - return issuerDn; - } - - public void setIssuerDn(String issuerDn) { - this.issuerDn = issuerDn; - } - - public String getSerialNumber() { - return serialNumber; - } - - public void setSerialNumber(String serialNumber) { - this.serialNumber = serialNumber; - } - - public LocalDateTime getValidFrom() { - return validFrom; - } - - public void setValidFrom(LocalDateTime validFrom) { - this.validFrom = validFrom; - } - - public LocalDateTime getValidUntil() { - return validUntil; - } - - public void setValidUntil(LocalDateTime validUntil) { - this.validUntil = validUntil; - } - - public LocalDateTime getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(LocalDateTime createdAt) { - this.createdAt = createdAt; - } - - public LocalDateTime getUpdatedAt() { - return updatedAt; - } - - public void setUpdatedAt(LocalDateTime updatedAt) { - this.updatedAt = updatedAt; - } - - public boolean isEnabled() { - return enabled; - } - - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } -} diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/SigningCredentialsPanel.java b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/SigningCredentialsPanel.java deleted file mode 100644 index 1816bb1..0000000 --- a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/SigningCredentialsPanel.java +++ /dev/null @@ -1,255 +0,0 @@ -package de.assecutor.votianlt.pages.base.ui.component; - -import com.vaadin.flow.component.button.Button; -import com.vaadin.flow.component.button.ButtonVariant; -import com.vaadin.flow.component.checkbox.Checkbox; -import com.vaadin.flow.component.html.Div; -import com.vaadin.flow.component.html.H3; -import com.vaadin.flow.component.html.Span; -import com.vaadin.flow.component.notification.Notification; -import com.vaadin.flow.component.orderedlayout.HorizontalLayout; -import com.vaadin.flow.component.orderedlayout.VerticalLayout; -import com.vaadin.flow.component.textfield.PasswordField; -import com.vaadin.flow.component.textfield.TextField; -import com.vaadin.flow.component.upload.Upload; -import com.vaadin.flow.component.upload.receivers.MemoryBuffer; -import com.vaadin.flow.i18n.I18NProvider; -import de.assecutor.votianlt.config.EInvoiceProperties; -import de.assecutor.votianlt.model.invoices.UserSigningCredentials; -import de.assecutor.votianlt.service.SigningCredentialsService; - -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.Locale; -import java.util.Optional; -import java.util.function.Function; - -/** - * UI-Panel zur Verwaltung der nutzerseitigen PKCS#12-Signatur-Credentials - * (Phase 5.5 / 5.6 der Rechnungsregeln). - * - * Wird in {@link de.assecutor.votianlt.pages.view.EditProfileView} eingebunden. - * Die Klasse ist absichtlich keine Vaadin-{@code @Route} und keine Spring-Bean — - * sie ist eine gewöhnliche Vaadin-Komponente, die mit den nötigen Services - * konstruiert und vom Profil-View positioniert wird. - */ -public class SigningCredentialsPanel extends VerticalLayout { - - private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY); - - private final SigningCredentialsService credentialsService; - private final EInvoiceProperties properties; - private final String userId; - private final Function tr; - - private final Div statusContainer = new Div(); - - public SigningCredentialsPanel(SigningCredentialsService credentialsService, EInvoiceProperties properties, - String userId, Function translate) { - this.credentialsService = credentialsService; - this.properties = properties; - this.userId = userId; - this.tr = translate; - - setPadding(true); - setSpacing(true); - setWidthFull(); - addClassName("surface-panel"); - getStyle().set("border", "1px solid var(--lumo-contrast-10pct)").set("border-radius", - "var(--lumo-border-radius-l)"); - - H3 heading = new H3(tr.apply("profile.signing.title")); - add(heading); - - Span hint = new Span(tr.apply("profile.signing.hint")); - hint.getStyle().set("color", "var(--lumo-secondary-text-color)") - .set("font-size", "var(--lumo-font-size-s)"); - add(hint); - - if (!properties.getSigning().hasMasterKey()) { - Span masterKeyWarning = new Span(tr.apply("profile.signing.masterkey.missing")); - masterKeyWarning.getStyle().set("color", "var(--lumo-error-text-color)") - .set("font-size", "var(--lumo-font-size-s)"); - add(masterKeyWarning); - } - - add(statusContainer); - renderStatus(); - - add(buildUploadSection()); - } - - private void renderStatus() { - statusContainer.removeAll(); - Optional credentialsOpt = credentialsService.findCredentials(userId); - if (credentialsOpt.isEmpty()) { - Span empty = new Span(tr.apply("profile.signing.none")); - empty.getStyle().set("color", "var(--lumo-secondary-text-color)"); - statusContainer.add(empty); - return; - } - - UserSigningCredentials credentials = credentialsOpt.get(); - Div card = new Div(); - card.getStyle().set("padding", "12px 16px").set("background", "var(--lumo-contrast-5pct)") - .set("border-radius", "var(--lumo-border-radius-m)"); - - addRow(card, tr.apply("profile.signing.metadata.alias"), credentials.getKeyAlias()); - addRow(card, tr.apply("profile.signing.metadata.subject"), credentials.getSubjectDn()); - addRow(card, tr.apply("profile.signing.metadata.issuer"), credentials.getIssuerDn()); - addRow(card, tr.apply("profile.signing.metadata.serial"), credentials.getSerialNumber()); - - String validity = formatDate(credentials.getValidFrom()) + " — " + formatDate(credentials.getValidUntil()); - addRow(card, tr.apply("profile.signing.metadata.validity"), validity); - - LocalDateTime now = LocalDateTime.now(); - if (credentials.getValidUntil() != null) { - if (credentials.getValidUntil().isBefore(now)) { - Span warn = new Span(tr.apply("profile.signing.expired")); - warn.getElement().getThemeList().add("badge"); - warn.getElement().getThemeList().add("error"); - card.add(new Div(warn)); - } else if (credentials.getValidUntil().isBefore(now.plusDays(30))) { - Span warn = new Span(tr.apply("profile.signing.expiring")); - warn.getElement().getThemeList().add("badge"); - warn.getElement().getThemeList().add("warning"); - card.add(new Div(warn)); - } - } - - Checkbox enabledCheckbox = new Checkbox(tr.apply("profile.signing.enabled")); - enabledCheckbox.setValue(credentials.isEnabled()); - enabledCheckbox.addValueChangeListener(e -> { - credentialsService.setEnabled(userId, Boolean.TRUE.equals(e.getValue())); - Notification.show(tr.apply("profile.signing.toggle.saved"), 2000, Notification.Position.BOTTOM_END); - }); - - Button deleteBtn = new Button(tr.apply("profile.signing.delete"), ev -> { - credentialsService.deleteForUser(userId); - Notification.show(tr.apply("profile.signing.deleted"), 2500, Notification.Position.BOTTOM_END); - renderStatus(); - }); - deleteBtn.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY); - - HorizontalLayout actions = new HorizontalLayout(enabledCheckbox, deleteBtn); - actions.setSpacing(true); - actions.setAlignItems(Alignment.CENTER); - actions.getStyle().set("margin-top", "12px"); - card.add(actions); - - statusContainer.add(card); - } - - private Div buildUploadSection() { - Div section = new Div(); - section.getStyle().set("margin-top", "16px").set("padding", "12px 16px") - .set("border", "1px dashed var(--lumo-contrast-20pct)") - .set("border-radius", "var(--lumo-border-radius-m)"); - - Span label = new Span(tr.apply("profile.signing.upload.title")); - label.getStyle().set("font-weight", "600"); - section.add(label); - - MemoryBuffer buffer = new MemoryBuffer(); - Upload upload = new Upload(buffer); - upload.setAcceptedFileTypes("application/x-pkcs12", ".p12", ".pfx"); - upload.setMaxFiles(1); - upload.setMaxFileSize(2 * 1024 * 1024); // 2 MB sind für PKCS#12 reichlich. - upload.setDropLabel(new Span(tr.apply("profile.signing.upload.drop"))); - upload.setWidthFull(); - upload.getStyle().set("margin-top", "8px"); - - TextField aliasField = new TextField(tr.apply("profile.signing.alias")); - aliasField.setWidthFull(); - aliasField.setRequiredIndicatorVisible(true); - - PasswordField passwordField = new PasswordField(tr.apply("profile.signing.password")); - passwordField.setWidthFull(); - passwordField.setRequiredIndicatorVisible(true); - - Span uploadStatus = new Span(); - uploadStatus.getStyle().set("color", "var(--lumo-secondary-text-color)") - .set("font-size", "var(--lumo-font-size-s)"); - - upload.addSucceededListener(event -> uploadStatus.setText(tr.apply("profile.signing.upload.received"))); - upload.addFileRejectedListener(event -> uploadStatus.setText(event.getErrorMessage())); - - Button saveBtn = new Button(tr.apply("profile.signing.upload.save"), ev -> { - if (buffer.getFileData() == null || buffer.getFileData().getFileName() == null - || buffer.getFileData().getFileName().isBlank()) { - Notification.show(tr.apply("profile.signing.upload.required"), 4000, Notification.Position.MIDDLE); - return; - } - if (aliasField.isEmpty()) { - aliasField.setInvalid(true); - aliasField.setErrorMessage(tr.apply("profile.signing.alias.required")); - return; - } - if (passwordField.isEmpty()) { - passwordField.setInvalid(true); - passwordField.setErrorMessage(tr.apply("profile.signing.password.required")); - return; - } - try { - byte[] bytes = buffer.getInputStream().readAllBytes(); - credentialsService.store(userId, bytes, passwordField.getValue(), aliasField.getValue().trim()); - Notification.show(tr.apply("profile.signing.saved"), 3000, Notification.Position.BOTTOM_END); - aliasField.clear(); - passwordField.clear(); - uploadStatus.setText(""); - renderStatus(); - } catch (IllegalArgumentException ex) { - Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE); - } catch (IllegalStateException ex) { - Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE); - } catch (Exception ex) { - Notification.show(tr.apply("profile.signing.error") + ": " + ex.getMessage(), 6000, - Notification.Position.MIDDLE); - } - }); - saveBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY); - - VerticalLayout fields = new VerticalLayout(upload, uploadStatus, aliasField, passwordField, saveBtn); - fields.setPadding(false); - fields.setSpacing(true); - section.add(fields); - - if (!properties.getSigning().hasMasterKey()) { - saveBtn.setEnabled(false); - upload.setVisible(true); - saveBtn.setTooltipText(tr.apply("profile.signing.masterkey.missing")); - } - - return section; - } - - private void addRow(Div container, String label, String value) { - if (value == null || value.isBlank()) { - return; - } - HorizontalLayout row = new HorizontalLayout(); - row.setSpacing(true); - row.setPadding(false); - Span lbl = new Span(label); - lbl.getStyle().set("min-width", "140px").set("color", "var(--lumo-secondary-text-color)"); - Span val = new Span(value); - val.getStyle().set("font-family", "var(--lumo-font-family)"); - row.add(lbl, val); - container.add(row); - } - - private String formatDate(LocalDateTime ts) { - return ts == null ? "—" : ts.format(DATE_FORMAT); - } - - /** - * Convenience-Konstruktor, der einen {@link I18NProvider} statt einer - * Translate-Funktion akzeptiert. So können Aufrufer ohne View-Kontext - * leichter eigene Übersetzungen einspeisen. - */ - public SigningCredentialsPanel(SigningCredentialsService credentialsService, EInvoiceProperties properties, - String userId, I18NProvider i18nProvider, Locale locale) { - this(credentialsService, properties, userId, - key -> i18nProvider.getTranslation(key, locale != null ? locale : Locale.getDefault())); - } -} diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/view/MainLayout.java b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/view/MainLayout.java index 9b32caf..3959811 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/view/MainLayout.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/view/MainLayout.java @@ -145,12 +145,8 @@ public final class MainLayout extends AppLayout { // Add children to "Verwaltung" treeData.addItem(verwaltungItem, new MenuTreeItem(getTranslation("nav.jobs"), "jobs", VaadinIcon.CLIPBOARD_TEXT)); - // Eigenes Rechnungs-Modul ist deaktiviert (siehe CreateInvoiceView/InvoicesView). - // Die rechnungsrelevanten Daten werden ausschließlich per DATEV-CSV exportiert. treeData.addItem(verwaltungItem, - new MenuTreeItem(getTranslation("nav.datev.export"), "datev-export", VaadinIcon.DOWNLOAD)); - treeData.addItem(verwaltungItem, - new MenuTreeItem(getTranslation("nav.approvals"), "approvals", VaadinIcon.CHECK_CIRCLE)); + new MenuTreeItem(getTranslation("nav.invoices"), "invoices", VaadinIcon.FILE_TEXT)); treeData.addItem(verwaltungItem, new MenuTreeItem(getTranslation("nav.customers"), "customers", VaadinIcon.USERS)); treeData.addItem(verwaltungItem, diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/ApprovalsView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/ApprovalsView.java deleted file mode 100644 index c91382f..0000000 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/ApprovalsView.java +++ /dev/null @@ -1,205 +0,0 @@ -package de.assecutor.votianlt.pages.view; - -import com.vaadin.flow.component.Component; -import com.vaadin.flow.component.button.Button; -import com.vaadin.flow.component.button.ButtonVariant; -import com.vaadin.flow.component.dialog.Dialog; -import com.vaadin.flow.component.grid.Grid; -import com.vaadin.flow.component.html.Div; -import com.vaadin.flow.component.html.Span; -import com.vaadin.flow.component.notification.Notification; -import com.vaadin.flow.component.orderedlayout.FlexComponent; -import com.vaadin.flow.component.orderedlayout.HorizontalLayout; -import com.vaadin.flow.component.orderedlayout.VerticalLayout; -import com.vaadin.flow.component.textfield.TextArea; -import com.vaadin.flow.router.HasDynamicTitle; -import com.vaadin.flow.router.Route; -import de.assecutor.votianlt.model.invoices.InvoiceApprovalAction; -import de.assecutor.votianlt.model.invoices.InvoiceApprovalRequest; -import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper; -import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar; -import de.assecutor.votianlt.service.InvoiceApprovalService; -import de.assecutor.votianlt.service.InvoiceLifecycleException; -import de.assecutor.votianlt.service.InvoicePermissionService; -import jakarta.annotation.security.RolesAllowed; - -import java.time.format.DateTimeFormatter; -import java.util.Locale; - -/** - * Freigabe-Posteingang gemäß R-42. Listet offene Storno-/Berichtigungs-Anfragen - * und erlaubt Approver-berechtigten Nutzern die Freigabe oder Ablehnung. - * - * Wird über die Route {@code /approvals} erreicht. Der Zugriff ist nur für Nutzer - * mit Rolle {@code USER}/{@code ADMIN} formal offen — die feingranulare Prüfung - * erfolgt durch den {@link InvoicePermissionService}, der im Render-Pfad - * unberechtigte Nutzer mit einem entsprechenden Hinweis abblockt. - */ -@Route(value = "approvals", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) -@RolesAllowed({ "USER", "ADMIN" }) -public class ApprovalsView extends VerticalLayout implements HasDynamicTitle { - - private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm", - Locale.GERMANY); - - private final InvoiceApprovalService invoiceApprovalService; - - private final Grid grid; - - public ApprovalsView(InvoiceApprovalService invoiceApprovalService, - InvoicePermissionService invoicePermissionService) { - this.invoiceApprovalService = invoiceApprovalService; - - setSizeFull(); - setPadding(true); - setSpacing(true); - setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER); - setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.CENTER); - addClassName("data-view"); - - add(new ViewToolbar(getTranslation("approvals.title"))); - - if (!invoicePermissionService.canApproveRequests(invoicePermissionService.currentUser())) { - Span hint = new Span(getTranslation("approvals.no.permission")); - hint.getStyle().set("color", "var(--lumo-secondary-text-color)"); - add(hint); - grid = null; - return; - } - - grid = new Grid<>(InvoiceApprovalRequest.class, false); - grid.setWidthFull(); - grid.addClassName("data-grid"); - grid.addColumn(this::formatTimestamp).setHeader(getTranslation("approvals.column.requested")) - .setAutoWidth(true); - grid.addColumn(InvoiceApprovalRequest::getRequestedByDisplayName) - .setHeader(getTranslation("approvals.column.requester")).setAutoWidth(true); - grid.addColumn(InvoiceApprovalRequest::getTargetInvoiceNumber) - .setHeader(getTranslation("approvals.column.invoice")).setAutoWidth(true); - grid.addComponentColumn(this::renderActionBadge).setHeader(getTranslation("approvals.column.action")) - .setAutoWidth(true); - grid.addColumn(InvoiceApprovalRequest::getReason).setHeader(getTranslation("approvals.column.reason")) - .setFlexGrow(1); - grid.addComponentColumn(this::renderActions).setHeader(getTranslation("invoices.column.actions")) - .setAutoWidth(true).setFlexGrow(0); - - Div panel = new Div(grid); - panel.addClassNames("surface-panel", "data-grid-panel"); - panel.setWidthFull(); - add(panel); - - reload(); - } - - private void reload() { - if (grid != null) { - grid.setItems(invoiceApprovalService.findPending()); - } - } - - private String formatTimestamp(InvoiceApprovalRequest req) { - return req.getRequestedAt() != null ? req.getRequestedAt().format(DATE_TIME_FMT) : ""; - } - - private Component renderActionBadge(InvoiceApprovalRequest req) { - InvoiceApprovalAction action = req.getAction(); - Span badge = new Span(action == InvoiceApprovalAction.CANCEL_INVOICE - ? getTranslation("invoices.type.cancellation") - : getTranslation("invoices.type.correction")); - badge.getElement().getThemeList().add("badge"); - badge.getElement().getThemeList().add(action == InvoiceApprovalAction.CANCEL_INVOICE ? "error" : "warning"); - return badge; - } - - private Component renderActions(InvoiceApprovalRequest req) { - HorizontalLayout layout = new HorizontalLayout(); - layout.setSpacing(true); - layout.setPadding(false); - - Button approveBtn = new Button(getTranslation("approvals.action.approve"), e -> openReviewDialog(req, true)); - approveBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS, - ButtonVariant.LUMO_SMALL); - layout.add(approveBtn); - - Button rejectBtn = new Button(getTranslation("approvals.action.reject"), e -> openReviewDialog(req, false)); - rejectBtn.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_SMALL); - layout.add(rejectBtn); - - return layout; - } - - private void openReviewDialog(InvoiceApprovalRequest req, boolean approve) { - String titleKey = approve ? "approvals.confirm.approve.title" : "approvals.confirm.reject.title"; - Dialog dialog = DialogStylingHelper.createStyledDialog(getTranslation(titleKey, req.getTargetInvoiceNumber()), - "520px"); - - VerticalLayout content = new VerticalLayout(); - content.setSpacing(true); - content.setPadding(false); - - if (req.getCorrectedFields() != null && !req.getCorrectedFields().isBlank()) { - Span label = new Span(getTranslation("approvals.review.fields")); - label.getStyle().set("font-weight", "600"); - content.add(label); - Div fields = new Div(); - fields.setText(req.getCorrectedFields()); - fields.getStyle().set("white-space", "pre-wrap"); - content.add(fields); - } - - if (req.getReason() != null && !req.getReason().isBlank()) { - Span label = new Span(getTranslation("approvals.review.reason")); - label.getStyle().set("font-weight", "600").set("margin-top", "8px"); - content.add(label); - Div reason = new Div(); - reason.setText(req.getReason()); - reason.getStyle().set("white-space", "pre-wrap"); - content.add(reason); - } - - TextArea commentField = new TextArea(getTranslation("approvals.review.comment")); - commentField.setWidthFull(); - commentField.setMinHeight("80px"); - content.add(commentField); - - dialog.add(DialogStylingHelper.wrapContent(content)); - - Button cancelBtn = new Button(getTranslation("button.cancel"), e -> dialog.close()); - cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY); - - Button confirmBtn = new Button( - getTranslation(approve ? "approvals.action.approve" : "approvals.action.reject"), e -> { - try { - if (approve) { - invoiceApprovalService.approve(req.getId(), commentField.getValue()); - Notification.show(getTranslation("approvals.notification.approved"), 4000, - Notification.Position.BOTTOM_END); - } else { - invoiceApprovalService.reject(req.getId(), commentField.getValue()); - Notification.show(getTranslation("approvals.notification.rejected"), 4000, - Notification.Position.BOTTOM_END); - } - dialog.close(); - reload(); - } catch (InvoiceLifecycleException ex) { - Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE); - } catch (Exception ex) { - Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000, - Notification.Position.MIDDLE); - } - }); - if (approve) { - confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS); - } else { - confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_ERROR); - } - - dialog.getFooter().add(cancelBtn, confirmBtn); - dialog.open(); - } - - @Override - public String getPageTitle() { - return getTranslation("page.title.approvals"); - } -} diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java index 3db1eb4..68208f9 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java @@ -14,8 +14,7 @@ import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.textfield.NumberField; import com.vaadin.flow.router.HasDynamicTitle; -// Import bleibt auskommentiert, solange @Route deaktiviert ist (siehe unten). -// import com.vaadin.flow.router.Route; +import com.vaadin.flow.router.Route; import com.vaadin.flow.router.BeforeEvent; import com.vaadin.flow.router.HasUrlParameter; import de.assecutor.votianlt.model.Customer; @@ -33,7 +32,6 @@ import de.assecutor.votianlt.repository.ServiceRepository; import de.assecutor.votianlt.repository.UserRepository; import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.service.CustomerInvoiceService; -import de.assecutor.votianlt.service.EInvoiceService; import de.assecutor.votianlt.service.InvoiceLifecycleException; import de.assecutor.votianlt.service.InvoiceLifecycleService; import de.assecutor.votianlt.service.InvoiceTemplateService; @@ -53,11 +51,7 @@ import java.util.Optional; import com.vaadin.flow.component.dialog.Dialog; import com.vaadin.flow.component.html.IFrame; -// Route deaktiviert: das System erstellt keine eigenen Rechnungen mehr. -// Code bleibt erhalten — die statische Methode showSavedInvoiceDialog(...) wird weiterhin -// genutzt, um vorhandene Rechnungs-PDFs anzuzeigen, und der DATEV-Export greift auf -// dieselben Backend-Services zu. Reaktivierung: nächste Zeile @Route entkommentieren. -// @Route(value = "create_invoice", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) +@Route(value = "create_invoice", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) @RolesAllowed({ "USER" }) @Slf4j public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter, HasDynamicTitle { @@ -71,7 +65,6 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter private final UserInvoiceDataService userInvoiceDataService; private final CustomerService customerService; private final InvoiceLifecycleService invoiceLifecycleService; - private final EInvoiceService eInvoiceService; private User currentUser; private Job currentJob; private List gridRows = new ArrayList<>(); @@ -126,7 +119,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter UserRepository userRepository, CustomerInvoiceService customerInvoiceService, InvoiceTemplateService invoiceTemplateService, SecurityService securityService, UserInvoiceDataService userInvoiceDataService, CustomerService customerService, - InvoiceLifecycleService invoiceLifecycleService, EInvoiceService eInvoiceService) { + InvoiceLifecycleService invoiceLifecycleService) { this.jobRepository = jobRepository; this.serviceRepository = serviceRepository; this.userRepository = userRepository; @@ -136,7 +129,6 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter this.userInvoiceDataService = userInvoiceDataService; this.customerService = customerService; this.invoiceLifecycleService = invoiceLifecycleService; - this.eInvoiceService = eInvoiceService; setSizeFull(); setPadding(true); setSpacing(true); @@ -594,17 +586,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter invoice.setVatAmount(vatAmount); invoice.setTotalAmount(totalAmount); - // ZUGFeRD-Anreicherung und PAdES-Signatur sind unabhängig: der Nutzer kann - // beides einzeln im Profil aktivieren. Signatur ist strikt – fehlt das - // Zertifikat, schlägt das Speichern hier mit einer InvoiceLifecycleException - // fehl und wird unten als Notification angezeigt. - boolean withZugferd = eInvoiceService.isEInvoiceEnabledGlobally() && user.isEinvoiceEnabled(); - boolean withSignature = user.isSignInvoicesEnabled(); - byte[] finalPdf = pdfBytes; - if (withZugferd || withSignature) { - finalPdf = eInvoiceService.enhanceAndSign(pdfBytes, invoice, withZugferd, withSignature); - } - invoice.setPdfData(finalPdf); + invoice.setPdfData(pdfBytes); // Finalisierung mit Audit-Eintrag und Eindeutigkeitsprüfung der Rechnungsnummer (R-07/R-11/R-36). CustomerInvoice savedInvoice = invoiceLifecycleService.createAndIssue(invoice, diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/DatevExportView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/DatevExportView.java deleted file mode 100644 index bcfee8f..0000000 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/DatevExportView.java +++ /dev/null @@ -1,117 +0,0 @@ -package de.assecutor.votianlt.pages.view; - -import com.vaadin.flow.component.button.Button; -import com.vaadin.flow.component.button.ButtonVariant; -import com.vaadin.flow.component.datepicker.DatePicker; -import com.vaadin.flow.component.html.Anchor; -import com.vaadin.flow.component.html.H2; -import com.vaadin.flow.component.html.Paragraph; -import com.vaadin.flow.component.icon.Icon; -import com.vaadin.flow.component.icon.VaadinIcon; -import com.vaadin.flow.component.notification.Notification; -import com.vaadin.flow.component.orderedlayout.HorizontalLayout; -import com.vaadin.flow.component.orderedlayout.VerticalLayout; -import com.vaadin.flow.router.HasDynamicTitle; -import com.vaadin.flow.router.Route; -import com.vaadin.flow.server.StreamRegistration; -import com.vaadin.flow.server.StreamResource; -import com.vaadin.flow.server.VaadinSession; -import de.assecutor.votianlt.security.SecurityService; -import de.assecutor.votianlt.service.DatevExportService; -import jakarta.annotation.security.RolesAllowed; - -import java.io.ByteArrayInputStream; -import java.time.LocalDate; - -/** - * Ersetzt das frühere Rechnungs-Modul: Anwender wählt einen Zeitraum und lädt - * einen DATEV-konformen Buchungsstapel mit allen festgeschriebenen Rechnungen - * dieses Zeitraums herunter. Erstellung/Bearbeitung von Rechnungen findet im - * System nicht mehr statt — die hier verwendeten Repositories enthalten - * ausschließlich Bestandsdaten. - */ -@Route(value = "datev-export", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) -@RolesAllowed({ "USER", "ADMIN" }) -public class DatevExportView extends VerticalLayout implements HasDynamicTitle { - - private final DatevExportService datevExportService; - private final SecurityService securityService; - - private final DatePicker fromDate = new DatePicker(); - private final DatePicker toDate = new DatePicker(); - - public DatevExportView(DatevExportService datevExportService, SecurityService securityService) { - this.datevExportService = datevExportService; - this.securityService = securityService; - - setSizeFull(); - setPadding(true); - setSpacing(true); - - add(new H2(getTranslation("datev.export.title"))); - add(new Paragraph(getTranslation("datev.export.description"))); - - LocalDate today = LocalDate.now(); - fromDate.setLabel(getTranslation("datev.export.from")); - fromDate.setValue(today.withDayOfMonth(1).minusMonths(1)); - toDate.setLabel(getTranslation("datev.export.to")); - toDate.setValue(today.withDayOfMonth(1).minusDays(1)); - - Button exportButton = new Button(getTranslation("datev.export.button"), - new Icon(VaadinIcon.DOWNLOAD)); - exportButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY); - exportButton.addClickListener(e -> triggerDownload()); - - HorizontalLayout controls = new HorizontalLayout(fromDate, toDate, exportButton); - controls.setAlignItems(Alignment.END); - controls.setSpacing(true); - add(controls); - } - - @Override - public String getPageTitle() { - return getTranslation("datev.export.title"); - } - - private void triggerDownload() { - LocalDate from = fromDate.getValue(); - LocalDate to = toDate.getValue(); - if (from == null || to == null) { - Notification.show(getTranslation("datev.export.error.dates"), 4000, Notification.Position.MIDDLE); - return; - } - if (to.isBefore(from)) { - Notification.show(getTranslation("datev.export.error.range"), 4000, Notification.Position.MIDDLE); - return; - } - - String userId; - try { - userId = securityService.getCurrentDatabaseUser().getId().toHexString(); - } catch (Exception ex) { - Notification.show(getTranslation("datev.export.error.user"), 4000, Notification.Position.MIDDLE); - return; - } - - byte[] csv; - try { - csv = datevExportService.export(userId, from, to); - } catch (IllegalArgumentException ex) { - Notification.show(ex.getMessage(), 4000, Notification.Position.MIDDLE); - return; - } - - String filename = datevExportService.suggestFilename(from, to); - StreamResource resource = new StreamResource(filename, () -> new ByteArrayInputStream(csv)); - resource.setContentType("text/csv;charset=windows-1252"); - StreamRegistration registration = VaadinSession.getCurrent().getResourceRegistry().registerResource(resource); - - Anchor downloadAnchor = new Anchor(registration.getResourceUri().toString(), ""); - downloadAnchor.getElement().setAttribute("download", filename); - downloadAnchor.getElement().getStyle().set("display", "none"); - add(downloadAnchor); - downloadAnchor.getElement().executeJs("$0.click(); setTimeout(() => $0.remove(), 0);"); - - Notification.show(getTranslation("datev.export.success", filename), 3000, Notification.Position.BOTTOM_END); - } -} diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java index da0201f..bd08a74 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java @@ -43,12 +43,9 @@ import de.assecutor.votianlt.pages.service.UserService; import de.assecutor.votianlt.pages.service.UserInvoiceDataService; import de.assecutor.votianlt.repository.ServiceRepository; import de.assecutor.votianlt.security.SecurityService; -import de.assecutor.votianlt.config.EInvoiceProperties; -import de.assecutor.votianlt.pages.base.ui.component.SigningCredentialsPanel; import de.assecutor.votianlt.service.CustomerInvoiceService; import de.assecutor.votianlt.service.InvoiceTemplateService; import de.assecutor.votianlt.service.LanguageService; -import de.assecutor.votianlt.service.SigningCredentialsService; import com.vaadin.flow.component.grid.Grid; import com.vaadin.flow.component.combobox.ComboBox; import com.vaadin.flow.component.textfield.NumberField; @@ -77,8 +74,6 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle private final UserInvoiceDataService userInvoiceDataService; private final CustomerInvoiceService customerInvoiceService; private final InvoiceTemplateService invoiceTemplateService; - private final SigningCredentialsService signingCredentialsService; - private final EInvoiceProperties eInvoiceProperties; private UserInvoiceData currentInvoiceData; private Checkbox billingEnabled; private NumberField vatRateField; @@ -92,15 +87,12 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle public EditProfileView(UserService userService, UserInvoiceDataService userInvoiceDataService, CustomerInvoiceService customerInvoiceService, InvoiceTemplateService invoiceTemplateService, - LanguageService languageService, SecurityService securityService, ServiceRepository serviceRepository, - SigningCredentialsService signingCredentialsService, EInvoiceProperties eInvoiceProperties) { + LanguageService languageService, SecurityService securityService, ServiceRepository serviceRepository) { this.userInvoiceDataService = userInvoiceDataService; this.customerInvoiceService = customerInvoiceService; this.invoiceTemplateService = invoiceTemplateService; this.currentUser = securityService.getCurrentDatabaseUser(); this.serviceRepository = serviceRepository; - this.signingCredentialsService = signingCredentialsService; - this.eInvoiceProperties = eInvoiceProperties; // Store the original language before any changes this.originalLanguage = this.currentUser != null ? this.currentUser.getLanguage() : Language.DE; @@ -375,32 +367,11 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle } }); - Checkbox eInvoiceCheckbox = new Checkbox(getTranslation("profile.settings.einvoice")); - eInvoiceCheckbox.setHelperText(getTranslation("profile.settings.einvoice.helper")); - eInvoiceCheckbox.setValue(currentUser.isEinvoiceEnabled()); - eInvoiceCheckbox.addValueChangeListener( - e -> currentUser.setEinvoiceEnabled(Boolean.TRUE.equals(e.getValue()))); - - Checkbox signInvoicesCheckbox = new Checkbox(getTranslation("profile.settings.signinvoices")); - signInvoicesCheckbox.setHelperText(getTranslation("profile.settings.signinvoices.helper")); - signInvoicesCheckbox.setValue(currentUser.isSignInvoicesEnabled()); - signInvoicesCheckbox.addValueChangeListener( - e -> currentUser.setSignInvoicesEnabled(Boolean.TRUE.equals(e.getValue()))); - - HorizontalLayout billingHeaderLayout = new HorizontalLayout(billingEnabled, prefixField, vatRateField, - eInvoiceCheckbox, signInvoicesCheckbox); + HorizontalLayout billingHeaderLayout = new HorizontalLayout(billingEnabled, prefixField, vatRateField); billingHeaderLayout.setSpacing(true); billingHeaderLayout.setAlignItems(FlexComponent.Alignment.BASELINE); billingTab.add(billingHeaderLayout); - // Signatur-Credentials (Phase 5.5/5.6) - if (currentUser != null && currentUser.getId() != null) { - SigningCredentialsPanel signingPanel = new SigningCredentialsPanel(signingCredentialsService, - eInvoiceProperties, currentUser.getId().toHexString(), this::getTranslation); - signingPanel.setMaxWidth("760px"); - billingTab.add(signingPanel); - } - // Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften) final HorizontalLayout mainLayout = new HorizontalLayout(); mainLayout.setWidthFull(); diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/InvoicesView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/InvoicesView.java index f0eb4ac..d173d45 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/InvoicesView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/InvoicesView.java @@ -18,9 +18,7 @@ import com.vaadin.flow.component.textfield.NumberField; import com.vaadin.flow.component.textfield.TextArea; import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.router.HasDynamicTitle; -// Route deaktiviert (siehe Klassen-Header) — die Anwendung erstellt/bearbeitet keine -// Rechnungen mehr selbst, der Bestand wird per DATEV-Export weiterverarbeitet. -// import com.vaadin.flow.router.Route; +import com.vaadin.flow.router.Route; import com.vaadin.flow.server.StreamRegistration; import com.vaadin.flow.server.StreamResource; import de.assecutor.votianlt.model.User; @@ -28,7 +26,6 @@ import de.assecutor.votianlt.model.invoices.CustomerInvoice; import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry; import de.assecutor.votianlt.model.invoices.InvoiceStatus; import de.assecutor.votianlt.model.invoices.InvoiceType; -import de.assecutor.votianlt.model.invoices.PaymentStatus; import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper; import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar; import de.assecutor.votianlt.pages.service.UserInvoiceDataService; @@ -36,7 +33,6 @@ import de.assecutor.votianlt.repository.CustomerInvoiceRepository; import de.assecutor.votianlt.repository.UserRepository; import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.service.CustomerInvoiceService; -import de.assecutor.votianlt.service.InvoiceApprovalService; import de.assecutor.votianlt.service.InvoiceExportService; import de.assecutor.votianlt.service.InvoiceLifecycleException; import de.assecutor.votianlt.service.InvoiceLifecycleService; @@ -51,8 +47,7 @@ import java.util.List; import java.util.Locale; import java.util.Optional; -// @Route deaktiviert — Rechnungs-UI ist durch DATEV-Export ersetzt. Reaktivierung: -// @Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) +@Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) @RolesAllowed({ "USER", "ADMIN" }) public class InvoicesView extends VerticalLayout implements HasDynamicTitle { @@ -66,14 +61,13 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle { private final CustomerInvoiceService customerInvoiceService; private final InvoiceExportService invoiceExportService; private final InvoicePermissionService invoicePermissionService; - private final InvoiceApprovalService invoiceApprovalService; private final UserInvoiceDataService userInvoiceDataService; private final UserRepository userRepository; public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService, InvoiceLifecycleService invoiceLifecycleService, CustomerInvoiceService customerInvoiceService, InvoiceExportService invoiceExportService, InvoicePermissionService invoicePermissionService, - InvoiceApprovalService invoiceApprovalService, UserInvoiceDataService userInvoiceDataService, + UserInvoiceDataService userInvoiceDataService, UserRepository userRepository) { this.customerInvoiceRepository = customerInvoiceRepository; this.securityService = securityService; @@ -81,7 +75,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle { this.customerInvoiceService = customerInvoiceService; this.invoiceExportService = invoiceExportService; this.invoicePermissionService = invoicePermissionService; - this.invoiceApprovalService = invoiceApprovalService; this.userInvoiceDataService = userInvoiceDataService; this.userRepository = userRepository; @@ -110,10 +103,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle { .setHeader(getTranslation("invoices.column.date")).setAutoWidth(true); invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount")) .setAutoWidth(true); - invoiceGrid.addComponentColumn(this::renderPaymentBadge) - .setHeader(getTranslation("invoices.column.payment")).setAutoWidth(true); - invoiceGrid.addColumn(this::formatOutstanding).setHeader(getTranslation("invoices.column.outstanding")) - .setAutoWidth(true); invoiceGrid.addComponentColumn(this::renderActions) .setHeader(getTranslation("invoices.column.actions")).setAutoWidth(true).setFlexGrow(0); @@ -165,29 +154,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle { return badge; } - private Component renderPaymentBadge(CustomerInvoice invoice) { - PaymentStatus status = invoice.getPaymentStatus() != null ? invoice.getPaymentStatus() : PaymentStatus.UNPAID; - Span badge = new Span(getTranslation("invoices.payment." + status.name().toLowerCase(Locale.ROOT))); - badge.getElement().getThemeList().add("badge"); - switch (status) { - case PAID -> badge.getElement().getThemeList().add("success"); - case PARTIALLY_PAID -> badge.getElement().getThemeList().add("contrast"); - case OVERPAID -> badge.getElement().getThemeList().add("warning"); - case REFUND_DUE -> badge.getElement().getThemeList().add("error"); - default -> { - } - } - return badge; - } - - private String formatOutstanding(CustomerInvoice invoice) { - if (invoice.getTotalAmount() == null) { - return ""; - } - java.math.BigDecimal outstanding = invoiceLifecycleService.computeOutstandingAmount(invoice); - return java.text.NumberFormat.getCurrencyInstance(Locale.GERMANY).format(outstanding); - } - private Component renderTypeBadge(CustomerInvoice invoice) { InvoiceType type = invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE; HorizontalLayout layout = new HorizontalLayout(); @@ -202,21 +168,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle { badge.getElement().getThemeList().add("warning"); } layout.add(badge); - - if (invoice.getEInvoiceFormat() != null - && invoice.getEInvoiceFormat() != de.assecutor.votianlt.model.invoices.EInvoiceFormat.NONE) { - Span eInvoiceBadge = new Span("ZUGFeRD"); - eInvoiceBadge.getElement().getThemeList().add("badge"); - eInvoiceBadge.getElement().getThemeList().add("primary"); - eInvoiceBadge.setTitle(getTranslation("invoices.einvoice.tooltip")); - layout.add(eInvoiceBadge); - } - if (invoice.isSigned()) { - Span signedBadge = new Span("✓ " + getTranslation("invoices.einvoice.signed")); - signedBadge.getElement().getThemeList().add("badge"); - signedBadge.getElement().getThemeList().add("success"); - layout.add(signedBadge); - } return layout; } @@ -251,28 +202,17 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle { } if (isLiveInvoice) { - boolean hasPendingRequest = !invoiceApprovalService - .findOpenForCurrentRequester().stream() - .filter(r -> invoice.getId().equals(r.getTargetInvoiceId())) - .toList().isEmpty(); - if (invoicePermissionService.canCorrect(currentUser)) { - String label = invoicePermissionService.requiresApproval(currentUser) - ? getTranslation("invoices.action.correct.request") - : getTranslation("invoices.action.correct"); - Button correctBtn = new Button(label, e -> openCorrectionDialog(invoice)); + Button correctBtn = new Button(getTranslation("invoices.action.correct"), + e -> openCorrectionDialog(invoice)); correctBtn.addThemeVariants(ButtonVariant.LUMO_SMALL); - correctBtn.setEnabled(!hasPendingRequest); actions.add(correctBtn); } if (invoicePermissionService.canCancel(currentUser)) { - String label = invoicePermissionService.requiresApproval(currentUser) - ? getTranslation("invoices.action.cancel.request") - : getTranslation("invoices.action.cancel"); - Button cancelBtn = new Button(label, e -> openCancellationDialog(invoice)); + Button cancelBtn = new Button(getTranslation("invoices.action.cancel"), + e -> openCancellationDialog(invoice)); cancelBtn.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ERROR); - cancelBtn.setEnabled(!hasPendingRequest); actions.add(cancelBtn); } } @@ -432,14 +372,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle { User currentUser = invoicePermissionService.currentUser(); try { invoicePermissionService.requireCancel(currentUser); - if (invoicePermissionService.requiresApproval(currentUser)) { - invoiceApprovalService.requestCancellation(invoice.getId(), reason); - dialog.close(); - Notification.show(getTranslation("invoices.notification.requested"), 4000, - Notification.Position.BOTTOM_END); - loadInvoices(); - return; - } User issuer = resolveIssuer(invoice); String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId()); LocalDate today = LocalDate.now(); @@ -506,15 +438,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle { User currentUser = invoicePermissionService.currentUser(); try { invoicePermissionService.requireCorrect(currentUser); - if (invoicePermissionService.requiresApproval(currentUser)) { - String requestReason = reason != null && !reason.isBlank() ? reason : correctedFields; - invoiceApprovalService.requestCorrection(invoice.getId(), correctedFields, requestReason); - dialog.close(); - Notification.show(getTranslation("invoices.notification.requested"), 4000, - Notification.Position.BOTTOM_END); - loadInvoices(); - return; - } User issuer = resolveIssuer(invoice); String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId()); LocalDate today = LocalDate.now(); diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/MyInvoicesView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/MyInvoicesView.java index 30ed5a3..7775f42 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/MyInvoicesView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/MyInvoicesView.java @@ -16,11 +16,9 @@ import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.data.renderer.ComponentRenderer; import com.vaadin.flow.data.value.ValueChangeMode; import com.vaadin.flow.router.HasDynamicTitle; -// Route deaktiviert (siehe Klassen-Header). -// import com.vaadin.flow.router.Route; +import com.vaadin.flow.router.Route; import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar; -// import bleibt auskommentiert, solange die @Route oben deaktiviert ist: -// import de.assecutor.votianlt.pages.base.ui.view.MainLayout; +import de.assecutor.votianlt.pages.base.ui.view.MainLayout; import jakarta.annotation.security.RolesAllowed; import com.vaadin.flow.component.UI; import com.vaadin.flow.server.StreamResource; @@ -43,8 +41,7 @@ import java.util.Locale; * Modernisierte Optik: Responsive Karten, Lumo-Theme-Varianten, Status-Badges, * Suche und leere Zustandsanzeige. */ -// @Route deaktiviert — Rechnungs-UI ist durch DATEV-Export ersetzt. Reaktivierung: -// @Route(value = "my-invoices", layout = MainLayout.class) +@Route(value = "my-invoices", layout = MainLayout.class) @RolesAllowed("USER") public class MyInvoicesView extends Main implements HasDynamicTitle { diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java index 8c8b0d6..8c9f73b 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java @@ -50,6 +50,7 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle { private final SecurityService securityService; private final ClientConnectionService clientConnectionService; private final MessagingPublisher messagingPublisher; + private final CustomerInvoiceRepository customerInvoiceRepository; private final Grid grid = new Grid<>(Job.class, false); @Autowired @@ -61,6 +62,7 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle { this.securityService = securityService; this.clientConnectionService = clientConnectionService; this.messagingPublisher = messagingPublisher; + this.customerInvoiceRepository = customerInvoiceRepository; setSizeFull(); setPadding(true); setSpacing(true); @@ -140,10 +142,38 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle { return new com.vaadin.flow.component.html.Span(); }).setHeader("").setAutoWidth(true).setFlexGrow(0); - // Rechnungs-Aktionen entfernt: Das System erstellt/verwaltet keine Rechnungen - // mehr aktiv aus der Jobs-Übersicht heraus. Bereits vorhandene Rechnungs-PDFs - // (Bestandsdaten) bleiben über den DATEV-Export bzw. die Backend-Repositories - // zugänglich; ein dedizierter UI-Button im Jobs-Grid ist dafür nicht mehr nötig. + // Invoice column - only show for completed jobs + grid.addComponentColumn(job -> { + if (job.getStatus() == JobStatus.COMPLETED) { + if (hasInvoice(job)) { + Button invoiceBtn = new Button(new Icon(VaadinIcon.FILE_TEXT_O)); + invoiceBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + invoiceBtn.setTooltipText(getTranslation("jobs.tooltip.showinvoice")); + invoiceBtn.addClickListener(e -> { + e.getSource().getElement().getNode(); + customerInvoiceRepository.findById(job.getInvoiceId()).ifPresentOrElse(invoice -> { + if (invoice.getPdfData() != null) { + CreateInvoiceView.showSavedInvoiceDialog(invoice.getPdfData(), + invoice.getInvoiceNumber(), this); + } else { + getUI().ifPresent(ui -> ui.navigate("create_invoice/" + job.getId().toHexString())); + } + }, () -> getUI().ifPresent(ui -> ui.navigate("create_invoice/" + job.getId().toHexString()))); + }); + return invoiceBtn; + } + + Button createInvoiceBtn = new Button(new Icon(VaadinIcon.DOLLAR)); + createInvoiceBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SUCCESS); + createInvoiceBtn.setTooltipText(getTranslation("jobs.tooltip.createinvoice")); + createInvoiceBtn.addClickListener(e -> { + e.getSource().getElement().getNode(); + getUI().ifPresent(ui -> ui.navigate("create_invoice/" + job.getId().toHexString())); + }); + return createInvoiceBtn; + } + return new com.vaadin.flow.component.html.Span(); + }).setHeader("").setWidth("60px").setFlexGrow(0); // Delete column (last column, right side) grid.addComponentColumn(job -> { diff --git a/backend/src/main/java/de/assecutor/votianlt/repository/InvoiceApprovalRequestRepository.java b/backend/src/main/java/de/assecutor/votianlt/repository/InvoiceApprovalRequestRepository.java deleted file mode 100644 index 0d619c7..0000000 --- a/backend/src/main/java/de/assecutor/votianlt/repository/InvoiceApprovalRequestRepository.java +++ /dev/null @@ -1,19 +0,0 @@ -package de.assecutor.votianlt.repository; - -import de.assecutor.votianlt.model.invoices.InvoiceApprovalRequest; -import de.assecutor.votianlt.model.invoices.InvoiceApprovalStatus; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public interface InvoiceApprovalRequestRepository extends MongoRepository { - - List findByStatus(InvoiceApprovalStatus status); - - List findByTargetInvoiceId(String targetInvoiceId); - - List findByRequestedByUserIdAndStatus(String requestedByUserId, - InvoiceApprovalStatus status); -} diff --git a/backend/src/main/java/de/assecutor/votianlt/repository/UserSigningCredentialsRepository.java b/backend/src/main/java/de/assecutor/votianlt/repository/UserSigningCredentialsRepository.java deleted file mode 100644 index b6fd69c..0000000 --- a/backend/src/main/java/de/assecutor/votianlt/repository/UserSigningCredentialsRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -package de.assecutor.votianlt.repository; - -import de.assecutor.votianlt.model.invoices.UserSigningCredentials; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.stereotype.Repository; - -import java.util.Optional; - -@Repository -public interface UserSigningCredentialsRepository extends MongoRepository { - - Optional findByUserId(String userId); - - void deleteByUserId(String userId); -} diff --git a/backend/src/main/java/de/assecutor/votianlt/service/DatevExportService.java b/backend/src/main/java/de/assecutor/votianlt/service/DatevExportService.java deleted file mode 100644 index b277e44..0000000 --- a/backend/src/main/java/de/assecutor/votianlt/service/DatevExportService.java +++ /dev/null @@ -1,230 +0,0 @@ -package de.assecutor.votianlt.service; - -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.springframework.stereotype.Service; - -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.nio.charset.Charset; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.Comparator; -import java.util.List; -import java.util.Locale; - -/** - * Erzeugt einen DATEV-kompatiblen Buchungsstapel (Format „DTVF"/„EXTF", - * Version 7) als CSV mit den festgeschriebenen Rechnungen eines Nutzers - * im gewählten Zeitraum. Die Datei lässt sich von DATEV Unternehmen - * Online sowie den meisten DATEV-importfähigen Drittprogrammen - * (z.B. sevDesk, lexoffice, GnuCash) als Buchungsstapel einlesen. - * - *

Inhaltliche Defaults — bewusst konservativ und für SKR03 ausgelegt; - * der Mandant kann sie im Abgleich mit dem Steuerberater später anpassen: - *

    - *
  • Sammeldebitor 10000 (Forderungen aus Lieferungen und Leistungen)
  • - *
  • Erlöskonto 8400 für 19 % USt, 8300 für 7 %, 8125 für innergemeinschaftliche - * Lieferungen / Reverse-Charge / sonstige steuerfreie Erlöse (rate = 0)
  • - *
  • Stornorechnungen werden mit umgekehrtem Soll/Haben-Kennzeichen gebucht.
  • - *
- * - *

Formatdetails: - *

    - *
  • Zeichensatz: Windows-1252 (DATEV-Vorgabe).
  • - *
  • Feldtrenner: Semikolon, Texte in Anführungszeichen.
  • - *
  • Beträge mit Komma als Dezimaltrenner, ohne Tausender-Trenner.
  • - *
  • Zeilenumbruch: CRLF.
  • - *
- */ -@Service -public class DatevExportService { - - private static final String CSV_LINE_SEPARATOR = "\r\n"; - private static final Charset DATEV_CHARSET = Charset.forName("Windows-1252"); - - private static final DateTimeFormatter HEADER_TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS", - Locale.GERMANY); - private static final DateTimeFormatter HEADER_DATE = DateTimeFormatter.ofPattern("yyyyMMdd", Locale.GERMANY); - private static final DateTimeFormatter VOUCHER_DATE = DateTimeFormatter.ofPattern("ddMM", Locale.GERMANY); - - /** Sammeldebitor-Konto laut SKR03. */ - static final String DEFAULT_DEBTOR_ACCOUNT = "10000"; - - private final CustomerInvoiceRepository invoiceRepository; - - public DatevExportService(CustomerInvoiceRepository invoiceRepository) { - this.invoiceRepository = invoiceRepository; - } - - /** - * Lädt die Rechnungen des Nutzers im Bereich [from, to] (inkl.) und liefert - * die DATEV-CSV als Byte-Array. Zeitraum bezieht sich auf das Rechnungsdatum. - * Entwürfe werden ignoriert — nur festgeschriebene Belege gehören in die - * Buchhaltung. Liefert eine Datei mit Header und leerer Buchungsliste, wenn - * keine Rechnungen im Zeitraum vorliegen — das ist gewollt, weil DATEV einen - * leeren Stapel als „nichts zu importieren" akzeptiert. - */ - public byte[] export(String userId, LocalDate from, LocalDate to) { - if (userId == null || userId.isBlank()) { - throw new IllegalArgumentException("userId ist Pflicht."); - } - if (from == null || to == null) { - throw new IllegalArgumentException("Zeitraum (from/to) ist Pflicht."); - } - if (to.isBefore(from)) { - throw new IllegalArgumentException("Bis-Datum darf nicht vor dem Von-Datum liegen."); - } - - List invoices = invoiceRepository.findByUserId(userId).stream() - .filter(this::isExportable) - .filter(inv -> inv.getInvoiceDate() != null - && !inv.getInvoiceDate().isBefore(from) - && !inv.getInvoiceDate().isAfter(to)) - .sorted(Comparator.comparing(CustomerInvoice::getInvoiceDate) - .thenComparing(CustomerInvoice::getInvoiceNumber, Comparator.nullsLast(String::compareTo))) - .toList(); - - StringBuilder sb = new StringBuilder(); - sb.append(buildHeader(from, to)).append(CSV_LINE_SEPARATOR); - sb.append(buildColumnHeader()).append(CSV_LINE_SEPARATOR); - for (CustomerInvoice invoice : invoices) { - sb.append(buildBookingRow(invoice, from)).append(CSV_LINE_SEPARATOR); - } - return sb.toString().getBytes(DATEV_CHARSET); - } - - /** - * Schlägt einen Dateinamen vor: EXTF_Buchungsstapel___.csv. - */ - public String suggestFilename(LocalDate from, LocalDate to) { - DateTimeFormatter df = DateTimeFormatter.ofPattern("yyyyMMdd"); - return "EXTF_Buchungsstapel_" + from.format(df) + "_" + to.format(df) + ".csv"; - } - - private boolean isExportable(CustomerInvoice invoice) { - // Entwürfe werden nicht exportiert — sie sind buchhalterisch nicht relevant. - // Stornos und Korrekturen jedoch schon, damit der Saldo sauber bleibt. - return invoice.getStatus() != null && invoice.getStatus() != InvoiceStatus.DRAFT; - } - - /** - * Header gemäß DATEV-Format „EXTF" Version 7. Felder, die wir nicht belegen - * können (Berater-/Mandantennummer), bleiben leer — DATEV importiert dann - * den Stapel ohne Zwangs-Mapping. - */ - private String buildHeader(LocalDate from, LocalDate to) { - String now = LocalDateTime.now().format(HEADER_TS); - return String.join(";", - quote("EXTF"), // Format-Kennzeichen - "700", // Versions-Nummer - "21", // Datenkategorie: Buchungsstapel - quote("Buchungsstapel"), - "7", // Format-Version - now, // Erzeugt am - "", // Importiert - quote("votianlt"), // Herkunft / erzeugendes System - quote(""), // Exportiert von - quote(""), // importiert von - "", // Beraternummer - "", // Mandantennummer - from.withDayOfMonth(1).format(HEADER_DATE), // Wirtschaftsjahresbeginn - "4", // Sachkontenlänge - from.format(HEADER_DATE), // Datum von - to.format(HEADER_DATE), // Datum bis - quote("Rechnungsexport"), // Bezeichnung - quote(""), // Diktatkürzel - "1", // Buchungstyp: Finanzbuchhaltung - "0", // Rechnungslegungszweck - quote("EUR"), // Festschreibung / WKZ - "", "", "", "", "", "", "", ""); - } - - private String buildColumnHeader() { - return String.join(";", - "Umsatz (ohne Soll/Haben-Kz)", - "Soll/Haben-Kennzeichen", - "WKZ Umsatz", - "Konto", - "Gegenkonto (ohne BU-Schlüssel)", - "BU-Schlüssel", - "Belegdatum", - "Belegfeld 1", - "Belegfeld 2", - "Buchungstext"); - } - - private String buildBookingRow(CustomerInvoice invoice, LocalDate periodFrom) { - BigDecimal gross = nonNull(invoice.getTotalAmount()); - boolean isCancellation = invoice.getType() == InvoiceType.CANCELLATION - || invoice.getStatus() == InvoiceStatus.CANCELLED; - BigDecimal absoluteGross = gross.abs().setScale(2, RoundingMode.HALF_UP); - - // Reguläre Forderung: S an Erlöskonto. Storno dreht das Vorzeichen über - // S/H-Kennzeichen, nicht über negative Beträge — DATEV-konventionell. - String solHaben = isCancellation ? "H" : "S"; - - String revenueAccount = resolveRevenueAccount(invoice); - String voucherDate = invoice.getInvoiceDate().format(VOUCHER_DATE); - String invoiceNumber = invoice.getInvoiceNumber() != null ? invoice.getInvoiceNumber() : ""; - String recipient = invoice.getRecipientName() != null ? invoice.getRecipientName() : ""; - String text = (isCancellation ? "Storno: " : "Rechnung an ") + recipient; - - return String.join(";", - formatAmount(absoluteGross), - solHaben, - quote("EUR"), - DEFAULT_DEBTOR_ACCOUNT, - revenueAccount, - "", // BU-Schlüssel: leer, USt steckt im Erlöskonto - voucherDate, - quote(invoiceNumber), - "", - quote(text)); - } - - /** - * Mappt den Steuersatz auf das passende SKR03-Erlöskonto. Konservative Wahl: - * Bei unbekannten Sätzen fällt der Service auf das 19 %-Konto zurück und - * markiert das via Buchungstext implizit — der Steuerberater sieht das beim - * Import und kann es korrigieren. - */ - private String resolveRevenueAccount(CustomerInvoice invoice) { - BigDecimal rate = invoice.getVatRate(); - if (rate == null) { - return "8400"; - } - BigDecimal scaled = rate.setScale(2, RoundingMode.HALF_UP); - if (scaled.compareTo(new BigDecimal("0.19")) == 0) { - return "8400"; - } - if (scaled.compareTo(new BigDecimal("0.07")) == 0) { - return "8300"; - } - if (scaled.signum() == 0) { - // Reverse-Charge / innergemeinschaftliche Lieferung / Kleinunternehmer - return "8125"; - } - return "8400"; - } - - private BigDecimal nonNull(BigDecimal value) { - return value != null ? value : BigDecimal.ZERO; - } - - private String formatAmount(BigDecimal value) { - // Komma als Dezimaltrenner, kein Tausender-Trenner. - return value.setScale(2, RoundingMode.HALF_UP).toPlainString().replace('.', ','); - } - - private String quote(String value) { - if (value == null) { - return "\"\""; - } - // DATEV: doppelte Anführungszeichen werden durch Verdoppeln escaped. - return "\"" + value.replace("\"", "\"\"") + "\""; - } -} diff --git a/backend/src/main/java/de/assecutor/votianlt/service/EInvoiceService.java b/backend/src/main/java/de/assecutor/votianlt/service/EInvoiceService.java deleted file mode 100644 index 8e36b4b..0000000 --- a/backend/src/main/java/de/assecutor/votianlt/service/EInvoiceService.java +++ /dev/null @@ -1,472 +0,0 @@ -package de.assecutor.votianlt.service; - -import de.assecutor.votianlt.config.EInvoiceProperties; -import de.assecutor.votianlt.model.invoices.UserSigningCredentials; -import de.assecutor.votianlt.model.invoices.CustomerInvoice; -import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem; -import de.assecutor.votianlt.model.invoices.EInvoiceFormat; -import org.apache.pdfbox.Loader; -import org.apache.pdfbox.io.IOUtils; -import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature; -import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface; -import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureOptions; -import org.bouncycastle.cert.jcajce.JcaCertStore; -import org.bouncycastle.cms.CMSException; -import org.bouncycastle.cms.CMSProcessableByteArray; -import org.bouncycastle.cms.CMSSignedData; -import org.bouncycastle.cms.CMSSignedDataGenerator; -import org.bouncycastle.cms.CMSTypedData; -import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder; -import org.bouncycastle.operator.ContentSigner; -import org.bouncycastle.operator.OperatorCreationException; -import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; -import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; -import org.bouncycastle.util.Store; -import org.mustangproject.Contact; -import org.mustangproject.Invoice; -import org.mustangproject.Item; -import org.mustangproject.Product; -import org.mustangproject.TradeParty; -import org.mustangproject.ZUGFeRD.PDFAConformanceLevel; -import org.mustangproject.ZUGFeRD.Profiles; -import org.mustangproject.ZUGFeRD.ZUGFeRDExporterFromA1; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Service; - -import java.io.ByteArrayOutputStream; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.math.BigDecimal; -import java.math.RoundingMode; -import java.nio.file.Path; -import java.security.KeyStore; -import java.security.PrivateKey; -import java.security.Provider; -import java.security.Security; -import java.security.cert.Certificate; -import java.security.cert.X509Certificate; -import java.time.LocalDate; -import java.time.ZoneId; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.Date; -import java.util.List; - -/** - * Erzeugt ZUGFeRD/Factur-X-konforme E-Rechnungen mit Mustangproject und - * signiert das Ergebnis optional via PAdES (iText + BouncyCastle). - * - * Aufrufer übergeben ein bereits gerendertes PDF (z.B. aus - * {@link CustomerInvoiceService}) und die Rechnungsdaten. Der Service - * konvertiert das PDF in ein PDF/A-3 mit eingebettetem ZUGFeRD-XML und - * hängt anschließend – sofern konfiguriert – eine PAdES-Signatur an. - * - * Schlägt einer der Schritte fehl, wird das Original-PDF unverändert zurückgegeben - * und der Fehler protokolliert. So bleibt die Rechnungserstellung als Ganzes - * funktionsfähig, falls die E-Rechnung-Konfiguration unvollständig ist. - */ -@Service -public class EInvoiceService { - - private static final Logger log = LoggerFactory.getLogger(EInvoiceService.class); - private static final String DEFAULT_UNIT_CODE = "C62"; // ISO 6E - "one" - private static final String DEFAULT_COUNTRY = "DE"; - - private final EInvoiceProperties properties; - private final SigningCredentialsService signingCredentialsService; - - static { - ensureBouncyCastleProvider(); - } - - public EInvoiceService(EInvoiceProperties properties, SigningCredentialsService signingCredentialsService) { - this.properties = properties; - this.signingCredentialsService = signingCredentialsService; - } - - public boolean isEInvoiceEnabledGlobally() { - return properties.isEnabled(); - } - - public boolean isSigningConfigured() { - return properties.getSigning().isFullyConfigured(); - } - - public boolean isUserSigningAvailable(String userId) { - return signingCredentialsService.loadActive(userId).isPresent(); - } - - /** - * Reichert ein gerendertes PDF mit ZUGFeRD-XML an und/oder signiert es. - * Markiert das Ergebnis im übergebenen {@link CustomerInvoice} (Format/Signatur), - * persistiert die Rechnung selbst aber nicht. - * - * @param withZugferd ZUGFeRD/Factur-X-XML einbetten (graceful: bei Fehler Roh-PDF) - * @param withSignature PAdES-Signatur erzeugen (strikt: wirft - * {@link InvoiceLifecycleException}, wenn Zertifikat fehlt - * oder nicht entschlüsselt werden kann) - */ - public byte[] enhanceAndSign(byte[] basePdf, CustomerInvoice invoice, boolean withZugferd, - boolean withSignature) { - byte[] result = basePdf; - - if (withZugferd && properties.isEnabled()) { - try { - result = embedZugferdXml(result, invoice); - invoice.setEInvoiceFormat(resolveFormat(properties.getProfile())); - } catch (Exception ex) { - log.warn("ZUGFeRD-Anreicherung fehlgeschlagen, fahre mit Roh-PDF fort: {}", ex.getMessage(), ex); - invoice.setEInvoiceFormat(EInvoiceFormat.NONE); - } - } else { - invoice.setEInvoiceFormat(EInvoiceFormat.NONE); - } - - if (withSignature) { - result = signOrFail(result, invoice); - } - return result; - } - - /** - * Convenience-Variante: ZUGFeRD und Signatur. Bestand für Aufrufer, die nicht - * die feingranulare Variante nutzen wollen. - */ - public byte[] enhanceAndSign(byte[] basePdf, CustomerInvoice invoice) { - return enhanceAndSign(basePdf, invoice, true, true); - } - - /** - * Signiert das PDF — bevorzugt mit dem nutzerseitigen Keystore, ansonsten mit dem - * systemweit konfigurierten. Wirft eine {@link InvoiceLifecycleException}, wenn: - *
    - *
  • der Nutzer Credentials hinterlegt hat, diese aber nicht entschlüsselt - * oder geladen werden können (z.B. fehlender/falscher Master-Key, - * beschädigter Keystore),
  • - *
  • der Nutzer keine Credentials hinterlegt hat und auch kein System-Keystore - * konfiguriert ist,
  • - *
  • die eigentliche Signatur-Operation fehlschlägt.
  • - *
- * Die Exception trägt eine anwendertaugliche Nachricht und wird in der UI als - * Notification angezeigt – eine stille Rückfall-Strategie zu unsignierten PDFs - * findet absichtlich nicht mehr statt. - */ - private byte[] signOrFail(byte[] pdfBytes, CustomerInvoice invoice) { - String userId = invoice.getUserId(); - java.util.Optional stored = userId != null - ? signingCredentialsService.findCredentials(userId) - : java.util.Optional.empty(); - - if (stored.isPresent()) { - UserSigningCredentials credentials = stored.get(); - if (!credentials.isEnabled()) { - throw new InvoiceLifecycleException( - "Ihr hinterlegtes Signatur-Zertifikat ist deaktiviert. " - + "Bitte aktivieren Sie es im Profil oder entfernen Sie es, um den System-Keystore zu nutzen."); - } - java.util.Optional loaded = signingCredentialsService - .loadActive(userId); - if (loaded.isEmpty()) { - throw new InvoiceLifecycleException( - "Ihr Signatur-Zertifikat konnte nicht entschlüsselt werden. " - + "Möglicherweise ist der Server-Master-Key nicht gesetzt oder wurde geändert. " - + "Bitte laden Sie das Zertifikat erneut hoch oder kontaktieren Sie den Administrator."); - } - try { - SigningCredentialsService.LoadedCredentials creds = loaded.get(); - byte[] signed = signWithCredentials(pdfBytes, creds.getKeystore(), creds.getPassword(), - creds.getAlias()); - invoice.setSigned(true); - invoice.setSignedAt(java.time.LocalDateTime.now()); - invoice.setSignedBy(creds.getMetadata() != null && creds.getMetadata().getSubjectDn() != null - ? creds.getMetadata().getSubjectDn() - : creds.getAlias()); - return signed; - } catch (Exception ex) { - log.error("PAdES-Signatur mit Nutzer-Keystore fehlgeschlagen", ex); - throw new InvoiceLifecycleException( - "Die digitale Signatur ist fehlgeschlagen: " + ex.getMessage() - + ". Bitte prüfen Sie Ihr Zertifikat im Profil.", - ex); - } - } - - if (properties.getSigning().isFullyConfigured()) { - try { - byte[] signed = signPdf(pdfBytes); - invoice.setSigned(true); - invoice.setSignedAt(java.time.LocalDateTime.now()); - invoice.setSignedBy(properties.getSigning().getKeyAlias()); - return signed; - } catch (Exception ex) { - log.error("PAdES-Signatur mit System-Keystore fehlgeschlagen", ex); - throw new InvoiceLifecycleException( - "Die digitale Signatur ist fehlgeschlagen: " + ex.getMessage() - + ". Bitte kontaktieren Sie den Administrator.", - ex); - } - } - - throw new InvoiceLifecycleException( - "Es ist kein Signatur-Zertifikat verfügbar. " - + "Bitte hinterlegen Sie ein eigenes Zertifikat in Ihrem Profil " - + "oder bitten Sie den Administrator, einen System-Keystore zu konfigurieren."); - } - - /** - * Bettet ZUGFeRD-XML in ein PDF ein und liefert ein PDF/A-3 zurück. - * Wirft bei Fehlschlag eine {@link RuntimeException} – der Caller entscheidet, - * ob mit dem Original-PDF fortgefahren wird. - */ - public byte[] embedZugferdXml(byte[] basePdf, CustomerInvoice invoice) throws Exception { - Invoice mustangInvoice = toMustangInvoice(invoice); - - try (ZUGFeRDExporterFromA1 exporter = new ZUGFeRDExporterFromA1()) { - exporter.setProducer("votianlt"); - exporter.setCreator("votianlt"); - exporter.ignorePDFAErrors(); - exporter.setConformanceLevel(PDFAConformanceLevel.UNICODE); - exporter.setProfile(resolveProfile(properties.getProfile())); - exporter.load(basePdf); - exporter.setTransaction(mustangInvoice); - - try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { - exporter.export(baos); - return baos.toByteArray(); - } - } - } - - /** - * PAdES-Signatur mit dem systemweit konfigurierten Keystore (Fallback-Pfad). - * Liest Keystore-Datei und Passwort aus {@link EInvoiceProperties.Signing}. - */ - public byte[] signPdf(byte[] pdfBytes) throws Exception { - EInvoiceProperties.Signing config = properties.getSigning(); - if (!config.isFullyConfigured()) { - throw new IllegalStateException("Signatur-Konfiguration unvollständig."); - } - - KeyStore keystore = KeyStore.getInstance("PKCS12"); - try (FileInputStream fis = new FileInputStream(Path.of(config.getKeystorePath()).toFile())) { - keystore.load(fis, config.getKeystorePassword().toCharArray()); - } - return signWithCredentials(pdfBytes, keystore, config.getKeystorePassword().toCharArray(), - config.getKeyAlias()); - } - - /** - * Kern-Signaturpfad — wird von System- wie Nutzer-Keystore-Variante geteilt. - * Erzeugt eine PAdES-Detached-CMS-Signatur (SHA-256 / RSA) via PDFBox + BouncyCastle. - */ - private byte[] signWithCredentials(byte[] pdfBytes, KeyStore keystore, char[] password, String alias) - throws Exception { - if (alias == null || alias.isBlank()) { - throw new IllegalStateException("Alias zum Signieren erforderlich."); - } - PrivateKey privateKey = (PrivateKey) keystore.getKey(alias, password); - if (privateKey == null) { - throw new IllegalStateException("Schlüssel '" + alias + "' im Keystore nicht gefunden."); - } - Certificate[] chain = keystore.getCertificateChain(alias); - if (chain == null || chain.length == 0) { - throw new IllegalStateException("Zertifikatskette für Alias '" + alias + "' leer."); - } - - EInvoiceProperties.Signing config = properties.getSigning(); - SignatureInterface signer = content -> buildCmsSignature(content, privateKey, chain); - - try (PDDocument document = Loader.loadPDF(pdfBytes); - ByteArrayOutputStream baos = new ByteArrayOutputStream()) { - PDSignature pdSignature = new PDSignature(); - pdSignature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE); - pdSignature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED); - pdSignature.setName("votianlt"); - if (notBlank(config.getReason())) { - pdSignature.setReason(config.getReason()); - } - if (notBlank(config.getLocation())) { - pdSignature.setLocation(config.getLocation()); - } - if (notBlank(config.getContact())) { - pdSignature.setContactInfo(config.getContact()); - } - pdSignature.setSignDate(Calendar.getInstance()); - - SignatureOptions options = new SignatureOptions(); - options.setPreferredSignatureSize(SignatureOptions.DEFAULT_SIGNATURE_SIZE * 2); - - document.addSignature(pdSignature, signer, options); - document.saveIncremental(baos); - options.close(); - return baos.toByteArray(); - } - } - - private byte[] buildCmsSignature(InputStream content, PrivateKey privateKey, Certificate[] chain) - throws IOException { - try { - byte[] data = IOUtils.toByteArray(content); - CMSTypedData typedData = new CMSProcessableByteArray(data); - - String providerName = bouncyCastleProviderName(); - JcaContentSignerBuilder signerBuilder = new JcaContentSignerBuilder("SHA256withRSA"); - if (providerName != null) { - signerBuilder.setProvider(providerName); - } - ContentSigner contentSigner = signerBuilder.build(privateKey); - - JcaDigestCalculatorProviderBuilder digestBuilder = new JcaDigestCalculatorProviderBuilder(); - if (providerName != null) { - digestBuilder.setProvider(providerName); - } - - JcaSignerInfoGeneratorBuilder infoGenBuilder = new JcaSignerInfoGeneratorBuilder(digestBuilder.build()); - - CMSSignedDataGenerator gen = new CMSSignedDataGenerator(); - gen.addSignerInfoGenerator(infoGenBuilder.build(contentSigner, (X509Certificate) chain[0])); - - List certList = new ArrayList<>(); - for (Certificate cert : chain) { - if (cert instanceof X509Certificate x509) { - certList.add(x509); - } - } - Store certStore = new JcaCertStore(certList); - gen.addCertificates(certStore); - - CMSSignedData signedData = gen.generate(typedData, false); - return signedData.getEncoded(); - } catch (CMSException | OperatorCreationException | java.security.cert.CertificateEncodingException ex) { - throw new IOException("CMS-Signatur fehlgeschlagen: " + ex.getMessage(), ex); - } - } - - private Invoice toMustangInvoice(CustomerInvoice invoice) { - Invoice mustang = new Invoice(); - mustang.setNumber(safe(invoice.getInvoiceNumber())); - mustang.setIssueDate(toDate(invoice.getInvoiceDate())); - mustang.setDeliveryDate(toDate(invoice.getDeliveryDate() != null ? invoice.getDeliveryDate() - : invoice.getInvoiceDate())); - if (invoice.getPaymentDueDate() != null) { - mustang.setDueDate(toDate(invoice.getPaymentDueDate())); - } - mustang.setSender(buildSender(invoice)); - mustang.setRecipient(buildRecipient(invoice)); - - BigDecimal vatPercent = toPercent(invoice.getVatRate()); - List items = invoice.getItems(); - if (items != null && !items.isEmpty()) { - for (CustomerInvoiceItem source : items) { - Product product = new Product(safe(source.getDescription()), "", DEFAULT_UNIT_CODE, vatPercent); - BigDecimal qty = source.getQuantity() != null ? source.getQuantity() : BigDecimal.ONE; - BigDecimal price = source.getUnitPrice() != null ? source.getUnitPrice() : BigDecimal.ZERO; - Item item = new Item(product, price, qty); - mustang.addItem(item); - } - } else { - // Fallback: ein Sammelposten mit dem Nettobetrag - Product product = new Product(safe(invoice.getDescription()), "", DEFAULT_UNIT_CODE, vatPercent); - BigDecimal price = invoice.getNetAmount() != null ? invoice.getNetAmount() : BigDecimal.ZERO; - mustang.addItem(new Item(product, price, BigDecimal.ONE)); - } - return mustang; - } - - private TradeParty buildSender(CustomerInvoice invoice) { - TradeParty sender = new TradeParty(safe(invoice.getSenderName()), safe(invoice.getSenderAddress()), - safe(invoice.getSenderPostcode()), safe(invoice.getSenderCity()), - safe(invoice.getSenderCountry(), DEFAULT_COUNTRY)); - if (notBlank(invoice.getSenderVatId())) { - sender.setVATID(invoice.getSenderVatId()); - } - if (notBlank(invoice.getSenderTaxNumber())) { - sender.setTaxID(invoice.getSenderTaxNumber()); - } - Contact contact = new Contact(safe(invoice.getSenderName()), safe(invoice.getSenderPhone()), - safe(invoice.getSenderEmail())); - sender.setContact(contact); - return sender; - } - - private TradeParty buildRecipient(CustomerInvoice invoice) { - String displayName = notBlank(invoice.getRecipientCompany()) ? invoice.getRecipientCompany() - : safe(invoice.getRecipientName()); - TradeParty recipient = new TradeParty(displayName, safe(invoice.getRecipientAddress()), - safe(invoice.getRecipientPostcode()), safe(invoice.getRecipientCity()), - safe(invoice.getRecipientCountry(), DEFAULT_COUNTRY)); - if (notBlank(invoice.getRecipientVatId())) { - recipient.setVATID(invoice.getRecipientVatId()); - } - if (notBlank(invoice.getRecipientName())) { - recipient.setContact(new Contact(invoice.getRecipientName(), "", "")); - } - return recipient; - } - - private BigDecimal toPercent(BigDecimal vatRate) { - if (vatRate == null) { - return new BigDecimal("19"); - } - return vatRate.multiply(new BigDecimal("100")).setScale(2, RoundingMode.HALF_UP); - } - - private Date toDate(LocalDate date) { - if (date == null) { - return Date.from(LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant()); - } - return Date.from(date.atStartOfDay(ZoneId.systemDefault()).toInstant()); - } - - private org.mustangproject.ZUGFeRD.Profile resolveProfile(String requested) { - if (requested == null) { - return Profiles.getByName("EN16931"); - } - try { - return Profiles.getByName(requested.toUpperCase()); - } catch (Exception ex) { - log.warn("Unbekanntes ZUGFeRD-Profil '{}', verwende EN16931.", requested); - return Profiles.getByName("EN16931"); - } - } - - private EInvoiceFormat resolveFormat(String profile) { - if (profile != null && profile.toUpperCase().contains("XRECHNUNG")) { - return EInvoiceFormat.XRECHNUNG; - } - return EInvoiceFormat.ZUGFERD; - } - - private static void ensureBouncyCastleProvider() { - if (Security.getProvider("BC") == null) { - try { - Class providerClass = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider"); - Provider provider = (Provider) providerClass.getDeclaredConstructor().newInstance(); - Security.addProvider(provider); - } catch (Exception ex) { - LoggerFactory.getLogger(EInvoiceService.class) - .warn("BouncyCastle Provider konnte nicht registriert werden: {}", ex.getMessage()); - } - } - } - - private static String bouncyCastleProviderName() { - return Security.getProvider("BC") != null ? "BC" : null; - } - - private boolean notBlank(String value) { - return value != null && !value.isBlank(); - } - - private String safe(String value) { - return value != null ? value : ""; - } - - private String safe(String value, String fallback) { - return value != null && !value.isBlank() ? value : fallback; - } - -} diff --git a/backend/src/main/java/de/assecutor/votianlt/service/InvoiceApprovalService.java b/backend/src/main/java/de/assecutor/votianlt/service/InvoiceApprovalService.java deleted file mode 100644 index ae2ab84..0000000 --- a/backend/src/main/java/de/assecutor/votianlt/service/InvoiceApprovalService.java +++ /dev/null @@ -1,219 +0,0 @@ -package de.assecutor.votianlt.service; - -import de.assecutor.votianlt.model.User; -import de.assecutor.votianlt.model.invoices.CustomerInvoice; -import de.assecutor.votianlt.model.invoices.InvoiceApprovalAction; -import de.assecutor.votianlt.model.invoices.InvoiceApprovalRequest; -import de.assecutor.votianlt.model.invoices.InvoiceApprovalStatus; -import de.assecutor.votianlt.pages.service.UserInvoiceDataService; -import de.assecutor.votianlt.repository.CustomerInvoiceRepository; -import de.assecutor.votianlt.repository.InvoiceApprovalRequestRepository; -import de.assecutor.votianlt.repository.UserRepository; -import org.bson.types.ObjectId; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Service; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; - -/** - * Freigabe-Workflow für kritische Rechnungsaktionen gemäß R-42. - * - * Nutzer ohne Approver-Rolle, deren Profil - * {@link User#isRequireApprovalForCriticalInvoiceActions()} aktiv hat, erzeugen über diesen - * Service eine Freigabe-Anfrage. Erst nach Freigabe durch einen Approver wird der - * tatsächliche Storno- bzw. Berichtigungsbeleg über den - * {@link InvoiceLifecycleService} erstellt. - */ -@Service -public class InvoiceApprovalService { - - private static final Logger log = LoggerFactory.getLogger(InvoiceApprovalService.class); - - private final InvoiceApprovalRequestRepository requestRepository; - private final CustomerInvoiceRepository invoiceRepository; - private final InvoiceLifecycleService lifecycleService; - private final InvoicePermissionService permissionService; - private final CustomerInvoiceService customerInvoiceService; - private final UserInvoiceDataService userInvoiceDataService; - private final UserRepository userRepository; - - public InvoiceApprovalService(InvoiceApprovalRequestRepository requestRepository, - CustomerInvoiceRepository invoiceRepository, InvoiceLifecycleService lifecycleService, - InvoicePermissionService permissionService, CustomerInvoiceService customerInvoiceService, - UserInvoiceDataService userInvoiceDataService, UserRepository userRepository) { - this.requestRepository = requestRepository; - this.invoiceRepository = invoiceRepository; - this.lifecycleService = lifecycleService; - this.permissionService = permissionService; - this.customerInvoiceService = customerInvoiceService; - this.userInvoiceDataService = userInvoiceDataService; - this.userRepository = userRepository; - } - - public InvoiceApprovalRequest requestCancellation(String invoiceId, String reason) { - return submitRequest(invoiceId, InvoiceApprovalAction.CANCEL_INVOICE, reason, null); - } - - public InvoiceApprovalRequest requestCorrection(String invoiceId, String correctedFields, String reason) { - return submitRequest(invoiceId, InvoiceApprovalAction.CORRECT_INVOICE, reason, correctedFields); - } - - private InvoiceApprovalRequest submitRequest(String invoiceId, InvoiceApprovalAction action, String reason, - String correctedFields) { - if (reason == null || reason.isBlank()) { - throw new InvoiceLifecycleException("Bitte einen Grund für die Anfrage angeben."); - } - CustomerInvoice invoice = invoiceRepository.findById(invoiceId) - .orElseThrow(() -> new IllegalStateException("Rechnung nicht gefunden: " + invoiceId)); - - User requester = permissionService.currentUser(); - InvoiceApprovalRequest request = new InvoiceApprovalRequest(); - request.setAction(action); - request.setStatus(InvoiceApprovalStatus.PENDING); - request.setTargetInvoiceId(invoice.getId()); - request.setTargetInvoiceNumber(invoice.getInvoiceNumber()); - request.setRequestedByUserId(requester.getId() != null ? requester.getId().toHexString() : null); - request.setRequestedByDisplayName(displayName(requester)); - request.setRequestedAt(LocalDateTime.now()); - request.setReason(reason); - request.setCorrectedFields(correctedFields); - InvoiceApprovalRequest saved = requestRepository.save(request); - log.info("Freigabe-Anfrage {} angelegt für Rechnung {} ({}).", saved.getId(), invoice.getInvoiceNumber(), - action); - return saved; - } - - public InvoiceApprovalRequest approve(String requestId, String reviewerComment) { - InvoiceApprovalRequest request = requireRequest(requestId); - User reviewer = permissionService.currentUser(); - permissionService.requireApprove(reviewer); - if (request.getStatus() != InvoiceApprovalStatus.PENDING) { - throw new InvoiceLifecycleException("Diese Anfrage wurde bereits bearbeitet."); - } - - CustomerInvoice originalInvoice = invoiceRepository.findById(request.getTargetInvoiceId()).orElseThrow( - () -> new IllegalStateException("Rechnung nicht gefunden: " + request.getTargetInvoiceId())); - User issuer = resolveInvoiceIssuer(originalInvoice, reviewer); - - CustomerInvoice resulting; - try { - resulting = switch (request.getAction()) { - case CANCEL_INVOICE -> executeCancellation(originalInvoice, issuer, request, reviewer); - case CORRECT_INVOICE -> executeCorrection(originalInvoice, issuer, request, reviewer); - }; - } catch (InvoiceLifecycleException ex) { - log.warn("Lifecycle-Verstoß bei Freigabe {}: {}", requestId, ex.getMessage()); - throw ex; - } catch (RuntimeException ex) { - log.error("Freigabe-Aktion {} für Anfrage {} fehlgeschlagen: {}", request.getAction(), requestId, - ex.getMessage(), ex); - throw ex; - } catch (Exception ex) { - log.error("Freigabe-Aktion {} für Anfrage {} fehlgeschlagen: {}", request.getAction(), requestId, - ex.getMessage(), ex); - throw new InvoiceLifecycleException("Freigabe konnte nicht ausgeführt werden: " + ex.getMessage(), ex); - } - - request.setStatus(InvoiceApprovalStatus.APPROVED); - request.setReviewedByUserId(reviewer.getId() != null ? reviewer.getId().toHexString() : null); - request.setReviewedByDisplayName(displayName(reviewer)); - request.setReviewedAt(LocalDateTime.now()); - request.setReviewerComment(reviewerComment); - request.setResultingInvoiceId(resulting.getId()); - request.setResultingInvoiceNumber(resulting.getInvoiceNumber()); - return requestRepository.save(request); - } - - public InvoiceApprovalRequest reject(String requestId, String reviewerComment) { - InvoiceApprovalRequest request = requireRequest(requestId); - User reviewer = permissionService.currentUser(); - permissionService.requireApprove(reviewer); - if (request.getStatus() != InvoiceApprovalStatus.PENDING) { - throw new InvoiceLifecycleException("Diese Anfrage wurde bereits bearbeitet."); - } - request.setStatus(InvoiceApprovalStatus.REJECTED); - request.setReviewedByUserId(reviewer.getId() != null ? reviewer.getId().toHexString() : null); - request.setReviewedByDisplayName(displayName(reviewer)); - request.setReviewedAt(LocalDateTime.now()); - request.setReviewerComment(reviewerComment); - return requestRepository.save(request); - } - - public List findPending() { - return requestRepository.findByStatus(InvoiceApprovalStatus.PENDING); - } - - public List findOpenForCurrentRequester() { - User user = permissionService.currentUser(); - if (user == null || user.getId() == null) { - return List.of(); - } - return requestRepository.findByRequestedByUserIdAndStatus(user.getId().toHexString(), - InvoiceApprovalStatus.PENDING); - } - - private CustomerInvoice executeCancellation(CustomerInvoice original, User issuer, - InvoiceApprovalRequest request, User reviewer) throws Exception { - String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId()); - LocalDate today = LocalDate.now(); - String reason = composeReason(request, reviewer); - byte[] pdf = customerInvoiceService.generateCancellationPdf(original, number, today, reason); - return lifecycleService.cancel(original.getId(), number, today, pdf, reason); - } - - private CustomerInvoice executeCorrection(CustomerInvoice original, User issuer, - InvoiceApprovalRequest request, User reviewer) throws Exception { - String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId()); - LocalDate today = LocalDate.now(); - String reason = composeReason(request, reviewer); - byte[] pdf = customerInvoiceService.generateCorrectionPdf(original, number, today, reason, - request.getCorrectedFields()); - return lifecycleService.correct(original.getId(), number, today, pdf, request.getCorrectedFields(), reason); - } - - private String composeReason(InvoiceApprovalRequest request, User reviewer) { - StringBuilder sb = new StringBuilder(); - if (request.getReason() != null && !request.getReason().isBlank()) { - sb.append(request.getReason()); - } else { - sb.append("Freigabe erteilt"); - } - sb.append(" — Anfrage von ").append(safe(request.getRequestedByDisplayName())); - sb.append(", freigegeben durch ").append(displayName(reviewer)); - return sb.toString(); - } - - private InvoiceApprovalRequest requireRequest(String requestId) { - if (requestId == null || requestId.isBlank()) { - throw new IllegalArgumentException("Anfrage-ID erforderlich."); - } - return requestRepository.findById(requestId) - .orElseThrow(() -> new IllegalStateException("Freigabe-Anfrage nicht gefunden: " + requestId)); - } - - private User resolveInvoiceIssuer(CustomerInvoice invoice, User fallback) { - if (invoice.getUserId() == null || invoice.getUserId().isBlank()) { - return fallback; - } - try { - return userRepository.findById(new ObjectId(invoice.getUserId())).orElse(fallback); - } catch (IllegalArgumentException ex) { - return fallback; - } - } - - private String displayName(User user) { - if (user == null) { - return "system"; - } - String composed = (safe(user.getFirstname()) + " " + safe(user.getName())).trim(); - return composed.isBlank() ? safe(user.getEmail()) : composed; - } - - private String safe(String value) { - return value != null ? value : ""; - } -} diff --git a/backend/src/main/java/de/assecutor/votianlt/service/InvoiceLifecycleService.java b/backend/src/main/java/de/assecutor/votianlt/service/InvoiceLifecycleService.java index d9bf4d9..6f71cfa 100644 --- a/backend/src/main/java/de/assecutor/votianlt/service/InvoiceLifecycleService.java +++ b/backend/src/main/java/de/assecutor/votianlt/service/InvoiceLifecycleService.java @@ -2,7 +2,6 @@ package de.assecutor.votianlt.service; import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.invoices.CustomerInvoice; -import de.assecutor.votianlt.model.invoices.EInvoiceFormat; import de.assecutor.votianlt.model.invoices.InvoiceAuditAction; import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry; import de.assecutor.votianlt.model.invoices.InvoiceStatus; @@ -43,16 +42,14 @@ public class InvoiceLifecycleService { private final CustomerInvoiceRepository invoiceRepository; private final SecurityService securityService; - private final EInvoiceService eInvoiceService; private final InvoiceComplianceValidator complianceValidator; private final InvoiceNumberAuditService numberAuditService; public InvoiceLifecycleService(CustomerInvoiceRepository invoiceRepository, SecurityService securityService, - EInvoiceService eInvoiceService, InvoiceComplianceValidator complianceValidator, + InvoiceComplianceValidator complianceValidator, InvoiceNumberAuditService numberAuditService) { this.invoiceRepository = invoiceRepository; this.securityService = securityService; - this.eInvoiceService = eInvoiceService; this.complianceValidator = complianceValidator; this.numberAuditService = numberAuditService; } @@ -212,7 +209,7 @@ public class InvoiceLifecycleService { cancellation.setTotalAmount(negate(original.getTotalAmount())); cancellation.setDescription("Stornorechnung zu Rechnung " + original.getInvoiceNumber()); - cancellation.setPdfData(applyEInvoiceIfApplicable(pdfData, cancellation, original)); + cancellation.setPdfData(pdfData); cancellation.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason)); InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason); issuedEntry.setResultingInvoiceNumber(cancellationNumber); @@ -297,7 +294,7 @@ public class InvoiceLifecycleService { correction.setDescription( correctedFields == null || correctedFields.isBlank() ? descriptionPrefix : descriptionPrefix + " — " + correctedFields); - correction.setPdfData(applyEInvoiceIfApplicable(pdfData, correction, original)); + correction.setPdfData(pdfData); correction.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason)); InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason); issuedEntry.setResultingInvoiceNumber(correctionNumber); @@ -536,37 +533,4 @@ public class InvoiceLifecycleService { private BigDecimal negate(BigDecimal value) { return value != null ? value.negate() : null; } - - /** - * Reichert ein PDF mit ZUGFeRD-XML an und signiert es, falls Mustangproject systemweit - * aktiviert ist und das Original bereits ein E-Rechnungsformat hatte. So bleibt das - * Format eines Storno- oder Berichtigungsbelegs konsistent zur Originalrechnung. - * - * Fehlt das Signatur-Zertifikat oder kann es nicht entschlüsselt werden, wird die - * {@link InvoiceLifecycleException} bewusst durchgereicht — der Anwender soll die - * Storno-/Berichtigungs-Aktion korrigieren können (Zertifikat hochladen, - * Master-Key prüfen). Andere Fehlerklassen (z.B. PDF-Strukturfehler bei der - * ZUGFeRD-Anreicherung) bleiben graceful: das Roh-PDF wird zurückgegeben. - */ - private byte[] applyEInvoiceIfApplicable(byte[] pdfData, CustomerInvoice followUp, CustomerInvoice original) { - if (pdfData == null || pdfData.length == 0 || eInvoiceService == null || original == null) { - return pdfData; - } - boolean originalHadZugferd = original.getEInvoiceFormat() != null - && original.getEInvoiceFormat() != EInvoiceFormat.NONE; - boolean originalWasSigned = original.isSigned(); - boolean wantsZugferd = originalHadZugferd && eInvoiceService.isEInvoiceEnabledGlobally(); - if (!wantsZugferd && !originalWasSigned) { - return pdfData; - } - try { - return eInvoiceService.enhanceAndSign(pdfData, followUp, wantsZugferd, originalWasSigned); - } catch (InvoiceLifecycleException ex) { - // Signatur-/Zertifikatsproblem dem Anwender sichtbar machen - throw ex; - } catch (Exception ex) { - log.warn("E-Invoice-Anreicherung des Folgebelegs fehlgeschlagen: {}", ex.getMessage(), ex); - return pdfData; - } - } } diff --git a/backend/src/main/java/de/assecutor/votianlt/service/InvoicePermissionService.java b/backend/src/main/java/de/assecutor/votianlt/service/InvoicePermissionService.java index 2ccbb16..5427e01 100644 --- a/backend/src/main/java/de/assecutor/votianlt/service/InvoicePermissionService.java +++ b/backend/src/main/java/de/assecutor/votianlt/service/InvoicePermissionService.java @@ -50,24 +50,6 @@ public class InvoicePermissionService { return hasAnyInvoiceRole(user, InvoiceRoles.ACCOUNTANT, InvoiceRoles.APPROVER) || isUnscoped(user); } - public boolean canApproveRequests(User user) { - return hasAnyInvoiceRole(user, InvoiceRoles.APPROVER) || isAdmin(user); - } - - /** - * Liefert {@code true}, wenn die Aktion dieses Nutzers vor Ausführung eine Freigabe benötigt - * (R-42). Approver können ihre eigenen Aktionen direkt ausführen. - */ - public boolean requiresApproval(User user) { - if (user == null) { - return false; - } - if (canApproveRequests(user)) { - return false; - } - return user.isRequireApprovalForCriticalInvoiceActions(); - } - public void requireCreate(User user) { if (!canCreateOrIssue(user)) { throw new InvoiceLifecycleException( @@ -102,12 +84,6 @@ public class InvoicePermissionService { } } - public void requireApprove(User user) { - if (!canApproveRequests(user)) { - throw new InvoiceLifecycleException("Sie haben keine Freigabe-Berechtigung."); - } - } - /** * Convenience: prüft, ob der Nutzer Eigentümer einer Rechnung ist (oder Admin). * Wird genutzt, um Cross-Tenant-Zugriffe zu verhindern. diff --git a/backend/src/main/java/de/assecutor/votianlt/service/SigningCredentialsService.java b/backend/src/main/java/de/assecutor/votianlt/service/SigningCredentialsService.java deleted file mode 100644 index e90896d..0000000 --- a/backend/src/main/java/de/assecutor/votianlt/service/SigningCredentialsService.java +++ /dev/null @@ -1,237 +0,0 @@ -package de.assecutor.votianlt.service; - -import de.assecutor.votianlt.config.EInvoiceProperties; -import de.assecutor.votianlt.model.invoices.UserSigningCredentials; -import de.assecutor.votianlt.repository.UserSigningCredentialsRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.io.ByteArrayInputStream; -import java.nio.charset.StandardCharsets; -import java.security.KeyStore; -import java.security.PrivateKey; -import java.security.cert.Certificate; -import java.security.cert.X509Certificate; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.Date; -import java.util.Optional; - -/** - * Verwaltet pro Nutzer hinterlegte PKCS#12-Signatur-Credentials. - * - * Beim Speichern wird der Keystore validiert (Alias vorhanden, privater Schlüssel - * extrahierbar, Zertifikat noch gültig) und anschließend mitsamt Passwort über - * AES-256-GCM unter dem konfigurierten Master-Key verschlüsselt. - * - * Beim Laden wird der Keystore zur Laufzeit entschlüsselt und in eine - * {@link LoadedCredentials}-Instanz verpackt; die Klar-Werte verlassen nie diese - * Service-Schicht. - */ -@Service -public class SigningCredentialsService { - - private static final Logger log = LoggerFactory.getLogger(SigningCredentialsService.class); - - private final UserSigningCredentialsRepository repository; - private final EInvoiceProperties properties; - - public SigningCredentialsService(UserSigningCredentialsRepository repository, EInvoiceProperties properties) { - this.repository = repository; - this.properties = properties; - } - - public Optional findCredentials(String userId) { - if (userId == null || userId.isBlank()) { - return Optional.empty(); - } - return repository.findByUserId(userId); - } - - /** - * Liefert die entschlüsselten Credentials, sofern hinterlegt und der Master-Key passt. - * Liefert {@link Optional#empty()}, wenn keine Credentials hinterlegt sind oder die - * Entschlüsselung fehlschlägt — dann fällt der Caller auf den System-Keystore zurück. - */ - public Optional loadActive(String userId) { - return findCredentials(userId).filter(UserSigningCredentials::isEnabled).flatMap(this::decrypt); - } - - @Transactional - public UserSigningCredentials store(String userId, byte[] p12Bytes, String password, String alias) { - if (userId == null || userId.isBlank()) { - throw new IllegalArgumentException("userId erforderlich."); - } - if (p12Bytes == null || p12Bytes.length == 0) { - throw new IllegalArgumentException("Keystore-Inhalt erforderlich."); - } - if (password == null) { - throw new IllegalArgumentException("Passwort erforderlich."); - } - if (alias == null || alias.isBlank()) { - throw new IllegalArgumentException("Schlüssel-Alias erforderlich."); - } - ensureMasterKey(); - - // Validieren — wirft bei falschen Daten eine aussagekräftige Exception. - ValidationResult validation = validate(p12Bytes, password, alias); - - AesGcmCipher cipher = newCipher(); - byte[] encryptedKeystore = cipher.encrypt(p12Bytes); - byte[] encryptedPassword = cipher.encrypt(password.getBytes(StandardCharsets.UTF_8)); - - UserSigningCredentials credentials = repository.findByUserId(userId).orElseGet(UserSigningCredentials::new); - credentials.setUserId(userId); - credentials.setEncryptedKeystore(encryptedKeystore); - credentials.setEncryptedPassword(java.util.Base64.getEncoder().encodeToString(encryptedPassword)); - credentials.setKeyAlias(alias); - credentials.setSubjectDn(validation.subjectDn); - credentials.setIssuerDn(validation.issuerDn); - credentials.setSerialNumber(validation.serialNumber); - credentials.setValidFrom(validation.validFrom); - credentials.setValidUntil(validation.validUntil); - credentials.setEnabled(true); - credentials.setUpdatedAt(LocalDateTime.now()); - if (credentials.getCreatedAt() == null) { - credentials.setCreatedAt(LocalDateTime.now()); - } - return repository.save(credentials); - } - - @Transactional - public void deleteForUser(String userId) { - if (userId == null || userId.isBlank()) { - return; - } - repository.deleteByUserId(userId); - } - - @Transactional - public void setEnabled(String userId, boolean enabled) { - repository.findByUserId(userId).ifPresent(credentials -> { - credentials.setEnabled(enabled); - credentials.setUpdatedAt(LocalDateTime.now()); - repository.save(credentials); - }); - } - - private Optional 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 8f1a33c..ad53bf9 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -95,34 +95,4 @@ 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 - -# =========================================== -# 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 +spring.ai.mcp.server.sse-message-endpoint=/mcp/message \ 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 304f22a..aa4e5b2 100644 --- a/backend/src/main/resources/messages_de.properties +++ b/backend/src/main/resources/messages_de.properties @@ -9,17 +9,6 @@ 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 @@ -58,36 +47,6 @@ 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 @@ -798,8 +757,6 @@ 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 @@ -815,28 +772,6 @@ 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 d046770..46f56f1 100644 --- a/backend/src/main/resources/messages_en.properties +++ b/backend/src/main/resources/messages_en.properties @@ -9,17 +9,6 @@ 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 @@ -58,36 +47,6 @@ 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 @@ -798,8 +757,6 @@ 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 @@ -815,28 +772,6 @@ 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 deleted file mode 100644 index c30e8d1..0000000 --- a/backend/src/test/java/de/assecutor/votianlt/service/DatevExportServiceTest.java +++ /dev/null @@ -1,173 +0,0 @@ -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 deleted file mode 100644 index a312a4d..0000000 --- a/backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceDssValidationTest.java +++ /dev/null @@ -1,271 +0,0 @@ -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 deleted file mode 100644 index 10767aa..0000000 --- a/backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceTest.java +++ /dev/null @@ -1,331 +0,0 @@ -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; - } -}