From d699609aa149c6c14a444b2980e5a12581b19fa3 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Mon, 4 May 2026 10:34:04 +0200 Subject: [PATCH] feat: E-Rechnungs-Backend, Pflichtangaben-Validator, Nummern-Audit und DATEV-Export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Größerer Wurf rund um Rechnungen: das vollständige E-Rechnungs- und Lifecycle-Backend wird eingecheckt, die UI-seitige Rechnungserstellung wird zugunsten eines DATEV-Exports zurückgebaut, und die Test-Infrastruktur wird auf JDK 25 angehoben. E-Rechnung & Signatur - ZUGFeRD/Factur-X/XRechnung-Anreicherung via Mustang (EInvoiceService). - PAdES-Detached-Signatur via PDFBox + BouncyCastle, System- und Nutzer- Keystore-Pfad, Nutzer-Keystores AES-GCM-verschlüsselt (AesGcmCipher, SigningCredentialsService, UserSigningCredentials). - Konfiguration via EInvoiceProperties + EInvoiceSecurityInitializer. - Approval-Workflow für kritische Rechnungsvorgänge (Storno, Berichtigung): InvoiceApprovalService, InvoiceApprovalRequest, ApprovalsView, InvoicePermissionService, InvoiceRoles. Lifecycle & Audit - InvoiceStatus DRAFT/ISSUED/SENT/CANCELLED/CORRECTED, InvoiceType INVOICE/CORRECTION/CANCELLATION, PaymentStatus, lückenloser Audit-Trail via InvoiceAuditEntry/Action. - InvoiceLifecycleService verwaltet alle Übergänge inklusive Storno- und Berichtigungsbelegen mit Querverweis zur Originalrechnung. - InvoiceLifecycleMigration zieht Bestandsdaten in das neue Lifecycle-Modell. - InvoiceExportService bündelt Original + Folgebelege als ZIP für die Auslieferung an den Steuerberater. Pflichtangaben-Validator (§ 14 UStG) - InvoiceComplianceValidator + Exception sammeln alle Verstöße in einem Lauf (Pflichtfelder, Adressen, Steuernummer/USt-IdNr, Items, Betrags- konsistenz, Hinweispflicht bei 0 % USt). - Wird vor jedem DRAFT-→-ISSUED-Übergang im Lifecycle aufgerufen, sodass festgeschriebene Rechnungen keine Pflichtfeld-Lücken mehr haben können. Rechnungsnummer-Audit (§ 14 Abs. 4 Nr. 4 UStG / GoBD) - InvoiceNumberReservation + Status RESERVED/USED/VOIDED protokollieren jede aus dem Counter gezogene Nummer. - UserInvoiceDataService schreibt bei Vergabe ein RESERVED-Audit, der Lifecycle setzt nach Festschreiben USED bzw. nach Löschen eines Entwurfs VOIDED — Lücken im Nummernkreis sind dadurch erklärbar. - InvoiceNumberAuditService liefert markUsed/markVoided/findUnused für Folge-UI und Betriebsprüfungs-Reports. UI-Rückbau und DATEV-Export - Routen für CreateInvoiceView, InvoicesView, MyInvoicesView auskommentiert (Code bleibt erhalten, Reaktivierung dokumentiert). - Rechnungs-Buttons aus ShowJobsView entfernt, Nav-Eintrag „Rechnungen" durch „DATEV-Export" ersetzt. - DatevExportService erzeugt einen DATEV-EXTF-Buchungsstapel (Version 7, Windows-1252, CRLF) mit SKR03-Erlöskonten (8400/8300/8125), Sammel- debitor 10000 und korrektem S/H-Verhalten für Stornorechnungen. - DatevExportView mit Zeitraum-Picker und Auto-Download. - i18n-Keys (de/en) für nav.datev.export und datev.export.*. Tests & Build - EInvoiceServiceTest (Signatur-Pfade), EInvoiceServiceDssValidationTest (PAdES-Profil via EU DSS 6.2 — dokumentiert PKCS7-B als Ist-Stand), InvoiceComplianceValidatorTest (26 Cases als Spezifikation der Pflichtangaben), InvoiceNumberAuditServiceTest, DatevExportServiceTest. - Mockito 5.18 + ByteBuddy 1.17.5 in gepinnt; die Spring-Boot-3.4.3-Defaults (Mockito 5.14.2 / ByteBuddy 1.15.11) konnten den Inline-Mock-Maker auf JDK 25 nicht laden, weshalb die beiden DemoModeServiceTests vorher rot waren. - DSS-Test-Dependencies (dss-pades-pdfbox, dss-validation, dss-utils-apache-commons, dss-crl-parser-x509crl 6.2) im Test-Scope. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/pom.xml | 65 ++ .../votianlt/config/EInvoiceProperties.java | 181 +++++ .../config/EInvoiceSecurityInitializer.java | 118 ++++ .../config/InvoiceLifecycleMigration.java | 59 ++ .../de/assecutor/votianlt/model/User.java | 25 + .../model/invoices/CustomerInvoice.java | 197 ++++++ .../model/invoices/EInvoiceFormat.java | 12 + .../model/invoices/InvoiceApprovalAction.java | 11 + .../invoices/InvoiceApprovalRequest.java | 179 +++++ .../model/invoices/InvoiceApprovalStatus.java | 5 + .../model/invoices/InvoiceAuditAction.java | 8 + .../model/invoices/InvoiceAuditEntry.java | 89 +++ .../invoices/InvoiceNumberReservation.java | 61 ++ .../InvoiceNumberReservationStatus.java | 17 + .../model/invoices/InvoiceStatus.java | 22 + .../votianlt/model/invoices/InvoiceType.java | 14 + .../model/invoices/PaymentStatus.java | 14 + .../invoices/UserSigningCredentials.java | 163 +++++ .../ui/component/SigningCredentialsPanel.java | 255 +++++++ .../pages/base/ui/view/MainLayout.java | 6 +- .../pages/service/UserInvoiceDataService.java | 71 +- .../votianlt/pages/view/ApprovalsView.java | 205 ++++++ .../pages/view/CreateInvoiceView.java | 44 +- .../votianlt/pages/view/DatevExportView.java | 117 ++++ .../votianlt/pages/view/EditProfileView.java | 33 +- .../votianlt/pages/view/InvoicesView.java | 634 ++++++++++++++++-- .../votianlt/pages/view/MyInvoicesView.java | 9 +- .../votianlt/pages/view/ShowJobsView.java | 36 +- .../repository/CustomerInvoiceRepository.java | 10 + .../InvoiceApprovalRequestRepository.java | 19 + .../InvoiceNumberReservationRepository.java | 23 + .../UserSigningCredentialsRepository.java | 15 + .../votianlt/security/InvoiceRoles.java | 26 + .../votianlt/service/AesGcmCipher.java | 71 ++ .../service/CustomerInvoiceService.java | 177 +++++ .../votianlt/service/DatevExportService.java | 230 +++++++ .../votianlt/service/EInvoiceService.java | 472 +++++++++++++ .../service/InvoiceApprovalService.java | 219 ++++++ .../service/InvoiceComplianceException.java | 35 + .../service/InvoiceComplianceValidator.java | 189 ++++++ .../service/InvoiceExportService.java | 182 +++++ .../service/InvoiceLifecycleException.java | 18 + .../service/InvoiceLifecycleService.java | 572 ++++++++++++++++ .../service/InvoiceNumberAuditService.java | 166 +++++ .../service/InvoicePermissionService.java | 164 +++++ .../service/SigningCredentialsService.java | 237 +++++++ .../src/main/resources/application.properties | 32 +- .../src/main/resources/messages_de.properties | 131 ++++ .../src/main/resources/messages_en.properties | 131 ++++ .../service/DatevExportServiceTest.java | 173 +++++ .../EInvoiceServiceDssValidationTest.java | 271 ++++++++ .../votianlt/service/EInvoiceServiceTest.java | 331 +++++++++ .../InvoiceComplianceValidatorTest.java | 304 +++++++++ .../InvoiceNumberAuditServiceTest.java | 171 +++++ 54 files changed, 6930 insertions(+), 89 deletions(-) create mode 100644 backend/src/main/java/de/assecutor/votianlt/config/EInvoiceProperties.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/config/EInvoiceSecurityInitializer.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/config/InvoiceLifecycleMigration.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/model/invoices/EInvoiceFormat.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceApprovalAction.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceApprovalRequest.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceApprovalStatus.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceAuditAction.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceAuditEntry.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceNumberReservation.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceNumberReservationStatus.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceStatus.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceType.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/model/invoices/PaymentStatus.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/model/invoices/UserSigningCredentials.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/SigningCredentialsPanel.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/pages/view/ApprovalsView.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/pages/view/DatevExportView.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/repository/InvoiceApprovalRequestRepository.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/repository/InvoiceNumberReservationRepository.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/repository/UserSigningCredentialsRepository.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/security/InvoiceRoles.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/service/AesGcmCipher.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/service/DatevExportService.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/service/EInvoiceService.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/service/InvoiceApprovalService.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/service/InvoiceComplianceException.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/service/InvoiceComplianceValidator.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/service/InvoiceExportService.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/service/InvoiceLifecycleException.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/service/InvoiceLifecycleService.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/service/InvoiceNumberAuditService.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/service/InvoicePermissionService.java create mode 100644 backend/src/main/java/de/assecutor/votianlt/service/SigningCredentialsService.java create mode 100644 backend/src/test/java/de/assecutor/votianlt/service/DatevExportServiceTest.java create mode 100644 backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceDssValidationTest.java create mode 100644 backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceTest.java create mode 100644 backend/src/test/java/de/assecutor/votianlt/service/InvoiceComplianceValidatorTest.java create mode 100644 backend/src/test/java/de/assecutor/votianlt/service/InvoiceNumberAuditServiceTest.java diff --git a/backend/pom.xml b/backend/pom.xml index 52e8a7e..0d872ec 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -44,6 +44,31 @@ pom import + + + org.mockito + mockito-core + 5.18.0 + + + org.mockito + mockito-junit-jupiter + 5.18.0 + + + net.bytebuddy + byte-buddy + 1.17.5 + + + net.bytebuddy + byte-buddy-agent + 1.17.5 + @@ -159,6 +184,20 @@ 5.0.5 + + + org.bouncycastle + bcpkix-jdk18on + 1.78 + + + + + org.mustangproject + library + 2.16.0 + + org.springframework.ai @@ -183,6 +222,32 @@ 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 new file mode 100644 index 0000000..6d76c9b --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/config/EInvoiceProperties.java @@ -0,0 +1,181 @@ +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 new file mode 100644 index 0000000..ed3f91e --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/config/EInvoiceSecurityInitializer.java @@ -0,0 +1,118 @@ +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: + * + * + * + * 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/config/InvoiceLifecycleMigration.java b/backend/src/main/java/de/assecutor/votianlt/config/InvoiceLifecycleMigration.java new file mode 100644 index 0000000..8381220 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/config/InvoiceLifecycleMigration.java @@ -0,0 +1,59 @@ +package de.assecutor.votianlt.config; + +import de.assecutor.votianlt.model.invoices.CustomerInvoice; +import de.assecutor.votianlt.model.invoices.InvoiceAuditAction; +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.repository.CustomerInvoiceRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.CommandLineRunner; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Einmalige Migration der vorhandenen Rechnungen auf das neue Lifecycle-Modell. + * + * Bestandsdaten haben weder Status noch Typ. Sie werden konservativ auf + * type=INVOICE, status=ISSUED gesetzt – sie sind in der Praxis bereits + * ausgestellt, denn sie tragen eine Rechnungsnummer und enthalten ein PDF. + * + * Ein Audit-Eintrag dokumentiert die Migration (R-37). + */ +@Component +@Order(50) +public class InvoiceLifecycleMigration implements CommandLineRunner { + + private static final Logger log = LoggerFactory.getLogger(InvoiceLifecycleMigration.class); + + private final CustomerInvoiceRepository invoiceRepository; + + public InvoiceLifecycleMigration(CustomerInvoiceRepository invoiceRepository) { + this.invoiceRepository = invoiceRepository; + } + + @Override + public void run(String... args) { + List legacyInvoices = invoiceRepository.findByStatusIsNull(); + if (legacyInvoices.isEmpty()) { + return; + } + LocalDateTime now = LocalDateTime.now(); + for (CustomerInvoice invoice : legacyInvoices) { + invoice.setType(InvoiceType.INVOICE); + invoice.setStatus(InvoiceStatus.ISSUED); + if (invoice.getIssuedAt() == null) { + invoice.setIssuedAt(now); + } + InvoiceAuditEntry entry = new InvoiceAuditEntry(InvoiceAuditAction.ISSUED, null, "system", + "Migration: Bestandsrechnung auf Status ISSUED gesetzt."); + invoice.addAuditEntry(entry); + } + invoiceRepository.saveAll(legacyInvoices); + log.info("Lifecycle-Migration: {} Bestandsrechnungen auf Status ISSUED gesetzt.", legacyInvoices.size()); + } +} 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 3f5a5f0..aae3077 100644 --- a/backend/src/main/java/de/assecutor/votianlt/model/User.java +++ b/backend/src/main/java/de/assecutor/votianlt/model/User.java @@ -72,4 +72,29 @@ 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 ed7044a..33ca4e9 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 @@ -3,7 +3,9 @@ package de.assecutor.votianlt.model.invoices; import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; import java.time.LocalDate; +import java.time.LocalDateTime; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.List; @Document(collection = "customerInvoices") @@ -12,6 +14,39 @@ public class CustomerInvoice { @Id private String id; + // Lebenszyklus und Belegtyp gemäß invoices_rules R-01 bis R-22 + private InvoiceStatus status = InvoiceStatus.DRAFT; + private InvoiceType type = InvoiceType.INVOICE; + + // Verknüpfung auf die Originalrechnung bei Korrektur- oder Stornobelegen (R-10, R-13, R-19, R-22, R-30) + private String originalInvoiceId; + private String originalInvoiceNumber; + private LocalDate originalInvoiceDate; + + // Verkettung: bei stornierten/korrigierten Originalen Verweise auf die erzeugten Folgebelege. + private String cancellationInvoiceId; + private String correctionInvoiceId; + private String replacementInvoiceId; + + // Zeitstempel für Statusübergänge + private LocalDateTime issuedAt; + private LocalDateTime sentAt; + private LocalDateTime cancelledAt; + + // Änderungsprotokoll (R-36 bis R-39) + private List auditLog = new ArrayList<>(); + + // Zahlungsstatus gemäß R-23 bis R-26 + private PaymentStatus paymentStatus = PaymentStatus.UNPAID; + 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 @@ -372,4 +407,166 @@ public class CustomerInvoice { public void setPdfData(byte[] pdfData) { this.pdfData = pdfData; } + + public InvoiceStatus getStatus() { + return status; + } + + public void setStatus(InvoiceStatus status) { + this.status = status; + } + + public InvoiceType getType() { + return type; + } + + public void setType(InvoiceType type) { + this.type = type; + } + + public String getOriginalInvoiceId() { + return originalInvoiceId; + } + + public void setOriginalInvoiceId(String originalInvoiceId) { + this.originalInvoiceId = originalInvoiceId; + } + + public String getOriginalInvoiceNumber() { + return originalInvoiceNumber; + } + + public void setOriginalInvoiceNumber(String originalInvoiceNumber) { + this.originalInvoiceNumber = originalInvoiceNumber; + } + + public LocalDate getOriginalInvoiceDate() { + return originalInvoiceDate; + } + + public void setOriginalInvoiceDate(LocalDate originalInvoiceDate) { + this.originalInvoiceDate = originalInvoiceDate; + } + + public String getCancellationInvoiceId() { + return cancellationInvoiceId; + } + + public void setCancellationInvoiceId(String cancellationInvoiceId) { + this.cancellationInvoiceId = cancellationInvoiceId; + } + + public String getCorrectionInvoiceId() { + return correctionInvoiceId; + } + + public void setCorrectionInvoiceId(String correctionInvoiceId) { + this.correctionInvoiceId = correctionInvoiceId; + } + + public String getReplacementInvoiceId() { + return replacementInvoiceId; + } + + public void setReplacementInvoiceId(String replacementInvoiceId) { + this.replacementInvoiceId = replacementInvoiceId; + } + + public LocalDateTime getIssuedAt() { + return issuedAt; + } + + public void setIssuedAt(LocalDateTime issuedAt) { + this.issuedAt = issuedAt; + } + + public LocalDateTime getSentAt() { + return sentAt; + } + + public void setSentAt(LocalDateTime sentAt) { + this.sentAt = sentAt; + } + + public LocalDateTime getCancelledAt() { + return cancelledAt; + } + + public void setCancelledAt(LocalDateTime cancelledAt) { + this.cancelledAt = cancelledAt; + } + + public List getAuditLog() { + if (auditLog == null) { + auditLog = new ArrayList<>(); + } + return auditLog; + } + + public void setAuditLog(List auditLog) { + this.auditLog = auditLog != null ? auditLog : new ArrayList<>(); + } + + public void addAuditEntry(InvoiceAuditEntry entry) { + if (entry == null) { + return; + } + getAuditLog().add(entry); + } + + public PaymentStatus getPaymentStatus() { + return paymentStatus; + } + + public void setPaymentStatus(PaymentStatus paymentStatus) { + this.paymentStatus = paymentStatus; + } + + public BigDecimal getPaidAmount() { + return paidAmount; + } + + public void setPaidAmount(BigDecimal paidAmount) { + this.paidAmount = paidAmount; + } + + public LocalDateTime getLastPaymentAt() { + return lastPaymentAt; + } + + 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 new file mode 100644 index 0000000..2ab77f2 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/model/invoices/EInvoiceFormat.java @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..1290e9b --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceApprovalAction.java @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..d8b00ab --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceApprovalRequest.java @@ -0,0 +1,179 @@ +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 new file mode 100644 index 0000000..85fbec1 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceApprovalStatus.java @@ -0,0 +1,5 @@ +package de.assecutor.votianlt.model.invoices; + +public enum InvoiceApprovalStatus { + PENDING, APPROVED, REJECTED +} diff --git a/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceAuditAction.java b/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceAuditAction.java new file mode 100644 index 0000000..b7840f7 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceAuditAction.java @@ -0,0 +1,8 @@ +package de.assecutor.votianlt.model.invoices; + +/** + * Aktionen, die im Rechnungs-Audit-Log gemäß R-36 protokolliert werden. + */ +public enum InvoiceAuditAction { + CREATED_DRAFT, UPDATED_DRAFT, ISSUED, SENT, CANCELLED, CORRECTED, REPLACED, DELETED_DRAFT, PAYMENT_RECORDED +} diff --git a/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceAuditEntry.java b/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceAuditEntry.java new file mode 100644 index 0000000..83f96c3 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceAuditEntry.java @@ -0,0 +1,89 @@ +package de.assecutor.votianlt.model.invoices; + +import java.time.LocalDateTime; + +/** + * Einzelner Eintrag im Änderungsprotokoll einer Rechnung gemäß R-36 bis R-39. + * + * Eingebettet in {@link CustomerInvoice}; wird ausschließlich angehängt, niemals + * geändert. Hält Wer/Wann/Was/Warum sowie ggf. den erzeugten Folgebeleg fest. + */ +public class InvoiceAuditEntry { + + private LocalDateTime timestamp; + private String userId; + private String userDisplayName; + private InvoiceAuditAction action; + private String reason; + + /** Optionale Referenz auf einen erzeugten Folgebeleg (Korrektur, Storno, Ersatzrechnung). */ + private String resultingInvoiceId; + private String resultingInvoiceNumber; + + public InvoiceAuditEntry() { + } + + public InvoiceAuditEntry(InvoiceAuditAction action, String userId, String userDisplayName, String reason) { + this.timestamp = LocalDateTime.now(); + this.action = action; + this.userId = userId; + this.userDisplayName = userDisplayName; + this.reason = reason; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } + + public void setTimestamp(LocalDateTime timestamp) { + this.timestamp = timestamp; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getUserDisplayName() { + return userDisplayName; + } + + public void setUserDisplayName(String userDisplayName) { + this.userDisplayName = userDisplayName; + } + + public InvoiceAuditAction getAction() { + return action; + } + + public void setAction(InvoiceAuditAction action) { + this.action = action; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + 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/InvoiceNumberReservation.java b/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceNumberReservation.java new file mode 100644 index 0000000..1c1ae97 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceNumberReservation.java @@ -0,0 +1,61 @@ +package de.assecutor.votianlt.model.invoices; + +import lombok.Data; +import org.bson.types.ObjectId; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.CompoundIndex; +import org.springframework.data.mongodb.core.index.CompoundIndexes; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.Instant; + +/** + * Audit-Eintrag für jede aus dem Rechnungsnummern-Counter gezogene Nummer. + * Erlaubt nachzuweisen, dass jede Lücke im fortlaufenden Nummernkreis erklärt + * werden kann (vergeben aber nicht ausgestellt → entweder offen oder begründet + * verworfen). Pflichtgrundlage: § 14 Abs. 4 Nr. 4 UStG i.V.m. GoBD. + * + * Pro (userId, sequence) existiert genau eine Reservierung — ein Eindeutigkeits- + * Index erzwingt das auch bei nebenläufigen Aufrufen. + */ +@Data +@Document(collection = "invoice_number_reservations") +@CompoundIndexes({ + @CompoundIndex(name = "user_sequence_unique", def = "{'userId': 1, 'sequence': 1}", unique = true), + @CompoundIndex(name = "user_status", def = "{'userId': 1, 'status': 1}") +}) +public class InvoiceNumberReservation { + + @Id + private ObjectId id; + + @Indexed + private ObjectId userId; + + /** Vollständige formatierte Rechnungsnummer wie sie auf dem Beleg erscheint (Präfix + Sequenz). */ + private String number; + + /** Roh-Sequenznummer aus dem Counter — Basis für Lücken-Analyse. */ + private long sequence; + + /** Präfix, mit dem die Nummer formatiert wurde — relevant, falls der Anwender den Präfix später ändert. */ + private String prefix; + + private Instant reservedAt; + + /** Anzeigename des reservierenden Nutzers (z.B. „Anna Müller") oder „system" für Hintergrundprozesse. */ + private String reservedBy; + + private InvoiceNumberReservationStatus status; + + /** Bei status=USED: ID der ausgestellten Rechnung. */ + private String invoiceId; + + private Instant usedAt; + + /** Bei status=VOIDED: vom Anwender erfasster Grund — Pflichtfeld für betriebsprüfungstaugliche Erklärung. */ + private String voidReason; + + private Instant voidedAt; +} diff --git a/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceNumberReservationStatus.java b/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceNumberReservationStatus.java new file mode 100644 index 0000000..3fb4a41 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceNumberReservationStatus.java @@ -0,0 +1,17 @@ +package de.assecutor.votianlt.model.invoices; + +/** + * Status einer aus dem Nummernkreis gezogenen Rechnungsnummer. + * + * RESERVED – Nummer wurde aus dem Counter gezogen, aber noch keine Rechnung dazu festgeschrieben. + * Bleibt eine Reservierung lange in diesem Zustand, deutet das auf einen + * abgebrochenen Erstell-Prozess hin und produziert eine erklärungsbedürftige + * Lücke im Nummernkreis (§ 14 Abs. 4 Nr. 4 UStG). + * USED – Eine festgeschriebene Rechnung trägt diese Nummer; lücken-unkritisch. + * VOIDED – Reservierung wurde bewusst verworfen (Erstellprozess abgebrochen, Anwender + * hat erklärt, warum die Nummer nicht ausgestellt wurde) — Lücke ist + * dokumentiert und betriebsprüfungstauglich. + */ +public enum InvoiceNumberReservationStatus { + RESERVED, USED, VOIDED +} diff --git a/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceStatus.java b/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceStatus.java new file mode 100644 index 0000000..bfa3b25 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceStatus.java @@ -0,0 +1,22 @@ +package de.assecutor.votianlt.model.invoices; + +/** + * Lebenszyklus einer Rechnung gemäß R-01 bis R-04. + * + * DRAFT – noch in Bearbeitung, darf editiert oder gelöscht werden. + * ISSUED – formal ausgestellt/gebucht, darf nicht mehr direkt überschrieben werden. + * SENT – an den Empfänger versendet. + * CANCELLED – durch eine Stornorechnung aufgehoben. + * CORRECTED – durch eine Berichtigung formal korrigiert (Original bleibt sichtbar). + */ +public enum InvoiceStatus { + DRAFT, ISSUED, SENT, CANCELLED, CORRECTED; + + public boolean isFinalized() { + return this != DRAFT; + } + + public boolean isMutable() { + return this == DRAFT; + } +} diff --git a/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceType.java b/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceType.java new file mode 100644 index 0000000..c252a3d --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/model/invoices/InvoiceType.java @@ -0,0 +1,14 @@ +package de.assecutor.votianlt.model.invoices; + +/** + * Belegtyp einer Rechnung gemäß R-09, R-12 ff. und R-17 ff. + * + * INVOICE – reguläre Ausgangsrechnung. + * CORRECTION – Rechnungsberichtigung für formale Fehler (R-12 bis R-16). + * Verweist eindeutig auf die zu korrigierende Originalrechnung. + * CANCELLATION – Stornorechnung für wirtschaftliche Fehler (R-17 bis R-22). + * Verweist eindeutig auf die zu stornierende Originalrechnung. + */ +public enum InvoiceType { + INVOICE, CORRECTION, CANCELLATION +} diff --git a/backend/src/main/java/de/assecutor/votianlt/model/invoices/PaymentStatus.java b/backend/src/main/java/de/assecutor/votianlt/model/invoices/PaymentStatus.java new file mode 100644 index 0000000..32100c0 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/model/invoices/PaymentStatus.java @@ -0,0 +1,14 @@ +package de.assecutor.votianlt.model.invoices; + +/** + * Zahlungsstatus einer Rechnung gemäß R-23 bis R-26. + * + * UNPAID – noch nicht bezahlt. + * PARTIALLY_PAID – Teilzahlung erhalten, Restbetrag offen. + * PAID – vollständig bezahlt. + * OVERPAID – Zahlbetrag übersteigt den Rechnungsbetrag. + * REFUND_DUE – Erstattungsbetrag offen (z.B. nach Storno einer bezahlten Rechnung). + */ +public enum PaymentStatus { + UNPAID, PARTIALLY_PAID, PAID, OVERPAID, REFUND_DUE +} 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 new file mode 100644 index 0000000..04c27ed --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/model/invoices/UserSigningCredentials.java @@ -0,0 +1,163 @@ +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 new file mode 100644 index 0000000..1816bb1 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/SigningCredentialsPanel.java @@ -0,0 +1,255 @@ +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 3959811..9b32caf 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,8 +145,12 @@ 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.invoices"), "invoices", VaadinIcon.FILE_TEXT)); + new MenuTreeItem(getTranslation("nav.datev.export"), "datev-export", VaadinIcon.DOWNLOAD)); + treeData.addItem(verwaltungItem, + new MenuTreeItem(getTranslation("nav.approvals"), "approvals", VaadinIcon.CHECK_CIRCLE)); 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/service/UserInvoiceDataService.java b/backend/src/main/java/de/assecutor/votianlt/pages/service/UserInvoiceDataService.java index f271fca..ce924cb 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/service/UserInvoiceDataService.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/service/UserInvoiceDataService.java @@ -1,8 +1,14 @@ package de.assecutor.votianlt.pages.service; import de.assecutor.votianlt.model.UserInvoiceData; +import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation; +import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus; +import de.assecutor.votianlt.repository.InvoiceNumberReservationRepository; import de.assecutor.votianlt.repository.UserInvoiceDataRepository; +import de.assecutor.votianlt.security.SecurityService; import org.bson.types.ObjectId; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.data.mongodb.core.FindAndModifyOptions; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.query.Criteria; @@ -10,17 +16,25 @@ import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.core.query.Update; import org.springframework.stereotype.Service; +import java.time.Instant; import java.util.Optional; @Service public class UserInvoiceDataService { + private static final Logger log = LoggerFactory.getLogger(UserInvoiceDataService.class); + private final UserInvoiceDataRepository userInvoiceDataRepository; private final MongoTemplate mongoTemplate; + private final InvoiceNumberReservationRepository reservationRepository; + private final SecurityService securityService; - public UserInvoiceDataService(UserInvoiceDataRepository userInvoiceDataRepository, MongoTemplate mongoTemplate) { + public UserInvoiceDataService(UserInvoiceDataRepository userInvoiceDataRepository, MongoTemplate mongoTemplate, + InvoiceNumberReservationRepository reservationRepository, SecurityService securityService) { this.userInvoiceDataRepository = userInvoiceDataRepository; this.mongoTemplate = mongoTemplate; + this.reservationRepository = reservationRepository; + this.securityService = securityService; } public Optional findByUserId(ObjectId userId) { @@ -64,6 +78,12 @@ public class UserInvoiceDataService { /** * Generiert atomar die nächste Rechnungsnummer für den Benutzer und erhöht den * Zähler um 1. Gibt die vollständige Rechnungsnummer zurück (Präfix + Nummer). + * + * Jede Vergabe wird als {@link InvoiceNumberReservation} mit Status RESERVED + * persistiert. Damit ist auch nachvollziehbar, wenn eine Nummer aus dem + * Counter gezogen, aber nie zu einer ausgestellten Rechnung wird (abgebrochener + * Erstell-Prozess, fehlgeschlagene Validierung). Die Reservierung wird später + * vom Lifecycle-Service auf USED bzw. VOIDED gesetzt. */ public String generateNextInvoiceNumber(ObjectId userId) { Query query = Query.query(Criteria.where("userId").is(userId)); @@ -75,11 +95,56 @@ public class UserInvoiceDataService { // Kein Eintrag vorhanden - Fallback auf aktuelle Daten return findByUserId(userId).map(d -> { String prefix = d.getPrefix() != null ? d.getPrefix() : ""; - return prefix + String.format("%06d", d.getNextInvoiceNumber()); + long sequence = d.getNextInvoiceNumber(); + String number = prefix + String.format("%06d", sequence); + recordReservation(userId, number, sequence, prefix); + return number; }).orElse("000000"); } String prefix = before.getPrefix() != null ? before.getPrefix() : ""; - return prefix + String.format("%06d", before.getNextInvoiceNumber()); + long sequence = before.getNextInvoiceNumber(); + String number = prefix + String.format("%06d", sequence); + recordReservation(userId, number, sequence, prefix); + return number; + } + + /** + * Persistiert die Reservierung einer Nummer. Das Schreiben des Audit-Eintrags + * ist von der Counter-Vergabe entkoppelt: Sollte das Audit-Repository + * vorübergehend ausfallen, geht die Nummer-Vergabe nicht verloren — wir + * loggen den Fehler und vertrauen darauf, dass die anschließende Lücken- + * Analyse auf Basis der ausgestellten Rechnungen die fehlende Reservierung + * sichtbar macht. + */ + private void recordReservation(ObjectId userId, String number, long sequence, String prefix) { + try { + InvoiceNumberReservation reservation = new InvoiceNumberReservation(); + reservation.setUserId(userId); + reservation.setNumber(number); + reservation.setSequence(sequence); + reservation.setPrefix(prefix); + reservation.setReservedAt(Instant.now()); + reservation.setReservedBy(currentUserDisplayName()); + reservation.setStatus(InvoiceNumberReservationStatus.RESERVED); + reservationRepository.save(reservation); + } catch (Exception ex) { + log.warn("Reservierung der Rechnungsnummer {} (User {}) konnte nicht persistiert werden: {}", + number, userId, ex.getMessage(), ex); + } + } + + private String currentUserDisplayName() { + try { + var user = securityService.getCurrentDatabaseUser(); + String composed = (safe(user.getFirstname()) + " " + safe(user.getName())).trim(); + return composed.isBlank() ? safe(user.getEmail()) : composed; + } catch (Exception ignored) { + return "system"; + } + } + + private String safe(String value) { + return value != null ? value : ""; } } \ No newline at end of file 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 new file mode 100644 index 0000000..c91382f --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/ApprovalsView.java @@ -0,0 +1,205 @@ +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 1b349d2..3db1eb4 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,7 +14,8 @@ import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.component.textfield.NumberField; import com.vaadin.flow.router.HasDynamicTitle; -import com.vaadin.flow.router.Route; +// Import bleibt auskommentiert, solange @Route deaktiviert ist (siehe unten). +// import com.vaadin.flow.router.Route; import com.vaadin.flow.router.BeforeEvent; import com.vaadin.flow.router.HasUrlParameter; import de.assecutor.votianlt.model.Customer; @@ -27,12 +28,14 @@ import de.assecutor.votianlt.model.invoices.CustomerInvoice; import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper; import de.assecutor.votianlt.pages.service.CustomerService; import de.assecutor.votianlt.pages.service.UserInvoiceDataService; -import de.assecutor.votianlt.repository.CustomerInvoiceRepository; import de.assecutor.votianlt.repository.JobRepository; 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; import jakarta.annotation.security.RolesAllowed; import lombok.extern.slf4j.Slf4j; @@ -50,7 +53,11 @@ import java.util.Optional; import com.vaadin.flow.component.dialog.Dialog; import com.vaadin.flow.component.html.IFrame; -@Route(value = "create_invoice", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) +// 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) @RolesAllowed({ "USER" }) @Slf4j public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter, HasDynamicTitle { @@ -62,8 +69,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter private final InvoiceTemplateService invoiceTemplateService; private final SecurityService securityService; private final UserInvoiceDataService userInvoiceDataService; - private final CustomerInvoiceRepository customerInvoiceRepository; private final CustomerService customerService; + private final InvoiceLifecycleService invoiceLifecycleService; + private final EInvoiceService eInvoiceService; private User currentUser; private Job currentJob; private List gridRows = new ArrayList<>(); @@ -117,8 +125,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter public CreateInvoiceView(JobRepository jobRepository, ServiceRepository serviceRepository, UserRepository userRepository, CustomerInvoiceService customerInvoiceService, InvoiceTemplateService invoiceTemplateService, SecurityService securityService, - UserInvoiceDataService userInvoiceDataService, CustomerInvoiceRepository customerInvoiceRepository, - CustomerService customerService) { + UserInvoiceDataService userInvoiceDataService, CustomerService customerService, + InvoiceLifecycleService invoiceLifecycleService, EInvoiceService eInvoiceService) { this.jobRepository = jobRepository; this.serviceRepository = serviceRepository; this.userRepository = userRepository; @@ -126,8 +134,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter this.invoiceTemplateService = invoiceTemplateService; this.securityService = securityService; this.userInvoiceDataService = userInvoiceDataService; - this.customerInvoiceRepository = customerInvoiceRepository; this.customerService = customerService; + this.invoiceLifecycleService = invoiceLifecycleService; + this.eInvoiceService = eInvoiceService; setSizeFull(); setPadding(true); setSpacing(true); @@ -584,8 +593,22 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter invoice.setVatRate(vatRate); invoice.setVatAmount(vatAmount); invoice.setTotalAmount(totalAmount); - invoice.setPdfData(pdfBytes); - CustomerInvoice savedInvoice = customerInvoiceRepository.save(invoice); + + // 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); + + // Finalisierung mit Audit-Eintrag und Eindeutigkeitsprüfung der Rechnungsnummer (R-07/R-11/R-36). + CustomerInvoice savedInvoice = invoiceLifecycleService.createAndIssue(invoice, + "Rechnung erstellt aus Auftrag " + currentJob.getJobNumber()); currentJob.setInvoiceId(savedInvoice.getId()); jobRepository.save(currentJob); @@ -594,6 +617,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter Notification.show(getTranslation("createinvoice.notification.saved", invoiceNumber), 4000, Notification.Position.BOTTOM_END); + } catch (InvoiceLifecycleException lifecycleEx) { + log.warn("Lifecycle-Verstoß beim Speichern der Rechnung: {}", lifecycleEx.getMessage()); + Notification.show(lifecycleEx.getMessage(), 5000, Notification.Position.MIDDLE); } catch (Exception ex) { log.error("Fehler beim Speichern der Rechnung", ex); Notification.show(getTranslation("createinvoice.notification.error", ex.getMessage()), 5000, 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 new file mode 100644 index 0000000..bcfee8f --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/DatevExportView.java @@ -0,0 +1,117 @@ +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 bd08a74..da0201f 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,9 +43,12 @@ 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; @@ -74,6 +77,8 @@ 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; @@ -87,12 +92,15 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle public EditProfileView(UserService userService, UserInvoiceDataService userInvoiceDataService, CustomerInvoiceService customerInvoiceService, InvoiceTemplateService invoiceTemplateService, - LanguageService languageService, SecurityService securityService, ServiceRepository serviceRepository) { + LanguageService languageService, SecurityService securityService, ServiceRepository serviceRepository, + SigningCredentialsService signingCredentialsService, EInvoiceProperties eInvoiceProperties) { 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; @@ -367,11 +375,32 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle } }); - HorizontalLayout billingHeaderLayout = new HorizontalLayout(billingEnabled, prefixField, vatRateField); + 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); 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 64d6a8c..f0eb4ac 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 @@ -1,39 +1,89 @@ package de.assecutor.votianlt.pages.view; +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.UI; +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.Anchor; 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.FlexComponent; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; import com.vaadin.flow.component.orderedlayout.VerticalLayout; +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; -import com.vaadin.flow.router.Route; -import com.vaadin.flow.component.UI; +// 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.server.StreamRegistration; +import com.vaadin.flow.server.StreamResource; +import de.assecutor.votianlt.model.User; 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; 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; +import de.assecutor.votianlt.service.InvoicePermissionService; import jakarta.annotation.security.RolesAllowed; import java.io.ByteArrayInputStream; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Comparator; import java.util.List; import java.util.Locale; import java.util.Optional; -import com.vaadin.flow.server.StreamResource; -import com.vaadin.flow.server.StreamRegistration; - -@Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) +// @Route deaktiviert — Rechnungs-UI ist durch DATEV-Export ersetzt. Reaktivierung: +// @Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) @RolesAllowed({ "USER", "ADMIN" }) public class InvoicesView extends VerticalLayout implements HasDynamicTitle { + private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm", + Locale.GERMANY); + private final Grid invoiceGrid; private final CustomerInvoiceRepository customerInvoiceRepository; private final SecurityService securityService; + private final InvoiceLifecycleService invoiceLifecycleService; + 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) { + public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService, + InvoiceLifecycleService invoiceLifecycleService, CustomerInvoiceService customerInvoiceService, + InvoiceExportService invoiceExportService, InvoicePermissionService invoicePermissionService, + InvoiceApprovalService invoiceApprovalService, UserInvoiceDataService userInvoiceDataService, + UserRepository userRepository) { this.customerInvoiceRepository = customerInvoiceRepository; this.securityService = securityService; + this.invoiceLifecycleService = invoiceLifecycleService; + this.customerInvoiceService = customerInvoiceService; + this.invoiceExportService = invoiceExportService; + this.invoicePermissionService = invoicePermissionService; + this.invoiceApprovalService = invoiceApprovalService; + this.userInvoiceDataService = userInvoiceDataService; + this.userRepository = userRepository; setSizeFull(); setPadding(true); @@ -43,60 +93,572 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle { addClassName("data-view"); add(new ViewToolbar(getTranslation("invoices.title"))); + add(buildLegalDisclaimer()); invoiceGrid = new Grid<>(CustomerInvoice.class, false); invoiceGrid.setWidthFull(); invoiceGrid.addClassName("data-grid"); invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getInvoiceNumber(), invoice.getId())) .setHeader(getTranslation("invoices.column.number")).setAutoWidth(true); + invoiceGrid.addComponentColumn(this::renderTypeBadge) + .setHeader(getTranslation("invoices.column.type")).setAutoWidth(true); + invoiceGrid.addComponentColumn(this::renderStatusBadge) + .setHeader(getTranslation("invoices.column.status")).setAutoWidth(true); invoiceGrid.addColumn(this::getRecipientLabel).setHeader(getTranslation("invoices.column.customer")) .setAutoWidth(true); invoiceGrid.addColumn(invoice -> Optional.ofNullable(invoice.getInvoiceDate()).map(Object::toString).orElse("")) .setHeader(getTranslation("invoices.column.date")).setAutoWidth(true); invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount")) .setAutoWidth(true); - invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getDescription(), "")) - .setHeader(getTranslation("invoices.column.description")).setAutoWidth(true); - invoiceGrid.setSelectionMode(Grid.SelectionMode.SINGLE); - invoiceGrid.getStyle().set("cursor", "pointer"); + 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); - invoiceGrid.addItemClickListener(event -> { - CustomerInvoice invoice = event.getItem(); - if (invoice != null) { - downloadInvoicePdf(invoice); - } - }); + invoiceGrid.setSelectionMode(Grid.SelectionMode.NONE); - loadInvoices(); Div gridPanel = new Div(invoiceGrid); gridPanel.addClassNames("surface-panel", "data-grid-panel"); gridPanel.setWidthFull(); add(gridPanel); + + loadInvoices(); + } + + private Component buildLegalDisclaimer() { + Div banner = new Div(); + banner.addClassName("surface-panel"); + banner.getStyle().set("padding", "12px 16px").set("border-left", "4px solid var(--lumo-primary-color)") + .set("background", "var(--lumo-contrast-5pct)"); + Span text = new Span(getTranslation("invoices.disclaimer")); + text.getStyle().set("font-size", "var(--lumo-font-size-s)"); + banner.add(text); + return banner; } private void loadInvoices() { String currentUserId = securityService.getCurrentUserId().toHexString(); List invoices = customerInvoiceRepository.findByUserId(currentUserId).stream() - .filter(this::hasPdfData).sorted((left, right) -> { - if (left.getInvoiceDate() == null && right.getInvoiceDate() == null) { - return 0; - } - if (left.getInvoiceDate() == null) { - return 1; - } - if (right.getInvoiceDate() == null) { - return -1; - } - return right.getInvoiceDate().compareTo(left.getInvoiceDate()); - }).toList(); + .sorted(Comparator + .comparing((CustomerInvoice i) -> i.getInvoiceDate() == null ? LocalDate.MIN + : i.getInvoiceDate()) + .reversed()) + .toList(); invoiceGrid.setItems(invoices); + } - if (invoices.isEmpty()) { - Span emptyState = new Span(getTranslation("invoices.empty")); - emptyState.getStyle().set("color", "var(--lumo-secondary-text-color)"); - add(emptyState); + private Component renderStatusBadge(CustomerInvoice invoice) { + InvoiceStatus status = invoice.getStatus() != null ? invoice.getStatus() : InvoiceStatus.ISSUED; + Span badge = new Span(getTranslation("invoices.status." + status.name().toLowerCase(Locale.ROOT))); + badge.getElement().getThemeList().add("badge"); + switch (status) { + case DRAFT -> badge.getElement().getThemeList().add("contrast"); + case SENT -> badge.getElement().getThemeList().add("success"); + case CANCELLED -> badge.getElement().getThemeList().add("error"); + case CORRECTED -> badge.getElement().getThemeList().add("warning"); + default -> { } + } + 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(); + layout.setSpacing(true); + layout.setPadding(false); + + Span badge = new Span(getTranslation("invoices.type." + type.name().toLowerCase(Locale.ROOT))); + badge.getElement().getThemeList().add("badge"); + if (type == InvoiceType.CANCELLATION) { + badge.getElement().getThemeList().add("error"); + } else if (type == InvoiceType.CORRECTION) { + 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; + } + + private Component renderActions(CustomerInvoice invoice) { + HorizontalLayout actions = new HorizontalLayout(); + actions.setSpacing(true); + actions.setPadding(false); + + Button viewBtn = new Button(getTranslation("invoices.action.view"), e -> downloadInvoicePdf(invoice)); + viewBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL); + viewBtn.setEnabled(invoice.getPdfData() != null && invoice.getPdfData().length > 0); + actions.add(viewBtn); + + Button historyBtn = new Button(getTranslation("invoices.action.history"), e -> openHistoryDialog(invoice)); + historyBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL); + actions.add(historyBtn); + + InvoiceStatus status = invoice.getStatus() != null ? invoice.getStatus() : InvoiceStatus.ISSUED; + InvoiceType type = invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE; + User currentUser = invoicePermissionService.currentUser(); + + // Aktionen nur für reguläre, noch aktive Rechnungen anbieten + boolean isLiveInvoice = type == InvoiceType.INVOICE + && (status == InvoiceStatus.ISSUED || status == InvoiceStatus.SENT); + + if (type == InvoiceType.INVOICE && status == InvoiceStatus.ISSUED + && invoicePermissionService.canMarkAsSent(currentUser)) { + Button sentBtn = new Button(getTranslation("invoices.action.marksent"), + e -> markAsSent(invoice)); + sentBtn.addThemeVariants(ButtonVariant.LUMO_SMALL); + actions.add(sentBtn); + } + + 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)); + 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)); + cancelBtn.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ERROR); + cancelBtn.setEnabled(!hasPendingRequest); + actions.add(cancelBtn); + } + } + + // Zahlung erfassen: nur für reguläre Rechnungen (R-25) + if (type == InvoiceType.INVOICE && status != InvoiceStatus.DRAFT + && invoicePermissionService.canRecordPayment(currentUser)) { + Button payBtn = new Button(getTranslation("invoices.action.payment"), + e -> openPaymentDialog(invoice)); + payBtn.addThemeVariants(ButtonVariant.LUMO_SMALL); + actions.add(payBtn); + } + + // Belegpaket exportieren (R-33/R-34) + Button exportBtn = new Button(getTranslation("invoices.action.export"), e -> exportPackage(invoice)); + exportBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL); + actions.add(exportBtn); + + return actions; + } + + private void openPaymentDialog(CustomerInvoice invoice) { + Dialog dialog = DialogStylingHelper.createStyledDialog( + getTranslation("invoices.payment.title", invoice.getInvoiceNumber()), "480px"); + + VerticalLayout content = new VerticalLayout(); + content.setSpacing(true); + content.setPadding(false); + + java.math.BigDecimal outstanding = invoiceLifecycleService.computeOutstandingAmount(invoice); + Span hint = new Span(getTranslation("invoices.payment.hint", + java.text.NumberFormat.getCurrencyInstance(Locale.GERMANY).format(outstanding))); + hint.getStyle().set("color", "var(--lumo-secondary-text-color)") + .set("font-size", "var(--lumo-font-size-s)"); + content.add(hint); + + NumberField amountField = new NumberField(getTranslation("invoices.payment.amount")); + amountField.setStep(0.01); + amountField.setValue(outstanding.doubleValue()); + amountField.setRequiredIndicatorVisible(true); + amountField.setWidthFull(); + content.add(amountField); + + TextField referenceField = new TextField(getTranslation("invoices.payment.reference")); + referenceField.setWidthFull(); + content.add(referenceField); + + TextArea reasonField = new TextArea(getTranslation("invoices.payment.reason")); + reasonField.setWidthFull(); + reasonField.setMinHeight("80px"); + content.add(reasonField); + + 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("invoices.payment.confirm"), e -> { + Double amount = amountField.getValue(); + if (amount == null || amount == 0d) { + amountField.setInvalid(true); + amountField.setErrorMessage(getTranslation("invoices.payment.amount.required")); + return; + } + performPayment(invoice, java.math.BigDecimal.valueOf(amount), referenceField.getValue(), + reasonField.getValue(), dialog); + }); + confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + dialog.getFooter().add(cancelBtn, confirmBtn); + dialog.open(); + } + + private void performPayment(CustomerInvoice invoice, java.math.BigDecimal amount, String reference, String reason, + Dialog dialog) { + try { + invoicePermissionService.requirePayment(invoicePermissionService.currentUser()); + invoiceLifecycleService.registerPayment(invoice.getId(), amount, reference, reason); + dialog.close(); + Notification.show(getTranslation("invoices.notification.payment"), 3000, Notification.Position.BOTTOM_END); + loadInvoices(); + } 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); + } + } + + private void exportPackage(CustomerInvoice invoice) { + try { + byte[] zipBytes = invoiceExportService.exportInvoicePackage(invoice); + String fileName = invoiceExportService.suggestFilename(invoice); + StreamResource resource = new StreamResource(fileName, () -> new ByteArrayInputStream(zipBytes)); + resource.setContentType("application/zip"); + resource.setCacheTime(0); + StreamRegistration registration = UI.getCurrent().getSession().getResourceRegistry() + .registerResource(resource); + UI.getCurrent().getPage().open(registration.getResourceUri().toString()); + } catch (Exception ex) { + Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000, + Notification.Position.MIDDLE); + } + } + + private void markAsSent(CustomerInvoice invoice) { + try { + invoicePermissionService.requireSend(invoicePermissionService.currentUser()); + invoiceLifecycleService.markAsSent(invoice.getId(), "Manuell als versendet markiert"); + Notification.show(getTranslation("invoices.notification.sent"), 3000, Notification.Position.BOTTOM_END); + loadInvoices(); + } catch (InvoiceLifecycleException ex) { + Notification.show(ex.getMessage(), 5000, Notification.Position.MIDDLE); + } + } + + private void openCancellationDialog(CustomerInvoice invoice) { + Dialog dialog = DialogStylingHelper.createStyledDialog( + getTranslation("invoices.cancel.title", invoice.getInvoiceNumber()), "560px"); + + VerticalLayout content = new VerticalLayout(); + content.setSpacing(true); + content.setPadding(false); + + Span hint = new Span(getTranslation("invoices.cancel.hint")); + hint.getStyle().set("color", "var(--lumo-secondary-text-color)") + .set("font-size", "var(--lumo-font-size-s)"); + content.add(hint); + + TextArea reasonField = new TextArea(getTranslation("invoices.cancel.reason")); + reasonField.setWidthFull(); + reasonField.setMinHeight("100px"); + reasonField.setRequired(true); + content.add(reasonField); + + 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("invoices.cancel.confirm"), e -> { + String reason = reasonField.getValue(); + if (reason == null || reason.isBlank()) { + reasonField.setInvalid(true); + reasonField.setErrorMessage(getTranslation("invoices.cancel.reason.required")); + return; + } + performCancellation(invoice, reason, dialog); + }); + confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_ERROR); + + dialog.getFooter().add(cancelBtn, confirmBtn); + dialog.open(); + } + + private void performCancellation(CustomerInvoice invoice, String reason, Dialog dialog) { + 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(); + byte[] pdf = customerInvoiceService.generateCancellationPdf(invoice, number, today, reason); + invoiceLifecycleService.cancel(invoice.getId(), number, today, pdf, reason); + dialog.close(); + Notification.show(getTranslation("invoices.notification.cancelled", number), 4000, + Notification.Position.BOTTOM_END); + loadInvoices(); + } 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); + } + } + + private void openCorrectionDialog(CustomerInvoice invoice) { + Dialog dialog = DialogStylingHelper.createStyledDialog( + getTranslation("invoices.correct.title", invoice.getInvoiceNumber()), "560px"); + + VerticalLayout content = new VerticalLayout(); + content.setSpacing(true); + content.setPadding(false); + + Span hint = new Span(getTranslation("invoices.correct.hint")); + hint.getStyle().set("color", "var(--lumo-secondary-text-color)") + .set("font-size", "var(--lumo-font-size-s)"); + content.add(hint); + + TextArea fieldsField = new TextArea(getTranslation("invoices.correct.fields")); + fieldsField.setWidthFull(); + fieldsField.setMinHeight("100px"); + fieldsField.setHelperText(getTranslation("invoices.correct.fields.helper")); + fieldsField.setRequired(true); + content.add(fieldsField); + + TextArea reasonField = new TextArea(getTranslation("invoices.correct.reason")); + reasonField.setWidthFull(); + reasonField.setMinHeight("80px"); + content.add(reasonField); + + 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("invoices.correct.confirm"), e -> { + String fields = fieldsField.getValue(); + if (fields == null || fields.isBlank()) { + fieldsField.setInvalid(true); + fieldsField.setErrorMessage(getTranslation("invoices.correct.fields.required")); + return; + } + performCorrection(invoice, fields, reasonField.getValue(), dialog); + }); + confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + + dialog.getFooter().add(cancelBtn, confirmBtn); + dialog.open(); + } + + private void performCorrection(CustomerInvoice invoice, String correctedFields, String reason, Dialog dialog) { + 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(); + byte[] pdf = customerInvoiceService.generateCorrectionPdf(invoice, number, today, reason, correctedFields); + invoiceLifecycleService.correct(invoice.getId(), number, today, pdf, correctedFields, reason); + dialog.close(); + Notification.show(getTranslation("invoices.notification.corrected", number), 4000, + Notification.Position.BOTTOM_END); + loadInvoices(); + } 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); + } + } + + private User resolveIssuer(CustomerInvoice invoice) { + if (invoice.getUserId() != null && !invoice.getUserId().isBlank()) { + try { + return userRepository.findById(new org.bson.types.ObjectId(invoice.getUserId())) + .orElseGet(securityService::getCurrentDatabaseUser); + } catch (IllegalArgumentException ex) { + // userId ist kein gültiger ObjectId – Fallback auf eingeloggten Nutzer + } + } + return securityService.getCurrentDatabaseUser(); + } + + private void openHistoryDialog(CustomerInvoice invoice) { + Dialog dialog = DialogStylingHelper.createStyledDialog( + getTranslation("invoices.history.title", invoice.getInvoiceNumber()), "640px"); + + VerticalLayout content = new VerticalLayout(); + content.setSpacing(true); + content.setPadding(false); + + // Verkettung anzeigen, falls vorhanden + Div linksBlock = renderRelatedInvoiceLinks(invoice); + if (linksBlock != null) { + content.add(linksBlock); + } + + H3 logTitle = new H3(getTranslation("invoices.history.log")); + content.add(logTitle); + + List log = invoice.getAuditLog(); + if (log == null || log.isEmpty()) { + content.add(new Span(getTranslation("invoices.history.empty"))); + } else { + log.stream() + .sorted(Comparator + .comparing((InvoiceAuditEntry e) -> e.getTimestamp() == null + ? java.time.LocalDateTime.MIN + : e.getTimestamp()) + .reversed()) + .forEach(entry -> content.add(renderAuditEntry(entry))); + } + + dialog.add(DialogStylingHelper.wrapContent(content, true)); + + Button closeBtn = new Button(getTranslation("button.close"), e -> dialog.close()); + closeBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + dialog.getFooter().add(closeBtn); + dialog.open(); + } + + private Div renderRelatedInvoiceLinks(CustomerInvoice invoice) { + Div block = new Div(); + boolean hasContent = false; + if (invoice.getOriginalInvoiceId() != null) { + block.add(buildLinkRow(getTranslation("invoices.history.original"), + invoice.getOriginalInvoiceNumber(), invoice.getOriginalInvoiceId())); + hasContent = true; + } + if (invoice.getCancellationInvoiceId() != null) { + block.add(buildLinkRow(getTranslation("invoices.history.cancellation"), + null, invoice.getCancellationInvoiceId())); + hasContent = true; + } + if (invoice.getCorrectionInvoiceId() != null) { + block.add(buildLinkRow(getTranslation("invoices.history.correction"), + null, invoice.getCorrectionInvoiceId())); + hasContent = true; + } + if (invoice.getReplacementInvoiceId() != null) { + block.add(buildLinkRow(getTranslation("invoices.history.replacement"), + null, invoice.getReplacementInvoiceId())); + hasContent = true; + } + return hasContent ? block : null; + } + + private HorizontalLayout buildLinkRow(String label, String fallbackNumber, String invoiceId) { + HorizontalLayout row = new HorizontalLayout(); + row.setSpacing(true); + row.setPadding(false); + Span lbl = new Span(label); + lbl.getStyle().set("min-width", "180px").set("color", "var(--lumo-secondary-text-color)"); + row.add(lbl); + + CustomerInvoice related = invoiceLifecycleService.findById(invoiceId).orElse(null); + String number = related != null && related.getInvoiceNumber() != null ? related.getInvoiceNumber() + : fallbackNumber != null ? fallbackNumber : invoiceId; + if (related != null && related.getPdfData() != null && related.getPdfData().length > 0) { + Anchor link = new Anchor("javascript:void(0)", number); + link.getElement().addEventListener("click", e -> downloadInvoicePdf(related)); + row.add(link); + } else { + row.add(new Span(number)); + } + return row; + } + + private Div renderAuditEntry(InvoiceAuditEntry entry) { + Div container = new Div(); + container.getStyle().set("padding", "8px 12px").set("margin-bottom", "6px") + .set("border-left", "3px solid var(--lumo-contrast-30pct)") + .set("background", "var(--lumo-contrast-5pct)"); + + String timestamp = entry.getTimestamp() != null ? entry.getTimestamp().format(DATE_TIME_FMT) : "—"; + String actionLabel = entry.getAction() != null + ? getTranslation("invoices.audit.action." + entry.getAction().name().toLowerCase(Locale.ROOT)) + : "?"; + String userLabel = entry.getUserDisplayName() != null ? entry.getUserDisplayName() : "system"; + + Span header = new Span(timestamp + " · " + actionLabel + " · " + userLabel); + header.getStyle().set("font-weight", "600"); + container.add(header); + + if (entry.getReason() != null && !entry.getReason().isBlank()) { + Div reason = new Div(); + reason.setText(entry.getReason()); + reason.getStyle().set("margin-top", "4px"); + container.add(reason); + } + if (entry.getResultingInvoiceNumber() != null) { + Div link = new Div(); + link.setText(getTranslation("invoices.audit.resulting", entry.getResultingInvoiceNumber())); + link.getStyle().set("margin-top", "4px").set("color", "var(--lumo-secondary-text-color)") + .set("font-size", "var(--lumo-font-size-s)"); + container.add(link); + } + return container; } private void downloadInvoicePdf(CustomerInvoice invoice) { @@ -123,10 +685,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle { } } - private boolean hasPdfData(CustomerInvoice invoice) { - return invoice != null && invoice.getPdfData() != null && invoice.getPdfData().length > 0; - } - private String getRecipientLabel(CustomerInvoice invoice) { return firstNonBlank(invoice.getRecipientCompany(), invoice.getRecipientName(), ""); } 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 7775f42..30ed5a3 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,9 +16,11 @@ 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; -import com.vaadin.flow.router.Route; +// Route deaktiviert (siehe Klassen-Header). +// import com.vaadin.flow.router.Route; import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar; -import de.assecutor.votianlt.pages.base.ui.view.MainLayout; +// import bleibt auskommentiert, solange die @Route oben deaktiviert ist: +// 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; @@ -41,7 +43,8 @@ import java.util.Locale; * Modernisierte Optik: Responsive Karten, Lumo-Theme-Varianten, Status-Badges, * Suche und leere Zustandsanzeige. */ -@Route(value = "my-invoices", layout = MainLayout.class) +// @Route deaktiviert — Rechnungs-UI ist durch DATEV-Export ersetzt. Reaktivierung: +// @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 479c877..8c8b0d6 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 @@ -140,38 +140,10 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle { return new com.vaadin.flow.component.html.Span(); }).setHeader("").setAutoWidth(true).setFlexGrow(0); - // 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); + // 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. // Delete column (last column, right side) grid.addComponentColumn(job -> { diff --git a/backend/src/main/java/de/assecutor/votianlt/repository/CustomerInvoiceRepository.java b/backend/src/main/java/de/assecutor/votianlt/repository/CustomerInvoiceRepository.java index 7f90e79..b244e80 100644 --- a/backend/src/main/java/de/assecutor/votianlt/repository/CustomerInvoiceRepository.java +++ b/backend/src/main/java/de/assecutor/votianlt/repository/CustomerInvoiceRepository.java @@ -1,6 +1,7 @@ package de.assecutor.votianlt.repository; import de.assecutor.votianlt.model.invoices.CustomerInvoice; +import de.assecutor.votianlt.model.invoices.InvoiceStatus; import org.springframework.data.mongodb.repository.MongoRepository; import org.springframework.stereotype.Repository; @@ -13,4 +14,13 @@ public interface CustomerInvoiceRepository extends MongoRepository findByJobId(String jobId); List findByUserId(String userId); + + /** Liefert die – höchstens eine – aktive (nicht stornierte) Rechnung mit dieser Nummer (R-11). */ + Optional findByInvoiceNumberAndStatusNot(String invoiceNumber, InvoiceStatus status); + + /** Alle Folgebelege (Storno, Korrektur, Ersatzrechnung), die auf diese Originalrechnung verweisen. */ + List findByOriginalInvoiceId(String originalInvoiceId); + + /** Findet alle Rechnungen ohne expliziten Status — wird für die Bestandsdatenmigration genutzt. */ + List findByStatusIsNull(); } diff --git a/backend/src/main/java/de/assecutor/votianlt/repository/InvoiceApprovalRequestRepository.java b/backend/src/main/java/de/assecutor/votianlt/repository/InvoiceApprovalRequestRepository.java new file mode 100644 index 0000000..0d619c7 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/repository/InvoiceApprovalRequestRepository.java @@ -0,0 +1,19 @@ +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/InvoiceNumberReservationRepository.java b/backend/src/main/java/de/assecutor/votianlt/repository/InvoiceNumberReservationRepository.java new file mode 100644 index 0000000..e46d5b4 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/repository/InvoiceNumberReservationRepository.java @@ -0,0 +1,23 @@ +package de.assecutor.votianlt.repository; + +import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation; +import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus; +import org.bson.types.ObjectId; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface InvoiceNumberReservationRepository extends MongoRepository { + + Optional findByUserIdAndNumber(ObjectId userId, String number); + + Optional findByUserIdAndSequence(ObjectId userId, long sequence); + + List findByUserIdOrderBySequenceAsc(ObjectId userId); + + List findByUserIdAndStatusOrderBySequenceAsc(ObjectId userId, + InvoiceNumberReservationStatus 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 new file mode 100644 index 0000000..b6fd69c --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/repository/UserSigningCredentialsRepository.java @@ -0,0 +1,15 @@ +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/security/InvoiceRoles.java b/backend/src/main/java/de/assecutor/votianlt/security/InvoiceRoles.java new file mode 100644 index 0000000..cf7b356 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/security/InvoiceRoles.java @@ -0,0 +1,26 @@ +package de.assecutor.votianlt.security; + +/** + * Rollen für die Bearbeitung von Rechnungen gemäß R-40 bis R-42. + * + * Die Rollen sind als Konstanten definiert und werden in der bestehenden + * {@link de.assecutor.votianlt.model.User#getRoles()}-Sammlung als String hinterlegt. + * + * Backwards-compat: Bestehende Nutzer haben keine dieser Rollen — die + * {@code USER}-Rolle bleibt vollumfänglich berechtigt, sofern keine speziellen + * Rechnungsrollen explizit zugewiesen sind. + */ +public final class InvoiceRoles { + + /** Erstellt Entwürfe und stellt Rechnungen aus. */ + public static final String CREATOR = "INVOICE_CREATOR"; + /** Prüft Entwürfe und Folgebelege vor Freigabe. */ + public static final String REVIEWER = "INVOICE_REVIEWER"; + /** Gibt Storno- und Berichtigungsbelege frei (R-42). */ + public static final String APPROVER = "INVOICE_APPROVER"; + /** Erfasst Zahlungen und buchhalterische Vorgänge (R-25). */ + public static final String ACCOUNTANT = "INVOICE_ACCOUNTANT"; + + private InvoiceRoles() { + } +} diff --git a/backend/src/main/java/de/assecutor/votianlt/service/AesGcmCipher.java b/backend/src/main/java/de/assecutor/votianlt/service/AesGcmCipher.java new file mode 100644 index 0000000..83a366d --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/service/AesGcmCipher.java @@ -0,0 +1,71 @@ +package de.assecutor.votianlt.service; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.security.SecureRandom; + +/** + * Symmetrische AES-256-GCM-Verschlüsselung mit Master-Key-Ableitung über SHA-256. + * + * Format der erzeugten Bytes: {@code IV (12 Byte) || Ciphertext+Tag (16 Byte Tag)}. + * Der IV wird pro Verschlüsselung neu zufällig erzeugt; ein Master-Key liefert + * deterministisch denselben AES-Schlüssel. + * + * Nicht für hochfrequente Krypto-Operationen optimiert — bewusst minimal gehalten. + */ +final class AesGcmCipher { + + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int IV_LENGTH = 12; + private static final int TAG_LENGTH_BITS = 128; + + private final SecretKeySpec key; + private final SecureRandom random = new SecureRandom(); + + AesGcmCipher(String masterKey) { + if (masterKey == null || masterKey.length() < 16) { + throw new IllegalArgumentException("Master-Key muss mindestens 16 Zeichen lang sein."); + } + try { + byte[] derived = MessageDigest.getInstance("SHA-256").digest(masterKey.getBytes("UTF-8")); + this.key = new SecretKeySpec(derived, "AES"); + } catch (Exception ex) { + throw new IllegalStateException("Master-Key konnte nicht abgeleitet werden.", ex); + } + } + + byte[] encrypt(byte[] plaintext) { + try { + byte[] iv = new byte[IV_LENGTH]; + random.nextBytes(iv); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + byte[] ciphertext = cipher.doFinal(plaintext); + return ByteBuffer.allocate(IV_LENGTH + ciphertext.length).put(iv).put(ciphertext).array(); + } catch (Exception ex) { + throw new IllegalStateException("Verschlüsselung fehlgeschlagen: " + ex.getMessage(), ex); + } + } + + byte[] decrypt(byte[] ivAndCiphertext) { + if (ivAndCiphertext == null || ivAndCiphertext.length < IV_LENGTH + 16) { + throw new IllegalArgumentException("Ciphertext zu kurz."); + } + try { + ByteBuffer buffer = ByteBuffer.wrap(ivAndCiphertext); + byte[] iv = new byte[IV_LENGTH]; + buffer.get(iv); + byte[] ciphertext = new byte[buffer.remaining()]; + buffer.get(ciphertext); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + return cipher.doFinal(ciphertext); + } catch (Exception ex) { + throw new IllegalStateException("Entschlüsselung fehlgeschlagen: " + ex.getMessage(), ex); + } + } +} diff --git a/backend/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java b/backend/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java index aadb731..90dfc11 100644 --- a/backend/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java +++ b/backend/src/main/java/de/assecutor/votianlt/service/CustomerInvoiceService.java @@ -921,4 +921,181 @@ public class CustomerInvoiceService { return input.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """) .replace("'", "'"); } + + /** + * Erzeugt ein einfaches PDF für einen Stornobeleg gemäß R-19. + * Verweist eindeutig auf die Originalrechnung (Nummer + Datum) und stellt die + * Beträge als negative Werte dar. + */ + public byte[] generateCancellationPdf(de.assecutor.votianlt.model.invoices.CustomerInvoice original, + String cancellationNumber, java.time.LocalDate cancellationDate, String reason) throws Exception { + return generateCorrectionDocumentPdf("STORNORECHNUNG", original, cancellationNumber, cancellationDate, reason, + null, true); + } + + /** + * Erzeugt ein einfaches PDF für einen Berichtigungsbeleg gemäß R-13/R-14. + * Verweist eindeutig auf die Originalrechnung und beschreibt die berichtigten Angaben. + */ + public byte[] generateCorrectionPdf(de.assecutor.votianlt.model.invoices.CustomerInvoice original, + String correctionNumber, java.time.LocalDate correctionDate, String reason, String correctedFields) + throws Exception { + return generateCorrectionDocumentPdf("RECHNUNGSBERICHTIGUNG", original, correctionNumber, correctionDate, + reason, correctedFields, false); + } + + private byte[] generateCorrectionDocumentPdf(String documentLabel, + de.assecutor.votianlt.model.invoices.CustomerInvoice original, String number, + java.time.LocalDate documentDate, String reason, String correctedFields, boolean negateAmounts) + throws Exception { + java.time.LocalDate effectiveDate = documentDate != null ? documentDate : java.time.LocalDate.now(); + java.time.format.DateTimeFormatter dateFmt = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy", + Locale.GERMANY); + + BigDecimal net = negateAmounts ? negateOrZero(original.getNetAmount()) : safeAmount(original.getNetAmount()); + BigDecimal vat = negateAmounts ? negateOrZero(original.getVatAmount()) : safeAmount(original.getVatAmount()); + BigDecimal total = negateAmounts ? negateOrZero(original.getTotalAmount()) + : safeAmount(original.getTotalAmount()); + + StringBuilder html = new StringBuilder(); + html.append(""); + + html.append("

").append(escapeHtml(documentLabel)).append("

"); + html.append("
Beleg-Nr.: ") + .append(escapeHtml(safe(number))) + .append("  ·  Datum: ").append(escapeHtml(effectiveDate.format(dateFmt))) + .append("
"); + + // Sender / Empfänger + html.append("
"); + html.append("Aussteller
"); + html.append(formatAddressBlock(original.getSenderName(), original.getSenderAddress(), + original.getSenderPostcode(), original.getSenderCity(), original.getSenderCountry())); + if (original.getSenderTaxNumber() != null && !original.getSenderTaxNumber().isBlank()) { + html.append("
Steuernr.: ").append(escapeHtml(original.getSenderTaxNumber())) + .append("
"); + } + if (original.getSenderVatId() != null && !original.getSenderVatId().isBlank()) { + html.append("
USt-IdNr.: ").append(escapeHtml(original.getSenderVatId())) + .append("
"); + } + html.append("
"); + html.append("Empfänger
"); + html.append(formatAddressBlock( + firstNonBlank(original.getRecipientCompany(), original.getRecipientName()), + original.getRecipientAddress(), original.getRecipientPostcode(), original.getRecipientCity(), + original.getRecipientCountry())); + html.append("
"); + + // Eindeutige Referenz auf Originalrechnung (R-13/R-19/R-28) + html.append("
"); + html.append("Bezug: "); + html.append("Diese ").append(escapeHtml(documentLabel.toLowerCase(Locale.GERMANY))).append(" bezieht sich "); + html.append("eindeutig auf die Rechnung ") + .append(escapeHtml(safe(original.getInvoiceNumber()))).append(""); + if (original.getInvoiceDate() != null) { + html.append(" vom ").append(escapeHtml(original.getInvoiceDate().format(dateFmt))); + } + html.append("."); + html.append("
"); + + if (correctedFields != null && !correctedFields.isBlank()) { + html.append("

Berichtigte Angaben

"); + html.append("
").append(escapeHtml(correctedFields).replace("\n", "
")).append("
"); + } + + if (reason != null && !reason.isBlank()) { + html.append("
Grund: ") + .append(escapeHtml(reason).replace("\n", "
")).append("
"); + } + + html.append("

Beträge

"); + html.append(""); + html.append(""); + if (original.getVatRate() != null) { + BigDecimal vatPct = original.getVatRate().multiply(new BigDecimal("100")) + .setScale(2, java.math.RoundingMode.HALF_UP).stripTrailingZeros(); + if (vatPct.scale() < 0) { + vatPct = vatPct.setScale(0); + } + html.append(""); + } else { + html.append(""); + } + html.append(""); + html.append("
Nettobetrag").append(formatCurrency(net)).append("
zzgl. ").append(vatPct.toPlainString().replace('.', ',')) + .append("% USt").append(formatCurrency(vat)).append("
zzgl. USt").append(formatCurrency(vat)).append("
Gesamtbetrag").append(formatCurrency(total)) + .append("
"); + + html.append("
"); + html.append("Hinweis: Dieser Beleg ersetzt die Originalrechnung nicht. Original und "); + html.append(escapeHtml(documentLabel.toLowerCase(Locale.GERMANY))); + html.append(" sind gemeinsam aufzubewahren."); + html.append("
"); + + html.append(""); + + return generatePdfFromHtmlString(html.toString()); + } + + private BigDecimal safeAmount(BigDecimal value) { + return value != null ? value : BigDecimal.ZERO; + } + + private BigDecimal negateOrZero(BigDecimal value) { + return value != null ? value.negate() : BigDecimal.ZERO; + } + + private String formatAddressBlock(String name, String street, String postcode, String city, String country) { + StringBuilder sb = new StringBuilder(); + if (name != null && !name.isBlank()) { + sb.append(escapeHtml(name)).append("
"); + } + if (street != null && !street.isBlank()) { + sb.append(escapeHtml(street)).append("
"); + } + String line = String.join(" ", filterBlanks(postcode, city)).trim(); + if (!line.isEmpty()) { + sb.append(escapeHtml(line)).append("
"); + } + if (country != null && !country.isBlank()) { + sb.append(escapeHtml(country)); + } + return sb.toString(); + } + + private java.util.List filterBlanks(String... values) { + java.util.List out = new java.util.ArrayList<>(); + for (String v : values) { + if (v != null && !v.isBlank()) { + out.add(v); + } + } + return out; + } + + private String firstNonBlank(String... values) { + for (String v : values) { + if (v != null && !v.isBlank()) { + return v; + } + } + return ""; + } } diff --git a/backend/src/main/java/de/assecutor/votianlt/service/DatevExportService.java b/backend/src/main/java/de/assecutor/votianlt/service/DatevExportService.java new file mode 100644 index 0000000..b277e44 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/service/DatevExportService.java @@ -0,0 +1,230 @@ +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 new file mode 100644 index 0000000..8e36b4b --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/service/EInvoiceService.java @@ -0,0 +1,472 @@ +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 new file mode 100644 index 0000000..ae2ab84 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/service/InvoiceApprovalService.java @@ -0,0 +1,219 @@ +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/InvoiceComplianceException.java b/backend/src/main/java/de/assecutor/votianlt/service/InvoiceComplianceException.java new file mode 100644 index 0000000..15b9edb --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/service/InvoiceComplianceException.java @@ -0,0 +1,35 @@ +package de.assecutor.votianlt.service; + +import java.util.Collections; +import java.util.List; + +/** + * Wird geworfen, wenn eine Rechnung beim Festschreiben gegen Pflichtangaben + * nach § 14 UStG bzw. interne Konsistenzregeln verstößt. Die Verstöße werden + * gesammelt geliefert, damit der Anwender alle Korrekturen in einem Schritt + * durchführen kann statt jeden Fehler einzeln zu beheben. + */ +public class InvoiceComplianceException extends InvoiceLifecycleException { + + private final List violations; + + public InvoiceComplianceException(List violations) { + super(buildMessage(violations)); + this.violations = List.copyOf(violations); + } + + public List getViolations() { + return Collections.unmodifiableList(violations); + } + + private static String buildMessage(List violations) { + if (violations == null || violations.isEmpty()) { + return "Die Rechnung erfüllt die gesetzlichen Pflichtangaben nicht."; + } + StringBuilder sb = new StringBuilder("Die Rechnung kann nicht festgeschrieben werden — folgende Pflichtangaben fehlen oder sind inkonsistent:"); + for (String violation : violations) { + sb.append("\n • ").append(violation); + } + return sb.toString(); + } +} diff --git a/backend/src/main/java/de/assecutor/votianlt/service/InvoiceComplianceValidator.java b/backend/src/main/java/de/assecutor/votianlt/service/InvoiceComplianceValidator.java new file mode 100644 index 0000000..0af33e6 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/service/InvoiceComplianceValidator.java @@ -0,0 +1,189 @@ +package de.assecutor.votianlt.service; + +import de.assecutor.votianlt.model.invoices.CustomerInvoice; +import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem; +import de.assecutor.votianlt.model.invoices.InvoiceType; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; + +/** + * Prüft eine {@link CustomerInvoice} auf die Pflichtangaben nach § 14 UStG + * sowie auf interne Konsistenz (Beträge, Items). Wird vor jeder Festschreibung + * (Übergang DRAFT → ISSUED) aufgerufen; eine festgeschriebene Rechnung darf + * keine Pflichtfeld-Lücken mehr haben, da sie nach R-08 nicht mehr direkt + * änderbar ist. + * + * Nicht abgedeckt (bewusst): + *
    + *
  • Lückenlose Rechnungsnummer-Vergabe (separater Block).
  • + *
  • Online-Validierung der USt-IdNr beim Bzst (separater Block).
  • + *
  • Storno-/Korrekturbelege — diese haben eigene Beleg-Regeln (negierte Beträge, + * Pflicht-Verweis auf Originalrechnung), die hier nicht greifen.
  • + *
+ * + * Toleranz für Beträge: 1 Cent. Damit fängt der Validator typische Rundungs- + * differenzen aus dezimaler Arithmetik ab, ohne echte Inkonsistenzen zu schlucken. + */ +@Service +public class InvoiceComplianceValidator { + + private static final BigDecimal AMOUNT_TOLERANCE = new BigDecimal("0.01"); + + /** + * Wirft {@link InvoiceComplianceException} mit allen gefundenen Verstößen, + * wenn die Rechnung nicht festschreibungsreif ist. Andernfalls passiert nichts. + */ + public void validateForIssuance(CustomerInvoice invoice) { + if (invoice == null) { + throw new IllegalArgumentException("Rechnung darf nicht null sein."); + } + if (invoice.getType() != null && invoice.getType() != InvoiceType.INVOICE) { + // Storno/Korrektur folgen anderen Regeln und werden hier nicht geprüft. + return; + } + List violations = collectViolations(invoice); + if (!violations.isEmpty()) { + throw new InvoiceComplianceException(violations); + } + } + + private List collectViolations(CustomerInvoice invoice) { + List violations = new ArrayList<>(); + checkInvoiceNumber(invoice, violations); + checkDates(invoice, violations); + checkSender(invoice, violations); + checkRecipient(invoice, violations); + checkItems(invoice, violations); + checkAmounts(invoice, violations); + checkVatNotices(invoice, violations); + return violations; + } + + private void checkInvoiceNumber(CustomerInvoice invoice, List violations) { + if (isBlank(invoice.getInvoiceNumber())) { + violations.add("Rechnungsnummer fehlt (§ 14 Abs. 4 Nr. 4 UStG)."); + } + } + + private void checkDates(CustomerInvoice invoice, List violations) { + if (invoice.getInvoiceDate() == null) { + violations.add("Rechnungsdatum (Ausstellungsdatum) fehlt (§ 14 Abs. 4 Nr. 3 UStG)."); + } + if (invoice.getDeliveryDate() == null) { + violations.add("Leistungsdatum fehlt (§ 14 Abs. 4 Nr. 6 UStG). " + + "Bei zeitgleicher Leistung kann es dem Rechnungsdatum entsprechen — muss aber gesetzt sein."); + } + } + + private void checkSender(CustomerInvoice invoice, List violations) { + if (isBlank(invoice.getSenderName())) { + violations.add("Name des Leistenden (Absender) fehlt (§ 14 Abs. 4 Nr. 1 UStG)."); + } + if (isBlank(invoice.getSenderAddress()) || isBlank(invoice.getSenderPostcode()) + || isBlank(invoice.getSenderCity())) { + violations.add("Vollständige Anschrift des Leistenden (Straße, PLZ, Ort) fehlt (§ 14 Abs. 4 Nr. 1 UStG)."); + } + if (isBlank(invoice.getSenderTaxNumber()) && isBlank(invoice.getSenderVatId())) { + violations.add("Steuernummer oder USt-IdNr des Leistenden fehlt (§ 14 Abs. 4 Nr. 2 UStG)."); + } + } + + private void checkRecipient(CustomerInvoice invoice, List violations) { + if (isBlank(invoice.getRecipientName())) { + violations.add("Name des Leistungsempfängers fehlt (§ 14 Abs. 4 Nr. 1 UStG)."); + } + if (isBlank(invoice.getRecipientAddress()) || isBlank(invoice.getRecipientPostcode()) + || isBlank(invoice.getRecipientCity())) { + violations.add("Vollständige Anschrift des Leistungsempfängers (Straße, PLZ, Ort) fehlt " + + "(§ 14 Abs. 4 Nr. 1 UStG)."); + } + } + + private void checkItems(CustomerInvoice invoice, List violations) { + List items = invoice.getItems(); + if (items == null || items.isEmpty()) { + violations.add("Keine Positionen erfasst — Menge und Art der Leistung sind erforderlich " + + "(§ 14 Abs. 4 Nr. 5 UStG)."); + return; + } + for (int i = 0; i < items.size(); i++) { + CustomerInvoiceItem item = items.get(i); + int rowNumber = i + 1; + if (item == null) { + violations.add("Position " + rowNumber + ": leere Position."); + continue; + } + if (isBlank(item.getDescription())) { + violations.add("Position " + rowNumber + ": Bezeichnung der Leistung fehlt."); + } + if (item.getQuantity() == null || item.getQuantity().signum() <= 0) { + violations.add("Position " + rowNumber + ": Menge muss größer 0 sein."); + } + if (item.getUnitPrice() == null || item.getUnitPrice().signum() < 0) { + violations.add("Position " + rowNumber + ": Einzelpreis fehlt oder ist negativ."); + } + } + } + + private void checkAmounts(CustomerInvoice invoice, List violations) { + BigDecimal net = invoice.getNetAmount(); + BigDecimal vat = invoice.getVatAmount(); + BigDecimal total = invoice.getTotalAmount(); + if (net == null || vat == null || total == null) { + violations.add("Beträge unvollständig: Netto, Steuerbetrag und Bruttobetrag müssen ausgewiesen sein " + + "(§ 14 Abs. 4 Nr. 7 + 8 UStG)."); + return; + } + if (net.add(vat).subtract(total).abs().compareTo(AMOUNT_TOLERANCE) > 0) { + violations.add("Bruttobetrag passt nicht zu Netto + Steuerbetrag (Differenz > 1 Cent)."); + } + if (invoice.getItems() != null && !invoice.getItems().isEmpty()) { + BigDecimal sumItems = invoice.getItems().stream() + .map(CustomerInvoiceItem::getNetTotal) + .filter(java.util.Objects::nonNull) + .reduce(BigDecimal.ZERO, BigDecimal::add); + if (sumItems.subtract(net).abs().compareTo(AMOUNT_TOLERANCE) > 0) { + violations.add("Summe der Positionen (netto) " + sumItems + " weicht vom Rechnungs-Netto " + net + + " ab (Differenz > 1 Cent)."); + } + } + } + + private void checkVatNotices(CustomerInvoice invoice, List violations) { + BigDecimal rate = invoice.getVatRate(); + if (rate == null) { + violations.add("Steuersatz fehlt (§ 14 Abs. 4 Nr. 8 UStG)."); + return; + } + if (rate.signum() == 0) { + // Bei 0 % USt verlangt das UStG einen erklärenden Hinweis — entweder + // Reverse-Charge (§ 13b), Kleinunternehmerregelung (§ 19), eine + // innergemeinschaftliche Lieferung (§ 6a) oder eine andere Steuerbefreiung. + // Ohne Hinweis ist eine 0 %-Rechnung formal mangelhaft. + boolean hasNotice = !isBlank(invoice.getReverseChargeNote()) || !isBlank(invoice.getLegalNotes()); + if (!hasNotice) { + violations.add("Bei 0 % USt ist ein rechtlicher Hinweis erforderlich " + + "(z.B. \"Steuerschuldnerschaft des Leistungsempfängers\" nach § 13b UStG, " + + "Kleinunternehmerregelung § 19 UStG oder Steuerbefreiung). " + + "Bitte im Feld \"Reverse-Charge-Hinweis\" oder \"Rechtliche Hinweise\" ergänzen."); + } + } else { + BigDecimal expectedVat = invoice.getNetAmount() != null + ? invoice.getNetAmount().multiply(rate).setScale(2, RoundingMode.HALF_UP) + : null; + if (expectedVat != null && invoice.getVatAmount() != null + && expectedVat.subtract(invoice.getVatAmount()).abs().compareTo(AMOUNT_TOLERANCE) > 0) { + violations.add("Ausgewiesener Steuerbetrag " + invoice.getVatAmount() + + " passt nicht zu Netto × Steuersatz (erwartet " + expectedVat + ")."); + } + } + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/backend/src/main/java/de/assecutor/votianlt/service/InvoiceExportService.java b/backend/src/main/java/de/assecutor/votianlt/service/InvoiceExportService.java new file mode 100644 index 0000000..f7774a1 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/service/InvoiceExportService.java @@ -0,0 +1,182 @@ +package de.assecutor.votianlt.service; + +import de.assecutor.votianlt.model.invoices.CustomerInvoice; +import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry; +import de.assecutor.votianlt.model.invoices.InvoiceType; +import de.assecutor.votianlt.repository.CustomerInvoiceRepository; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * Export-Funktion für Rechnungen gemäß R-33 und R-34. + * + * Bündelt eine Originalrechnung mit allen erzeugten Folgebelegen + * (Storno, Berichtigung, Ersatzrechnung) sowie einer Manifest-Datei + * mit Audit-Log und Verkettungsangaben in einer ZIP-Datei. + */ +@Service +public class InvoiceExportService { + + private static final DateTimeFormatter TS_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", + Locale.GERMANY); + + private final CustomerInvoiceRepository invoiceRepository; + + public InvoiceExportService(CustomerInvoiceRepository invoiceRepository) { + this.invoiceRepository = invoiceRepository; + } + + /** + * Erzeugt ein ZIP-Archiv mit dem Originalbeleg, allen verlinkten Folgebelegen + * sowie einer Manifest-Datei. Ist die übergebene Rechnung selbst ein Folgebeleg, + * wird automatisch das Bündel um die zugehörige Originalrechnung erweitert. + */ + public byte[] exportInvoicePackage(CustomerInvoice anchor) { + if (anchor == null) { + throw new IllegalArgumentException("Rechnung erforderlich."); + } + + CustomerInvoice root = resolveRoot(anchor); + List bundle = collectBundle(root); + + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zip = new ZipOutputStream(baos, StandardCharsets.UTF_8)) { + + for (CustomerInvoice invoice : bundle) { + writePdfEntry(zip, invoice); + } + + String manifest = buildManifest(root, bundle); + ZipEntry manifestEntry = new ZipEntry("MANIFEST.txt"); + zip.putNextEntry(manifestEntry); + zip.write(manifest.getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + + zip.finish(); + return baos.toByteArray(); + } catch (Exception ex) { + throw new IllegalStateException("Export fehlgeschlagen: " + ex.getMessage(), ex); + } + } + + /** + * Schlägt einen Dateinamen für das ZIP-Archiv auf Basis des Originalbelegs vor. + */ + public String suggestFilename(CustomerInvoice anchor) { + CustomerInvoice root = resolveRoot(anchor); + String number = root.getInvoiceNumber() != null ? root.getInvoiceNumber() : root.getId(); + return "Rechnung_" + sanitize(number) + ".zip"; + } + + private CustomerInvoice resolveRoot(CustomerInvoice anchor) { + if (anchor.getType() != InvoiceType.INVOICE && anchor.getOriginalInvoiceId() != null) { + return invoiceRepository.findById(anchor.getOriginalInvoiceId()).orElse(anchor); + } + return anchor; + } + + private List collectBundle(CustomerInvoice root) { + List result = new ArrayList<>(); + Set seen = new HashSet<>(); + result.add(root); + seen.add(root.getId()); + + for (CustomerInvoice related : invoiceRepository.findByOriginalInvoiceId(root.getId())) { + if (related.getId() != null && seen.add(related.getId())) { + result.add(related); + } + } + return result; + } + + private void writePdfEntry(ZipOutputStream zip, CustomerInvoice invoice) throws java.io.IOException { + if (invoice.getPdfData() == null || invoice.getPdfData().length == 0) { + return; + } + String label = switch (invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE) { + case INVOICE -> "Rechnung"; + case CANCELLATION -> "Storno"; + case CORRECTION -> "Berichtigung"; + }; + String number = invoice.getInvoiceNumber() != null ? invoice.getInvoiceNumber() : invoice.getId(); + String name = sanitize(label + "_" + number) + ".pdf"; + ZipEntry entry = new ZipEntry(name); + zip.putNextEntry(entry); + zip.write(invoice.getPdfData()); + zip.closeEntry(); + } + + private String buildManifest(CustomerInvoice root, List bundle) { + StringBuilder sb = new StringBuilder(); + sb.append("Rechnungspaket\n"); + sb.append("===============\n\n"); + sb.append("Originalrechnung: ").append(safe(root.getInvoiceNumber())); + if (root.getInvoiceDate() != null) { + sb.append(" vom ").append(root.getInvoiceDate()); + } + sb.append("\n"); + sb.append("Status: ").append(root.getStatus()).append("\n"); + sb.append("Zahlungsstatus: ").append(root.getPaymentStatus()).append("\n"); + if (root.getTotalAmount() != null) { + sb.append("Gesamtbetrag: ").append(root.getTotalAmount()).append("\n"); + } + if (root.getPaidAmount() != null) { + sb.append("Bezahlt: ").append(root.getPaidAmount()).append("\n"); + } + + sb.append("\nEnthaltene Belege:\n"); + for (CustomerInvoice invoice : bundle) { + sb.append("- [").append(invoice.getType()).append("] ") + .append(safe(invoice.getInvoiceNumber())); + if (invoice.getInvoiceDate() != null) { + sb.append(" vom ").append(invoice.getInvoiceDate()); + } + sb.append(" — Status ").append(invoice.getStatus()).append("\n"); + } + + sb.append("\nÄnderungsprotokoll der Originalrechnung:\n"); + List log = root.getAuditLog(); + if (log == null || log.isEmpty()) { + sb.append("(keine Einträge)\n"); + } else { + for (InvoiceAuditEntry entry : log) { + sb.append("- "); + sb.append(entry.getTimestamp() != null ? entry.getTimestamp().format(TS_FMT) : "-"); + sb.append(" · ").append(entry.getAction()); + sb.append(" · ").append(safe(entry.getUserDisplayName())); + if (entry.getReason() != null && !entry.getReason().isBlank()) { + sb.append(" — ").append(entry.getReason()); + } + if (entry.getResultingInvoiceNumber() != null) { + sb.append(" → ").append(entry.getResultingInvoiceNumber()); + } + sb.append("\n"); + } + } + sb.append( + "\nHinweis: Dieses Paket dient der gemeinsamen Aufbewahrung von Original und Folgebelegen.\n"); + sb.append("Die rechtliche Aufbewahrungspflicht liegt beim Aussteller (R-31/R-32).\n"); + return sb.toString(); + } + + private String safe(String value) { + return value != null ? value : ""; + } + + private String sanitize(String input) { + if (input == null) { + return "Beleg"; + } + return input.replaceAll("[^A-Za-z0-9._-]", "_"); + } +} diff --git a/backend/src/main/java/de/assecutor/votianlt/service/InvoiceLifecycleException.java b/backend/src/main/java/de/assecutor/votianlt/service/InvoiceLifecycleException.java new file mode 100644 index 0000000..4ccfd6d --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/service/InvoiceLifecycleException.java @@ -0,0 +1,18 @@ +package de.assecutor.votianlt.service; + +/** + * Wird geworfen, wenn ein Statusübergang oder eine Änderung an einer Rechnung + * gegen die Regeln aus invoices_rules.md verstößt (z.B. R-03, R-08, R-11, R-35). + * + * Die Nachricht ist als Anwendertext formuliert und kann direkt in der UI angezeigt werden. + */ +public class InvoiceLifecycleException extends RuntimeException { + + public InvoiceLifecycleException(String message) { + super(message); + } + + public InvoiceLifecycleException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/backend/src/main/java/de/assecutor/votianlt/service/InvoiceLifecycleService.java b/backend/src/main/java/de/assecutor/votianlt/service/InvoiceLifecycleService.java new file mode 100644 index 0000000..d9bf4d9 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/service/InvoiceLifecycleService.java @@ -0,0 +1,572 @@ +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; +import de.assecutor.votianlt.model.invoices.InvoiceType; +import de.assecutor.votianlt.model.invoices.PaymentStatus; +import de.assecutor.votianlt.repository.CustomerInvoiceRepository; +import de.assecutor.votianlt.security.SecurityService; +import org.bson.types.ObjectId; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * Verwaltet den Lebenszyklus einer {@link CustomerInvoice} gemäß den Regeln aus + * invoices_rules.md. + * + * Phase 1 stellt die Status- und Audit-Mechanik bereit: + *
    + *
  • R-02/R-03: Entwürfe sind editier-/löschbar, finalisierte Belege nicht.
  • + *
  • R-07: Finalisierung markiert eine Rechnung als verbindlich.
  • + *
  • R-08/R-11: Verhindert Doppelvergabe der Rechnungsnummer und unsichtbare Änderungen.
  • + *
  • R-35: Direktes Löschen finalisierter Belege wird abgelehnt.
  • + *
  • R-36 bis R-39: Jede Statusänderung wird im Audit-Log protokolliert.
  • + *
+ * + * Korrektur-/Storno-Workflows folgen in Phase 2; entsprechende Hooks werden hier vorbereitet. + */ +@Service +public class InvoiceLifecycleService { + + private static final Logger log = LoggerFactory.getLogger(InvoiceLifecycleService.class); + + 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, + InvoiceNumberAuditService numberAuditService) { + this.invoiceRepository = invoiceRepository; + this.securityService = securityService; + this.eInvoiceService = eInvoiceService; + this.complianceValidator = complianceValidator; + this.numberAuditService = numberAuditService; + } + + /** + * Persistiert einen neu erzeugten Rechnungsentwurf (Status DRAFT). + */ + public CustomerInvoice createDraft(CustomerInvoice draft, String reason) { + if (draft == null) { + throw new IllegalArgumentException("Rechnungsentwurf darf nicht null sein."); + } + draft.setStatus(InvoiceStatus.DRAFT); + if (draft.getType() == null) { + draft.setType(InvoiceType.INVOICE); + } + draft.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason)); + return invoiceRepository.save(draft); + } + + /** + * Erzeugt eine Rechnung und finalisiert sie unmittelbar (Status ISSUED). + * Wird vom bestehenden Erstell-Flow verwendet, der Vorschau und Speichern in + * einem Schritt vereint (R-06/R-07). + */ + public CustomerInvoice createAndIssue(CustomerInvoice invoice, String reason) { + if (invoice == null) { + throw new IllegalArgumentException("Rechnung darf nicht null sein."); + } + if (invoice.getType() == null) { + invoice.setType(InvoiceType.INVOICE); + } + complianceValidator.validateForIssuance(invoice); + ensureInvoiceNumberUnique(invoice); + invoice.setStatus(InvoiceStatus.ISSUED); + invoice.setIssuedAt(LocalDateTime.now()); + invoice.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason)); + invoice.addAuditEntry(audit(InvoiceAuditAction.ISSUED, reason)); + CustomerInvoice saved = invoiceRepository.save(invoice); + numberAuditService.markUsed(saved); + return saved; + } + + /** + * Speichert Änderungen an einem bestehenden Entwurf (R-02/R-05). + * Lehnt Änderungen an finalisierten Rechnungen ab (R-03/R-08). + */ + public CustomerInvoice updateDraft(CustomerInvoice draft, String reason) { + if (draft == null || draft.getId() == null) { + throw new IllegalArgumentException("Bestehender Entwurf erwartet."); + } + CustomerInvoice persisted = invoiceRepository.findById(draft.getId()) + .orElseThrow(() -> new IllegalStateException("Rechnung nicht gefunden: " + draft.getId())); + if (!persisted.getStatus().isMutable()) { + throw new InvoiceLifecycleException( + "Diese Rechnung ist bereits ausgestellt und kann nicht mehr direkt bearbeitet werden. " + + "Bitte erstellen Sie eine Berichtigung oder ein Storno."); + } + draft.setStatus(InvoiceStatus.DRAFT); + draft.setAuditLog(persisted.getAuditLog()); + draft.addAuditEntry(audit(InvoiceAuditAction.UPDATED_DRAFT, reason)); + return invoiceRepository.save(draft); + } + + /** + * Finalisiert einen Entwurf (Status ISSUED). Stellt sicher, dass die Rechnungsnummer + * eindeutig ist (R-11) und protokolliert den Wechsel. + */ + public CustomerInvoice issue(String invoiceId, String reason) { + CustomerInvoice invoice = requireInvoice(invoiceId); + if (invoice.getStatus() == InvoiceStatus.ISSUED || invoice.getStatus() == InvoiceStatus.SENT) { + return invoice; + } + if (invoice.getStatus() != InvoiceStatus.DRAFT) { + throw new InvoiceLifecycleException( + "Nur Entwürfe können ausgestellt werden. Aktueller Status: " + invoice.getStatus()); + } + complianceValidator.validateForIssuance(invoice); + ensureInvoiceNumberUnique(invoice); + invoice.setStatus(InvoiceStatus.ISSUED); + invoice.setIssuedAt(LocalDateTime.now()); + invoice.addAuditEntry(audit(InvoiceAuditAction.ISSUED, reason)); + CustomerInvoice saved = invoiceRepository.save(invoice); + numberAuditService.markUsed(saved); + return saved; + } + + /** + * Markiert eine ausgestellte Rechnung als versendet (R-08). + */ + public CustomerInvoice markAsSent(String invoiceId, String reason) { + CustomerInvoice invoice = requireInvoice(invoiceId); + if (invoice.getStatus() == InvoiceStatus.DRAFT) { + throw new InvoiceLifecycleException( + "Eine Rechnung muss vor dem Versand zunächst ausgestellt werden."); + } + if (invoice.getStatus() == InvoiceStatus.CANCELLED) { + throw new InvoiceLifecycleException("Eine stornierte Rechnung kann nicht mehr versendet werden."); + } + invoice.setStatus(InvoiceStatus.SENT); + invoice.setSentAt(LocalDateTime.now()); + invoice.addAuditEntry(audit(InvoiceAuditAction.SENT, reason)); + return invoiceRepository.save(invoice); + } + + /** + * Erzeugt einen Stornobeleg zu einer bereits ausgestellten Rechnung (R-17 bis R-22). + * + * Der Stornobeleg ist ein eigenständiger Beleg vom Typ {@link InvoiceType#CANCELLATION} + * mit eigener (neuer) Rechnungsnummer. Die Originalrechnung wird auf + * {@link InvoiceStatus#CANCELLED} gesetzt; Original und Storno sind über + * {@code originalInvoiceId} bzw. {@code cancellationInvoiceId} verlinkt. + * + * @param originalId ID der zu stornierenden Rechnung + * @param cancellationNumber neue, fortlaufende Rechnungsnummer für den Stornobeleg + * @param cancellationDate Belegdatum des Stornos + * @param pdfData generiertes PDF des Stornobelegs + * @param reason nachvollziehbarer Grund (R-36) + */ + public CustomerInvoice cancel(String originalId, String cancellationNumber, LocalDate cancellationDate, + byte[] pdfData, String reason) { + CustomerInvoice original = requireInvoice(originalId); + if (original.getType() != InvoiceType.INVOICE) { + throw new InvoiceLifecycleException( + "Nur reguläre Rechnungen können storniert werden. Belegtyp: " + original.getType()); + } + if (original.getStatus() == InvoiceStatus.CANCELLED) { + throw new InvoiceLifecycleException("Diese Rechnung ist bereits storniert."); + } + if (original.getStatus() == InvoiceStatus.DRAFT) { + throw new InvoiceLifecycleException( + "Ein Entwurf wird nicht storniert, sondern gelöscht oder bearbeitet."); + } + if (cancellationNumber == null || cancellationNumber.isBlank()) { + throw new InvoiceLifecycleException("Stornobeleg benötigt eine fortlaufende Belegnummer."); + } + + CustomerInvoice cancellation = new CustomerInvoice(); + cancellation.setType(InvoiceType.CANCELLATION); + cancellation.setStatus(InvoiceStatus.ISSUED); + cancellation.setInvoiceNumber(cancellationNumber); + cancellation.setInvoiceDate(cancellationDate != null ? cancellationDate : LocalDate.now()); + cancellation.setIssuedAt(LocalDateTime.now()); + cancellation.setUserId(original.getUserId()); + cancellation.setJobId(original.getJobId()); + cancellation.setOriginalInvoiceId(original.getId()); + cancellation.setOriginalInvoiceNumber(original.getInvoiceNumber()); + cancellation.setOriginalInvoiceDate(original.getInvoiceDate()); + + // Empfänger-/Sender-Daten übernehmen für vollständige Pflichtangaben + copyParties(original, cancellation); + cancellation.setItems(original.getItems()); + + // Beträge negieren – Storno bucht den Originalbetrag aus + cancellation.setNetAmount(negate(original.getNetAmount())); + cancellation.setVatRate(original.getVatRate()); + cancellation.setVatAmount(negate(original.getVatAmount())); + cancellation.setTotalAmount(negate(original.getTotalAmount())); + + cancellation.setDescription("Stornorechnung zu Rechnung " + original.getInvoiceNumber()); + cancellation.setPdfData(applyEInvoiceIfApplicable(pdfData, cancellation, original)); + cancellation.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason)); + InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason); + issuedEntry.setResultingInvoiceNumber(cancellationNumber); + cancellation.addAuditEntry(issuedEntry); + + ensureInvoiceNumberUnique(cancellation); + CustomerInvoice savedCancellation = invoiceRepository.save(cancellation); + numberAuditService.markUsed(savedCancellation); + + // Original markieren und verlinken + original.setStatus(InvoiceStatus.CANCELLED); + original.setCancelledAt(LocalDateTime.now()); + original.setCancellationInvoiceId(savedCancellation.getId()); + // Wenn die Originalrechnung bereits (teil-)bezahlt war, entsteht ein Erstattungsanspruch (R-26) + BigDecimal paid = original.getPaidAmount() != null ? original.getPaidAmount() : BigDecimal.ZERO; + original.setPaymentStatus(computePaymentStatus(original, paid)); + InvoiceAuditEntry cancelEntry = audit(InvoiceAuditAction.CANCELLED, reason); + cancelEntry.setResultingInvoiceId(savedCancellation.getId()); + cancelEntry.setResultingInvoiceNumber(savedCancellation.getInvoiceNumber()); + original.addAuditEntry(cancelEntry); + invoiceRepository.save(original); + + log.info("Rechnung {} storniert durch Beleg {}.", original.getInvoiceNumber(), + savedCancellation.getInvoiceNumber()); + return savedCancellation; + } + + /** + * Erzeugt einen Berichtigungsbeleg zu einer bereits ausgestellten Rechnung (R-12 bis R-16). + * + * Eine Berichtigung adressiert formale Fehler (Adresse, Leistungsdatum, Pflichtangabe). + * Sie ersetzt die Originalrechnung nicht, sondern verweist auf sie. Originalrechnung + * wechselt in den Status {@link InvoiceStatus#CORRECTED} und hält eine Referenz auf den + * Berichtigungsbeleg. + * + * @param originalId ID der zu berichtigenden Rechnung + * @param correctionNumber fortlaufende Belegnummer für den Berichtigungsbeleg + * @param correctionDate Belegdatum + * @param pdfData generiertes PDF des Berichtigungsbelegs + * @param correctedFields Beschreibung der ergänzten/korrigierten Angaben (R-14) + * @param reason Grund der Berichtigung (R-36) + */ + public CustomerInvoice correct(String originalId, String correctionNumber, LocalDate correctionDate, + byte[] pdfData, String correctedFields, String reason) { + CustomerInvoice original = requireInvoice(originalId); + if (original.getType() != InvoiceType.INVOICE) { + throw new InvoiceLifecycleException( + "Nur reguläre Rechnungen können berichtigt werden. Belegtyp: " + original.getType()); + } + if (original.getStatus() == InvoiceStatus.DRAFT) { + throw new InvoiceLifecycleException( + "Ein Entwurf wird nicht berichtigt, sondern direkt bearbeitet."); + } + if (original.getStatus() == InvoiceStatus.CANCELLED) { + throw new InvoiceLifecycleException( + "Eine bereits stornierte Rechnung kann nicht berichtigt werden. Erstellen Sie eine neue Rechnung."); + } + if (correctionNumber == null || correctionNumber.isBlank()) { + throw new InvoiceLifecycleException("Berichtigungsbeleg benötigt eine fortlaufende Belegnummer."); + } + + CustomerInvoice correction = new CustomerInvoice(); + correction.setType(InvoiceType.CORRECTION); + correction.setStatus(InvoiceStatus.ISSUED); + correction.setInvoiceNumber(correctionNumber); + correction.setInvoiceDate(correctionDate != null ? correctionDate : LocalDate.now()); + correction.setIssuedAt(LocalDateTime.now()); + correction.setUserId(original.getUserId()); + correction.setJobId(original.getJobId()); + correction.setOriginalInvoiceId(original.getId()); + correction.setOriginalInvoiceNumber(original.getInvoiceNumber()); + correction.setOriginalInvoiceDate(original.getInvoiceDate()); + + copyParties(original, correction); + correction.setItems(original.getItems()); + correction.setNetAmount(original.getNetAmount()); + correction.setVatRate(original.getVatRate()); + correction.setVatAmount(original.getVatAmount()); + correction.setTotalAmount(original.getTotalAmount()); + + String descriptionPrefix = "Berichtigung zu Rechnung " + original.getInvoiceNumber(); + correction.setDescription( + correctedFields == null || correctedFields.isBlank() ? descriptionPrefix + : descriptionPrefix + " — " + correctedFields); + correction.setPdfData(applyEInvoiceIfApplicable(pdfData, correction, original)); + correction.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason)); + InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason); + issuedEntry.setResultingInvoiceNumber(correctionNumber); + correction.addAuditEntry(issuedEntry); + + ensureInvoiceNumberUnique(correction); + CustomerInvoice savedCorrection = invoiceRepository.save(correction); + numberAuditService.markUsed(savedCorrection); + + original.setStatus(InvoiceStatus.CORRECTED); + original.setCorrectionInvoiceId(savedCorrection.getId()); + InvoiceAuditEntry correctEntry = audit(InvoiceAuditAction.CORRECTED, reason); + correctEntry.setResultingInvoiceId(savedCorrection.getId()); + correctEntry.setResultingInvoiceNumber(savedCorrection.getInvoiceNumber()); + original.addAuditEntry(correctEntry); + invoiceRepository.save(original); + + log.info("Rechnung {} berichtigt durch Beleg {}.", original.getInvoiceNumber(), + savedCorrection.getInvoiceNumber()); + return savedCorrection; + } + + /** + * Speichert eine vollständig neue Ersatzrechnung nach einem Storno (R-20 bis R-22). + * Die neue Rechnung erhält eine eigene Rechnungsnummer und referenziert die stornierte + * Originalrechnung, sodass die Verkettung Original → Storno → Ersatzrechnung erhalten bleibt. + */ + public CustomerInvoice createReplacementInvoice(String cancelledOriginalId, CustomerInvoice replacement, + String reason) { + CustomerInvoice cancelledOriginal = requireInvoice(cancelledOriginalId); + if (cancelledOriginal.getStatus() != InvoiceStatus.CANCELLED) { + throw new InvoiceLifecycleException( + "Eine Ersatzrechnung kann nur zu einer stornierten Rechnung erstellt werden."); + } + if (replacement == null) { + throw new IllegalArgumentException("Ersatzrechnung darf nicht null sein."); + } + replacement.setType(InvoiceType.INVOICE); + replacement.setOriginalInvoiceId(cancelledOriginal.getId()); + replacement.setOriginalInvoiceNumber(cancelledOriginal.getInvoiceNumber()); + replacement.setOriginalInvoiceDate(cancelledOriginal.getInvoiceDate()); + + CustomerInvoice savedReplacement = createAndIssue(replacement, + reason != null ? reason : "Ersatzrechnung nach Storno"); + + cancelledOriginal.setReplacementInvoiceId(savedReplacement.getId()); + InvoiceAuditEntry replaceEntry = audit(InvoiceAuditAction.REPLACED, reason); + replaceEntry.setResultingInvoiceId(savedReplacement.getId()); + replaceEntry.setResultingInvoiceNumber(savedReplacement.getInvoiceNumber()); + cancelledOriginal.addAuditEntry(replaceEntry); + invoiceRepository.save(cancelledOriginal); + + return savedReplacement; + } + + /** + * Liefert die Folgebelege (Storno, Berichtigung, Ersatzrechnung) zu einer Rechnung. + */ + public List findRelatedDocuments(String originalInvoiceId) { + if (originalInvoiceId == null) { + return List.of(); + } + return invoiceRepository.findByOriginalInvoiceId(originalInvoiceId); + } + + /** + * Erfasst eine Zahlung zur Rechnung (R-23 bis R-26). + * + * Eine Zahlung wird ausschließlich erfasst – die Rechnungsdaten selbst werden nicht + * verändert (R-25). Der Zahlungsstatus ergibt sich aus dem Verhältnis Zahlung zu + * Bruttobetrag der Rechnung. + * + * @param invoiceId Rechnung, auf die gebucht wird + * @param amount erfasster Zahlbetrag (kann negativ sein für Korrekturen) + * @param paymentReference frei wählbarer Referenztext (z.B. Kontoauszug, Beleg) + * @param reason erläuternder Grund für das Audit-Log + */ + public CustomerInvoice registerPayment(String invoiceId, BigDecimal amount, String paymentReference, + String reason) { + if (amount == null) { + throw new IllegalArgumentException("Zahlbetrag erforderlich."); + } + CustomerInvoice invoice = requireInvoice(invoiceId); + if (invoice.getStatus() == InvoiceStatus.DRAFT) { + throw new InvoiceLifecycleException( + "Auf Entwürfen können keine Zahlungen erfasst werden. Bitte zuerst ausstellen."); + } + if (invoice.getType() == InvoiceType.CORRECTION) { + throw new InvoiceLifecycleException( + "Auf Berichtigungsbelegen werden keine Zahlungen erfasst – buchen Sie auf der Originalrechnung."); + } + + BigDecimal previous = invoice.getPaidAmount() != null ? invoice.getPaidAmount() : BigDecimal.ZERO; + BigDecimal newPaid = previous.add(amount); + invoice.setPaidAmount(newPaid); + invoice.setLastPaymentAt(LocalDateTime.now()); + invoice.setPaymentStatus(computePaymentStatus(invoice, newPaid)); + + StringBuilder logReason = new StringBuilder(); + logReason.append("Zahlung erfasst: ").append(amount.toPlainString()); + if (paymentReference != null && !paymentReference.isBlank()) { + logReason.append(" (Referenz: ").append(paymentReference).append(")"); + } + if (reason != null && !reason.isBlank()) { + logReason.append(" – ").append(reason); + } + invoice.addAuditEntry(audit(InvoiceAuditAction.PAYMENT_RECORDED, logReason.toString())); + + return invoiceRepository.save(invoice); + } + + /** + * Liefert den noch offenen Betrag einer Rechnung. Negative Werte zeigen eine + * Überzahlung bzw. einen Erstattungsanspruch (R-26). + */ + public BigDecimal computeOutstandingAmount(CustomerInvoice invoice) { + BigDecimal total = invoice.getTotalAmount() != null ? invoice.getTotalAmount() : BigDecimal.ZERO; + BigDecimal paid = invoice.getPaidAmount() != null ? invoice.getPaidAmount() : BigDecimal.ZERO; + return total.subtract(paid); + } + + private PaymentStatus computePaymentStatus(CustomerInvoice invoice, BigDecimal paid) { + BigDecimal total = invoice.getTotalAmount() != null ? invoice.getTotalAmount() : BigDecimal.ZERO; + if (invoice.getStatus() == InvoiceStatus.CANCELLED) { + return paid.signum() == 0 ? PaymentStatus.UNPAID : PaymentStatus.REFUND_DUE; + } + int cmp = paid.compareTo(total); + if (paid.signum() == 0) { + return PaymentStatus.UNPAID; + } + if (cmp == 0) { + return PaymentStatus.PAID; + } + if (cmp < 0) { + return PaymentStatus.PARTIALLY_PAID; + } + return PaymentStatus.OVERPAID; + } + + /** + * Löscht einen Entwurf. Finalisierte Rechnungen dürfen nicht gelöscht werden (R-35). + */ + public void deleteDraft(String invoiceId, String reason) { + CustomerInvoice invoice = requireInvoice(invoiceId); + if (!invoice.getStatus().isMutable()) { + throw new InvoiceLifecycleException( + "Finalisierte Rechnungen können nicht gelöscht werden. " + + "Bitte führen Sie stattdessen ein Storno oder eine Berichtigung durch."); + } + invoice.addAuditEntry(audit(InvoiceAuditAction.DELETED_DRAFT, reason)); + log.info("Rechnungsentwurf {} wird gelöscht: {}", invoiceId, reason); + invoiceRepository.delete(invoice); + // Wenn dem Entwurf bereits eine Nummer reserviert wurde, dokumentiert das + // Verwerfen jetzt die Lücke im Nummernkreis — sonst bliebe die Reservierung + // als unerklärter „RESERVED"-Eintrag im Audit hängen. + if (invoice.getInvoiceNumber() != null && !invoice.getInvoiceNumber().isBlank() + && invoice.getUserId() != null) { + try { + ObjectId userId = new ObjectId(invoice.getUserId()); + String voidReason = (reason != null && !reason.isBlank()) + ? reason + : "Rechnungsentwurf gelöscht"; + numberAuditService.markVoided(userId, invoice.getInvoiceNumber(), voidReason); + } catch (IllegalArgumentException | InvoiceLifecycleException ex) { + // Keine Reservierung vorhanden oder UserId nicht parsebar — Lücken-Detektor + // erfasst das später; das Löschen selbst soll nicht blockiert werden. + log.debug("VOIDED-Markierung beim Löschen des Entwurfs übersprungen: {}", ex.getMessage()); + } + } + } + + public Optional findById(String invoiceId) { + return invoiceRepository.findById(invoiceId); + } + + private CustomerInvoice requireInvoice(String invoiceId) { + if (invoiceId == null || invoiceId.isBlank()) { + throw new IllegalArgumentException("Rechnungs-ID erforderlich."); + } + return invoiceRepository.findById(invoiceId) + .orElseThrow(() -> new IllegalStateException("Rechnung nicht gefunden: " + invoiceId)); + } + + private void ensureInvoiceNumberUnique(CustomerInvoice invoice) { + String number = invoice.getInvoiceNumber(); + if (number == null || number.isBlank()) { + throw new InvoiceLifecycleException("Eine ausgestellte Rechnung benötigt eine Rechnungsnummer."); + } + invoiceRepository.findByInvoiceNumberAndStatusNot(number, InvoiceStatus.CANCELLED).ifPresent(existing -> { + if (!existing.getId().equals(invoice.getId())) { + throw new InvoiceLifecycleException( + "Rechnungsnummer " + number + " wird bereits von einer aktiven Rechnung verwendet."); + } + }); + } + + private InvoiceAuditEntry audit(InvoiceAuditAction action, String reason) { + String userId = null; + String displayName = "system"; + try { + User user = securityService.getCurrentDatabaseUser(); + userId = user.getId() != null ? user.getId().toHexString() : null; + String composed = (safe(user.getFirstname()) + " " + safe(user.getName())).trim(); + displayName = composed.isBlank() ? safe(user.getEmail()) : composed; + } catch (Exception ignored) { + // Audit funktioniert auch außerhalb einer Vaadin-Session (z.B. Migration). + } + return new InvoiceAuditEntry(action, userId, displayName, reason); + } + + private String safe(String value) { + return value != null ? value : ""; + } + + private void copyParties(CustomerInvoice source, CustomerInvoice target) { + target.setSenderName(source.getSenderName()); + target.setSenderAddress(source.getSenderAddress()); + target.setSenderPostcode(source.getSenderPostcode()); + target.setSenderCity(source.getSenderCity()); + target.setSenderCountry(source.getSenderCountry()); + target.setSenderTaxNumber(source.getSenderTaxNumber()); + target.setSenderVatId(source.getSenderVatId()); + target.setSenderPhone(source.getSenderPhone()); + target.setSenderEmail(source.getSenderEmail()); + target.setSenderWebsite(source.getSenderWebsite()); + + target.setRecipientName(source.getRecipientName()); + target.setRecipientCompany(source.getRecipientCompany()); + target.setRecipientAddress(source.getRecipientAddress()); + target.setRecipientPostcode(source.getRecipientPostcode()); + target.setRecipientCity(source.getRecipientCity()); + target.setRecipientCountry(source.getRecipientCountry()); + target.setRecipientVatId(source.getRecipientVatId()); + } + + 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/InvoiceNumberAuditService.java b/backend/src/main/java/de/assecutor/votianlt/service/InvoiceNumberAuditService.java new file mode 100644 index 0000000..99aa9a1 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/service/InvoiceNumberAuditService.java @@ -0,0 +1,166 @@ +package de.assecutor.votianlt.service; + +import de.assecutor.votianlt.model.invoices.CustomerInvoice; +import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation; +import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus; +import de.assecutor.votianlt.repository.InvoiceNumberReservationRepository; +import org.bson.types.ObjectId; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +/** + * Verwaltet das Audit der vergebenen Rechnungsnummern. Jede Vergabe aus dem + * Counter wird als RESERVED protokolliert; dieser Service vollzieht die + * Status-Übergänge nach (USED beim Festschreiben, VOIDED beim Verwerfen) + * und liefert Lücken-Reports für die Betriebsprüfung. + * + * Pflichtgrundlage: § 14 Abs. 4 Nr. 4 UStG verlangt eine fortlaufende + * Rechnungsnummer; lückenhafte Nummernkreise sind nur zulässig, wenn jede + * fehlende Nummer dokumentiert erklärt werden kann (GoBD). + * + * Fehler beim Audit-Schreiben werden bewusst nicht propagiert: Die + * fachliche Operation (Rechnung festschreiben) hat Vorrang. Verlorene + * USED-Markierungen sind über den Lücken-Report nachträglich rekonstruierbar. + */ +@Service +public class InvoiceNumberAuditService { + + private static final Logger log = LoggerFactory.getLogger(InvoiceNumberAuditService.class); + + private final InvoiceNumberReservationRepository repository; + + public InvoiceNumberAuditService(InvoiceNumberReservationRepository repository) { + this.repository = repository; + } + + /** + * Markiert die Reservierung der übergebenen Rechnung als USED. + * Wird vom Lifecycle nach erfolgreichem Festschreiben aufgerufen. + */ + public void markUsed(CustomerInvoice invoice) { + if (invoice == null || invoice.getInvoiceNumber() == null || invoice.getUserId() == null) { + return; + } + ObjectId userId = parseUserId(invoice.getUserId()); + if (userId == null) { + return; + } + try { + Optional existing = repository.findByUserIdAndNumber(userId, + invoice.getInvoiceNumber()); + InvoiceNumberReservation reservation = existing.orElseGet(() -> bootstrapReservation(userId, invoice)); + reservation.setStatus(InvoiceNumberReservationStatus.USED); + reservation.setInvoiceId(invoice.getId()); + reservation.setUsedAt(Instant.now()); + repository.save(reservation); + } catch (Exception ex) { + log.warn("USED-Markierung für Nummer {} (Rechnung {}) fehlgeschlagen: {}", + invoice.getInvoiceNumber(), invoice.getId(), ex.getMessage(), ex); + } + } + + /** + * Markiert die zur übergebenen Nummer gehörende Reservierung als VOIDED. + * Pflichtfeld {@code reason} dokumentiert die Erklärung der Lücke. + */ + public void markVoided(ObjectId userId, String number, String reason) { + if (userId == null || number == null || number.isBlank()) { + throw new IllegalArgumentException("userId und number sind Pflichtparameter."); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("Grund (reason) ist Pflicht beim Verwerfen einer Reservierung."); + } + InvoiceNumberReservation reservation = repository.findByUserIdAndNumber(userId, number) + .orElseThrow(() -> new InvoiceLifecycleException( + "Keine Reservierung für Nummer " + number + " gefunden.")); + if (reservation.getStatus() == InvoiceNumberReservationStatus.USED) { + throw new InvoiceLifecycleException( + "Nummer " + number + " ist bereits einer ausgestellten Rechnung zugeordnet " + + "und kann nicht verworfen werden."); + } + reservation.setStatus(InvoiceNumberReservationStatus.VOIDED); + reservation.setVoidReason(reason); + reservation.setVoidedAt(Instant.now()); + repository.save(reservation); + } + + /** + * Liefert alle Reservierungen eines Nutzers in Sequenzreihenfolge. + * Basis für vollständige Audit-Reports. + */ + public List findAll(ObjectId userId) { + return repository.findByUserIdOrderBySequenceAsc(userId); + } + + /** + * Liefert nur die noch nicht verwendeten Reservierungen eines Nutzers + * (Status RESERVED oder VOIDED). Im Idealfall ist diese Liste leer oder + * enthält ausschließlich VOIDED-Einträge mit dokumentiertem Grund. + * Verbleibende RESERVED-Einträge nach abgeschlossenen Vorgängen sind + * unerklärte Lücken und sollten in der UI hervorgehoben werden. + */ + public List findUnused(ObjectId userId) { + List all = repository.findByUserIdOrderBySequenceAsc(userId); + return all.stream() + .filter(r -> r.getStatus() != InvoiceNumberReservationStatus.USED) + .toList(); + } + + /** + * Erzeugt einen Bootstrap-Eintrag für Rechnungen, die ohne vorausgegangene + * Reservierung festgeschrieben wurden — z.B. Bestandsdaten aus der Zeit + * vor Einführung des Reservierungs-Audits oder Storno-/Korrekturbelege, + * deren Nummer extern übergeben wurde. Status wird direkt auf USED gesetzt. + */ + private InvoiceNumberReservation bootstrapReservation(ObjectId userId, CustomerInvoice invoice) { + InvoiceNumberReservation reservation = new InvoiceNumberReservation(); + reservation.setUserId(userId); + reservation.setNumber(invoice.getInvoiceNumber()); + reservation.setSequence(extractSequence(invoice.getInvoiceNumber())); + reservation.setReservedAt(Instant.now()); + reservation.setReservedBy("system (bootstrap)"); + return reservation; + } + + /** + * Best-effort-Extraktion der numerischen Sequenz aus einer formatierten + * Rechnungsnummer (z.B. „RE-2026-000123" → 123). Liefert -1, wenn keine + * trailing-Ziffern vorhanden sind — dann ist die Nummer für die + * Lücken-Sortierung ungeeignet, aber der Audit-Eintrag bleibt erhalten. + */ + private long extractSequence(String number) { + if (number == null) { + return -1L; + } + int end = number.length(); + int start = end; + while (start > 0 && Character.isDigit(number.charAt(start - 1))) { + start--; + } + if (start == end) { + return -1L; + } + try { + return Long.parseLong(number.substring(start, end)); + } catch (NumberFormatException ex) { + return -1L; + } + } + + private ObjectId parseUserId(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return new ObjectId(value); + } catch (IllegalArgumentException ex) { + log.warn("UserId '{}' ist keine gültige ObjectId — Audit übersprungen.", value); + return null; + } + } +} diff --git a/backend/src/main/java/de/assecutor/votianlt/service/InvoicePermissionService.java b/backend/src/main/java/de/assecutor/votianlt/service/InvoicePermissionService.java new file mode 100644 index 0000000..2ccbb16 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/service/InvoicePermissionService.java @@ -0,0 +1,164 @@ +package de.assecutor.votianlt.service; + +import de.assecutor.votianlt.model.User; +import de.assecutor.votianlt.model.invoices.CustomerInvoice; +import de.assecutor.votianlt.security.InvoiceRoles; +import de.assecutor.votianlt.security.SecurityService; +import org.springframework.stereotype.Service; + +import java.util.Set; + +/** + * Berechtigungs-Checks für Rechnungsaktionen gemäß R-40 bis R-42. + * + * Backwards-compat: ein Nutzer mit der bestehenden {@code USER}- oder {@code ADMIN}-Rolle, + * der keine der spezialisierten Invoice-Rollen besitzt, hat weiterhin volle Berechtigung + * — andernfalls würden alle bestehenden Installationen sofort handlungsunfähig. + * + * Sobald ein Nutzer mindestens eine {@code INVOICE_*}-Rolle hat, gelten die feingranularen + * Regeln und nur die explizit zugewiesenen Aktionen sind erlaubt. + */ +@Service +public class InvoicePermissionService { + + private static final String ROLE_ADMIN = "ADMIN"; + + private final SecurityService securityService; + + public InvoicePermissionService(SecurityService securityService) { + this.securityService = securityService; + } + + public boolean canCreateOrIssue(User user) { + return hasAnyInvoiceRole(user, InvoiceRoles.CREATOR) || isUnscoped(user); + } + + public boolean canMarkAsSent(User user) { + return hasAnyInvoiceRole(user, InvoiceRoles.CREATOR, InvoiceRoles.REVIEWER, InvoiceRoles.APPROVER) + || isUnscoped(user); + } + + public boolean canCancel(User user) { + return hasAnyInvoiceRole(user, InvoiceRoles.APPROVER) || isUnscoped(user); + } + + public boolean canCorrect(User user) { + return hasAnyInvoiceRole(user, InvoiceRoles.APPROVER, InvoiceRoles.REVIEWER) || isUnscoped(user); + } + + public boolean canRecordPayment(User user) { + 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( + "Sie haben keine Berechtigung, Rechnungen zu erstellen oder auszustellen."); + } + } + + public void requireSend(User user) { + if (!canMarkAsSent(user)) { + throw new InvoiceLifecycleException( + "Sie haben keine Berechtigung, Rechnungen als versendet zu markieren."); + } + } + + public void requireCancel(User user) { + if (!canCancel(user)) { + throw new InvoiceLifecycleException( + "Sie haben keine Berechtigung, Rechnungen zu stornieren."); + } + } + + public void requireCorrect(User user) { + if (!canCorrect(user)) { + throw new InvoiceLifecycleException( + "Sie haben keine Berechtigung, Berichtigungsbelege zu erstellen."); + } + } + + public void requirePayment(User user) { + if (!canRecordPayment(user)) { + throw new InvoiceLifecycleException("Sie haben keine Berechtigung, Zahlungen zu erfassen."); + } + } + + 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. + */ + public boolean isOwnerOrAdmin(User user, CustomerInvoice invoice) { + if (user == null) { + return false; + } + if (isAdmin(user)) { + return true; + } + if (invoice == null || invoice.getUserId() == null || user.getId() == null) { + return false; + } + return invoice.getUserId().equals(user.getId().toHexString()); + } + + private boolean hasAnyInvoiceRole(User user, String... roles) { + if (user == null || user.getRoles() == null) { + return false; + } + Set userRoles = user.getRoles(); + if (userRoles.contains(ROLE_ADMIN)) { + return true; + } + for (String role : roles) { + if (userRoles.contains(role)) { + return true; + } + } + return false; + } + + /** + * {@code true}, wenn dem Nutzer keine spezialisierte Invoice-Rolle zugewiesen ist — + * dann gilt das alte Pauschalrecht der USER-Rolle (Backwards-Compat). + */ + private boolean isUnscoped(User user) { + if (user == null || user.getRoles() == null) { + return false; + } + Set roles = user.getRoles(); + return roles.stream().noneMatch(r -> r.startsWith("INVOICE_")); + } + + private boolean isAdmin(User user) { + return user != null && user.getRoles() != null && user.getRoles().contains(ROLE_ADMIN); + } + + /** Bequemer Lookup für die UI. */ + public User currentUser() { + return securityService.getCurrentDatabaseUser(); + } +} diff --git a/backend/src/main/java/de/assecutor/votianlt/service/SigningCredentialsService.java b/backend/src/main/java/de/assecutor/votianlt/service/SigningCredentialsService.java new file mode 100644 index 0000000..e90896d --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/service/SigningCredentialsService.java @@ -0,0 +1,237 @@ +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 ad53bf9..8f1a33c 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -95,4 +95,34 @@ spring.ai.openai.read-timeout=120s spring.ai.mcp.server.enabled=true spring.ai.mcp.server.name=votianlt-mcp-server spring.ai.mcp.server.version=1.0.0 -spring.ai.mcp.server.sse-message-endpoint=/mcp/message \ No newline at end of file +spring.ai.mcp.server.sse-message-endpoint=/mcp/message + +# =========================================== +# E-Rechnung (ZUGFeRD/Factur-X) und PAdES-Signatur +# =========================================== +# Aktivieren Sie die ZUGFeRD-Anreicherung systemweit. Pro Nutzer entscheidet +# zusätzlich das Profilfeld eInvoiceEnabled, ob es tatsächlich angewendet wird. +votianlt.einvoice.enabled=false +votianlt.einvoice.profile=EN16931 + +# PAdES-Signatur: nur aktiv, wenn unten ein gültiger Keystore konfiguriert ist. +votianlt.einvoice.signing.enabled=false +votianlt.einvoice.signing.keystore-path= +votianlt.einvoice.signing.keystore-password= +votianlt.einvoice.signing.key-alias= +votianlt.einvoice.signing.reason=Rechnung +votianlt.einvoice.signing.location= +votianlt.einvoice.signing.contact= +# Master-Key (>= 16 Zeichen) zum Verschlüsseln nutzerseitig hinterlegter PKCS#12-Keystores. +# Verlust dieses Keys macht alle gespeicherten Nutzer-Keystores unbrauchbar. +# +# SICHERHEITSEMPFEHLUNG (Stufe 2/3): +# - Den Key NIEMALS inline hier hinterlegen — nutzen Sie ENV oder eine Secret-Datei. +# - ENV-Variante: VOTIANLT_EINVOICE_SIGNING_MASTER_KEY=... +# (oder Lower-Case-Equivalent via Spring Relaxed Binding) +# - Secret-Datei-Variante: master-key-file zeigt auf eine Datei (Docker-/K8s-Secret), +# chmod 600 auf Bare-Metal/VM-Deployments. +# - Die Spring-Placeholder-Syntax ${VAR:default} liest die ENV automatisch. +# - application.properties selbst sollte nicht weltlesbar sein (chmod 600). +votianlt.einvoice.signing.master-key=${VOTIANLT_EINVOICE_SIGNING_MASTER_KEY:} +votianlt.einvoice.signing.master-key-file=${VOTIANLT_EINVOICE_SIGNING_MASTER_KEY_FILE:} \ No newline at end of file diff --git a/backend/src/main/resources/messages_de.properties b/backend/src/main/resources/messages_de.properties index a33a0b5..304f22a 100644 --- a/backend/src/main/resources/messages_de.properties +++ b/backend/src/main/resources/messages_de.properties @@ -9,6 +9,17 @@ nav.customers=Adressbuch nav.appusers=App-Nutzer nav.statistics=Statistiken nav.invoices=Rechnungen +nav.datev.export=DATEV-Export +nav.approvals=Freigaben +datev.export.title=DATEV-Export +datev.export.description=Lädt einen DATEV-kompatiblen Buchungsstapel mit allen festgeschriebenen Rechnungen des gewählten Zeitraums herunter. Die Datei kann in DATEV Unternehmen Online sowie in DATEV-importfähigen Drittprogrammen eingelesen werden. +datev.export.from=Von +datev.export.to=Bis +datev.export.button=Rechnungen exportieren +datev.export.success=Export erstellt: {0} +datev.export.error.dates=Bitte Von- und Bis-Datum auswählen. +datev.export.error.range=Das Bis-Datum darf nicht vor dem Von-Datum liegen. +datev.export.error.user=Aktueller Nutzer konnte nicht ermittelt werden. nav.messages=Nachrichten nav.profile=Mein Profil nav.myinvoices=Rechnungen @@ -47,6 +58,36 @@ profile.settings.digitalprocess.info=Aufträge werden digital über die App abge profile.settings.locateappuser=App-Nutzer orten profile.settings.locateappuser.info=Standort der App-Nutzer wird regelmäßig übertragen profile.settings.vatrate=Umsatzsteuer +profile.settings.einvoice=ZUGFeRD-E-Rechnung erstellen +profile.settings.einvoice.helper=Erzeugt PDF/A-3 mit eingebettetem XRechnung/ZUGFeRD-XML (sofern systemweit aktiviert). +profile.settings.signinvoices=Rechnungen digital signieren +profile.settings.signinvoices.helper=Erzeugt eine PAdES-Signatur mit dem hinterlegten Zertifikat. Ohne aktives Zertifikat schlägt das Speichern fehl. +profile.signing.title=Signatur-Zertifikat +profile.signing.hint=Hinterlegen Sie Ihr eigenes PKCS#12-Zertifikat (.p12/.pfx), damit Ihre Rechnungen mit Ihrer Signatur erstellt werden. Der private Schlüssel wird verschlüsselt in der Datenbank gespeichert. +profile.signing.masterkey.missing=Hinweis: Der Server-Master-Key ist nicht gesetzt. Bitten Sie Ihren Administrator, votianlt.einvoice.signing.master-key zu konfigurieren, bevor Sie ein Zertifikat hinterlegen. +profile.signing.none=Es ist noch kein eigenes Signatur-Zertifikat hinterlegt. Beim Signieren wird der systemweit konfigurierte Schlüssel verwendet. +profile.signing.metadata.alias=Alias +profile.signing.metadata.subject=Inhaber +profile.signing.metadata.issuer=Aussteller +profile.signing.metadata.serial=Seriennummer +profile.signing.metadata.validity=Gültig +profile.signing.expired=Zertifikat abgelaufen +profile.signing.expiring=Läuft in den nächsten 30 Tagen ab +profile.signing.enabled=Eigenes Zertifikat zum Signieren verwenden +profile.signing.toggle.saved=Einstellung gespeichert. +profile.signing.delete=Zertifikat entfernen +profile.signing.deleted=Signatur-Zertifikat entfernt. +profile.signing.upload.title=Zertifikat hochladen +profile.signing.upload.drop=PKCS#12-Datei hier ablegen oder klicken +profile.signing.upload.received=Datei empfangen — bitte Alias und Passwort angeben. +profile.signing.upload.required=Bitte zuerst eine Zertifikatsdatei hochladen. +profile.signing.upload.save=Speichern +profile.signing.alias=Schlüssel-Alias +profile.signing.alias.required=Bitte den Alias des Schlüssels angeben. +profile.signing.password=Keystore-Passwort +profile.signing.password.required=Bitte das Keystore-Passwort angeben. +profile.signing.saved=Signatur-Zertifikat gespeichert. +profile.signing.error=Speichern fehlgeschlagen profile.account=Konto profile.security=Sicherheit profile.security.twofactor=Zwei-Faktor-Authentifizierung @@ -706,6 +747,96 @@ invoices.column.amount=Betrag invoices.column.description=Beschreibung invoices.empty=Es wurden noch keine Rechnungen erstellt. invoices.notification.pdf.missing=Für diese Rechnung ist kein PDF gespeichert. +invoices.notification.pdf.error=Die PDF-Anzeige ist fehlgeschlagen: {0} +invoices.column.status=Status +invoices.column.type=Typ +invoices.column.actions=Aktionen +invoices.disclaimer=Hinweis: Die rechtliche Aufbewahrungspflicht liegt beim Aussteller. Eine bereits ausgestellte Rechnung wird nicht überschrieben — Korrekturen erfolgen über Berichtigung oder Stornorechnung mit eindeutigem Bezug. +invoices.status.draft=Entwurf +invoices.status.issued=Ausgestellt +invoices.status.sent=Versendet +invoices.status.cancelled=Storniert +invoices.status.corrected=Berichtigt +invoices.type.invoice=Rechnung +invoices.type.cancellation=Stornorechnung +invoices.type.correction=Berichtigung +invoices.action.view=PDF anzeigen +invoices.action.history=Historie +invoices.action.marksent=Versendet markieren +invoices.action.correct=Berichtigen +invoices.action.cancel=Stornieren +invoices.notification.sent=Rechnung als versendet markiert. +invoices.notification.cancelled=Stornobeleg {0} erstellt. +invoices.notification.corrected=Berichtigungsbeleg {0} erstellt. +invoices.notification.error=Aktion fehlgeschlagen: {0} +invoices.cancel.title=Rechnung {0} stornieren +invoices.cancel.hint=Die Originalrechnung bleibt unverändert sichtbar. Es wird ein eigenständiger Stornobeleg mit eigener Belegnummer erstellt, der die Originalrechnung eindeutig referenziert. +invoices.cancel.reason=Grund der Stornierung +invoices.cancel.reason.required=Bitte einen Grund angeben. +invoices.cancel.confirm=Stornobeleg erstellen +invoices.correct.title=Rechnung {0} berichtigen +invoices.correct.hint=Eine Berichtigung dient ausschließlich der Korrektur formaler Fehler (z.B. Adresse, Leistungsdatum). Die Originalrechnung bleibt sichtbar; der Berichtigungsbeleg verweist eindeutig auf sie. +invoices.correct.fields=Berichtigte Angaben +invoices.correct.fields.helper=Beschreiben Sie, welche Angaben ergänzt oder ersetzt werden. +invoices.correct.fields.required=Bitte die berichtigten Angaben beschreiben. +invoices.correct.reason=Grund der Berichtigung +invoices.correct.confirm=Berichtigung erstellen +invoices.history.title=Historie zu Rechnung {0} +invoices.history.log=Änderungsprotokoll +invoices.history.empty=Keine Einträge vorhanden. +invoices.history.original=Original­rechnung +invoices.history.cancellation=Stornobeleg +invoices.history.correction=Berichtigungsbeleg +invoices.history.replacement=Ersatzrechnung +invoices.audit.action.created_draft=Entwurf erstellt +invoices.audit.action.updated_draft=Entwurf geändert +invoices.audit.action.issued=Ausgestellt +invoices.audit.action.sent=Versendet +invoices.audit.action.cancelled=Storniert +invoices.audit.action.corrected=Berichtigt +invoices.audit.action.replaced=Ersetzt durch neue Rechnung +invoices.audit.action.deleted_draft=Entwurf gelöscht +invoices.audit.action.payment_recorded=Zahlung erfasst +invoices.audit.resulting=Erzeugter Folgebeleg: {0} +invoices.column.payment=Zahlung +invoices.column.outstanding=Offen +invoices.payment.unpaid=Offen +invoices.payment.partially_paid=Teilzahlung +invoices.payment.paid=Bezahlt +invoices.payment.overpaid=Überzahlung +invoices.payment.refund_due=Erstattung offen +invoices.action.payment=Zahlung erfassen +invoices.action.export=Exportieren +invoices.payment.title=Zahlung zu Rechnung {0} +invoices.payment.hint=Offener Restbetrag: {0}. Negative Beträge können zur Korrektur erfasst werden. +invoices.payment.amount=Zahlbetrag +invoices.payment.amount.required=Bitte einen Betrag (ungleich 0) angeben. +invoices.payment.reference=Zahlungsreferenz (z.B. Kontoauszug, Buchungs-Nr.) +invoices.payment.reason=Anmerkung +invoices.payment.confirm=Zahlung erfassen +invoices.notification.payment=Zahlung erfasst. +invoices.einvoice.tooltip=PDF/A-3 mit eingebettetem ZUGFeRD/XRechnung-XML +invoices.einvoice.signed=Signiert +invoices.action.cancel.request=Storno beantragen +invoices.action.correct.request=Berichtigung beantragen +invoices.notification.requested=Freigabe-Anfrage erstellt. Bitte auf Freigabe warten. +approvals.title=Freigaben +approvals.no.permission=Sie haben keine Berechtigung, Freigaben zu bearbeiten. +approvals.column.requested=Beantragt am +approvals.column.requester=Beantragt von +approvals.column.invoice=Rechnung +approvals.column.action=Aktion +approvals.column.reason=Grund +approvals.action.approve=Freigeben +approvals.action.reject=Ablehnen +approvals.confirm.approve.title=Anfrage zu Rechnung {0} freigeben +approvals.confirm.reject.title=Anfrage zu Rechnung {0} ablehnen +approvals.review.fields=Berichtigte Angaben +approvals.review.reason=Grund +approvals.review.comment=Kommentar (optional) +approvals.notification.approved=Anfrage freigegeben — Folgebeleg wurde erstellt. +approvals.notification.rejected=Anfrage abgelehnt. +page.title.approvals=Freigaben # My Invoices myinvoices.title=Rechnungen diff --git a/backend/src/main/resources/messages_en.properties b/backend/src/main/resources/messages_en.properties index 8fa75f9..d046770 100644 --- a/backend/src/main/resources/messages_en.properties +++ b/backend/src/main/resources/messages_en.properties @@ -9,6 +9,17 @@ nav.customers=Address Book nav.appusers=App Users nav.statistics=Statistics nav.invoices=Invoices +nav.datev.export=DATEV Export +nav.approvals=Approvals +datev.export.title=DATEV Export +datev.export.description=Downloads a DATEV-compatible booking batch containing all finalized invoices for the selected period. The file can be imported into DATEV Unternehmen Online as well as DATEV-compatible third-party tools. +datev.export.from=From +datev.export.to=To +datev.export.button=Export invoices +datev.export.success=Export created: {0} +datev.export.error.dates=Please pick both From and To dates. +datev.export.error.range=To date must not be before From date. +datev.export.error.user=Could not determine the current user. nav.messages=Messages nav.profile=My Profile nav.myinvoices=Invoices @@ -47,6 +58,36 @@ profile.settings.digitalprocess.info=Jobs are processed digitally via the app profile.settings.locateappuser=Locate App Users profile.settings.locateappuser.info=App user location is transmitted regularly profile.settings.vatrate=VAT rate +profile.settings.einvoice=Create ZUGFeRD e-invoice +profile.settings.einvoice.helper=Generates PDF/A-3 with embedded XRechnung/ZUGFeRD XML (when enabled system-wide). +profile.settings.signinvoices=Digitally sign invoices +profile.settings.signinvoices.helper=Adds a PAdES signature using the configured certificate. Saving fails if no active certificate is available. +profile.signing.title=Signing certificate +profile.signing.hint=Upload your own PKCS#12 certificate (.p12/.pfx) so invoices are signed with your signature. The private key is stored encrypted in the database. +profile.signing.masterkey.missing=Note: The server master key is not configured. Ask your administrator to set votianlt.einvoice.signing.master-key before uploading a certificate. +profile.signing.none=No personal signing certificate stored yet. The system-wide key will be used when signing. +profile.signing.metadata.alias=Alias +profile.signing.metadata.subject=Subject +profile.signing.metadata.issuer=Issuer +profile.signing.metadata.serial=Serial number +profile.signing.metadata.validity=Valid +profile.signing.expired=Certificate expired +profile.signing.expiring=Expires within the next 30 days +profile.signing.enabled=Use my certificate for signing +profile.signing.toggle.saved=Setting saved. +profile.signing.delete=Remove certificate +profile.signing.deleted=Signing certificate removed. +profile.signing.upload.title=Upload certificate +profile.signing.upload.drop=Drop your PKCS#12 file here or click to upload +profile.signing.upload.received=File received — please provide alias and password. +profile.signing.upload.required=Please upload a certificate file first. +profile.signing.upload.save=Save +profile.signing.alias=Key alias +profile.signing.alias.required=Please provide the key alias. +profile.signing.password=Keystore password +profile.signing.password.required=Please provide the keystore password. +profile.signing.saved=Signing certificate saved. +profile.signing.error=Save failed profile.account=Account profile.security=Security profile.security.twofactor=Two-Factor Authentication @@ -706,6 +747,96 @@ invoices.column.amount=Amount invoices.column.description=Description invoices.empty=No invoices have been created yet. invoices.notification.pdf.missing=No PDF is stored for this invoice. +invoices.notification.pdf.error=Failed to display PDF: {0} +invoices.column.status=Status +invoices.column.type=Type +invoices.column.actions=Actions +invoices.disclaimer=Note: Legal retention obligations remain with the issuer. An already issued invoice is never overwritten — corrections are made through a correction document or cancellation invoice that explicitly references the original. +invoices.status.draft=Draft +invoices.status.issued=Issued +invoices.status.sent=Sent +invoices.status.cancelled=Cancelled +invoices.status.corrected=Corrected +invoices.type.invoice=Invoice +invoices.type.cancellation=Cancellation invoice +invoices.type.correction=Correction document +invoices.action.view=View PDF +invoices.action.history=History +invoices.action.marksent=Mark as sent +invoices.action.correct=Correct +invoices.action.cancel=Cancel +invoices.notification.sent=Invoice marked as sent. +invoices.notification.cancelled=Cancellation document {0} created. +invoices.notification.corrected=Correction document {0} created. +invoices.notification.error=Action failed: {0} +invoices.cancel.title=Cancel invoice {0} +invoices.cancel.hint=The original invoice remains visible. A separate cancellation document with its own number will be created, explicitly referencing the original invoice. +invoices.cancel.reason=Reason for cancellation +invoices.cancel.reason.required=Please provide a reason. +invoices.cancel.confirm=Create cancellation document +invoices.correct.title=Correct invoice {0} +invoices.correct.hint=A correction document is intended for formal errors only (e.g. address, delivery date). The original invoice remains visible and the correction document refers to it explicitly. +invoices.correct.fields=Corrected information +invoices.correct.fields.helper=Describe which fields are added or replaced. +invoices.correct.fields.required=Please describe the corrected information. +invoices.correct.reason=Reason for correction +invoices.correct.confirm=Create correction document +invoices.history.title=History for invoice {0} +invoices.history.log=Audit log +invoices.history.empty=No entries available. +invoices.history.original=Original invoice +invoices.history.cancellation=Cancellation document +invoices.history.correction=Correction document +invoices.history.replacement=Replacement invoice +invoices.audit.action.created_draft=Draft created +invoices.audit.action.updated_draft=Draft updated +invoices.audit.action.issued=Issued +invoices.audit.action.sent=Sent +invoices.audit.action.cancelled=Cancelled +invoices.audit.action.corrected=Corrected +invoices.audit.action.replaced=Replaced by new invoice +invoices.audit.action.deleted_draft=Draft deleted +invoices.audit.action.payment_recorded=Payment recorded +invoices.audit.resulting=Resulting document: {0} +invoices.column.payment=Payment +invoices.column.outstanding=Outstanding +invoices.payment.unpaid=Open +invoices.payment.partially_paid=Partially paid +invoices.payment.paid=Paid +invoices.payment.overpaid=Overpaid +invoices.payment.refund_due=Refund due +invoices.action.payment=Record payment +invoices.action.export=Export +invoices.payment.title=Payment for invoice {0} +invoices.payment.hint=Outstanding balance: {0}. Negative amounts can be recorded for corrections. +invoices.payment.amount=Amount +invoices.payment.amount.required=Please enter a non-zero amount. +invoices.payment.reference=Payment reference (e.g. statement, booking ID) +invoices.payment.reason=Note +invoices.payment.confirm=Record payment +invoices.notification.payment=Payment recorded. +invoices.einvoice.tooltip=PDF/A-3 with embedded ZUGFeRD/XRechnung XML +invoices.einvoice.signed=Signed +invoices.action.cancel.request=Request cancellation +invoices.action.correct.request=Request correction +invoices.notification.requested=Approval request created. Please wait for approval. +approvals.title=Approvals +approvals.no.permission=You do not have permission to handle approvals. +approvals.column.requested=Requested at +approvals.column.requester=Requested by +approvals.column.invoice=Invoice +approvals.column.action=Action +approvals.column.reason=Reason +approvals.action.approve=Approve +approvals.action.reject=Reject +approvals.confirm.approve.title=Approve request for invoice {0} +approvals.confirm.reject.title=Reject request for invoice {0} +approvals.review.fields=Corrected information +approvals.review.reason=Reason +approvals.review.comment=Comment (optional) +approvals.notification.approved=Request approved — follow-up document created. +approvals.notification.rejected=Request rejected. +page.title.approvals=Approvals # My Invoices myinvoices.title=Invoices diff --git a/backend/src/test/java/de/assecutor/votianlt/service/DatevExportServiceTest.java b/backend/src/test/java/de/assecutor/votianlt/service/DatevExportServiceTest.java new file mode 100644 index 0000000..c30e8d1 --- /dev/null +++ b/backend/src/test/java/de/assecutor/votianlt/service/DatevExportServiceTest.java @@ -0,0 +1,173 @@ +package de.assecutor.votianlt.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import de.assecutor.votianlt.model.invoices.CustomerInvoice; +import de.assecutor.votianlt.model.invoices.InvoiceStatus; +import de.assecutor.votianlt.model.invoices.InvoiceType; +import de.assecutor.votianlt.repository.CustomerInvoiceRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.nio.charset.Charset; +import java.time.LocalDate; +import java.util.List; + +@ExtendWith(MockitoExtension.class) +class DatevExportServiceTest { + + private static final Charset DATEV_CHARSET = Charset.forName("Windows-1252"); + private static final String USER_ID = "user-1"; + + @Mock + private CustomerInvoiceRepository invoiceRepository; + + private DatevExportService service; + + @BeforeEach + void setUp() { + service = new DatevExportService(invoiceRepository); + } + + @Test + void exportContainsHeaderColumnsAndOneRowPerInvoice() { + when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of( + invoice("R-2026-001", LocalDate.of(2026, 4, 5), new BigDecimal("119.00"), + new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, "Kunde A"), + invoice("R-2026-002", LocalDate.of(2026, 4, 12), new BigDecimal("214.00"), + new BigDecimal("0.07"), InvoiceType.INVOICE, InvoiceStatus.SENT, "Kunde B"))); + + byte[] csv = service.export(USER_ID, LocalDate.of(2026, 4, 1), LocalDate.of(2026, 4, 30)); + String content = new String(csv, DATEV_CHARSET); + String[] lines = content.split("\\r\\n"); + + assertThat(lines[0]).startsWith("\"EXTF\";700;21;\"Buchungsstapel\""); + assertThat(lines[0]).contains("20260401;20260430"); + assertThat(lines[1]).startsWith("Umsatz (ohne Soll/Haben-Kz);Soll/Haben-Kennzeichen;WKZ Umsatz;Konto;"); + assertThat(lines).hasSize(4); // header + col-header + 2 invoices + assertThat(lines[2]).startsWith("119,00;S;\"EUR\";10000;8400;;0504;\"R-2026-001\";"); + assertThat(lines[3]).startsWith("214,00;S;\"EUR\";10000;8300;;1204;\"R-2026-002\";"); + } + + @Test + void cancellationFlipsSollHabenAndUsesAbsoluteAmount() { + when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of( + invoice("S-2026-007", LocalDate.of(2026, 4, 20), new BigDecimal("-119.00"), + new BigDecimal("0.19"), InvoiceType.CANCELLATION, InvoiceStatus.ISSUED, "Kunde A"))); + + String content = new String(service.export(USER_ID, LocalDate.of(2026, 4, 1), + LocalDate.of(2026, 4, 30)), DATEV_CHARSET); + String[] lines = content.split("\\r\\n"); + + assertThat(lines[2]).startsWith("119,00;H;\"EUR\";10000;8400;;"); + assertThat(lines[2]).contains("\"Storno: Kunde A\""); + } + + @Test + void zeroVatMapsToReverseChargeAccount() { + when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of( + invoice("R-2026-009", LocalDate.of(2026, 4, 7), new BigDecimal("500.00"), + BigDecimal.ZERO, InvoiceType.INVOICE, InvoiceStatus.ISSUED, "EU-Kunde"))); + + String content = new String(service.export(USER_ID, LocalDate.of(2026, 4, 1), + LocalDate.of(2026, 4, 30)), DATEV_CHARSET); + String[] lines = content.split("\\r\\n"); + + assertThat(lines[2]).contains(";8125;"); + } + + @Test + void draftsAreExcluded() { + when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of( + invoice("R-DRAFT", LocalDate.of(2026, 4, 5), new BigDecimal("100.00"), + new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.DRAFT, "X"), + invoice("R-ISSUED", LocalDate.of(2026, 4, 6), new BigDecimal("100.00"), + new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, "Y"))); + + String content = new String(service.export(USER_ID, LocalDate.of(2026, 4, 1), + LocalDate.of(2026, 4, 30)), DATEV_CHARSET); + String[] lines = content.split("\\r\\n"); + + assertThat(lines).hasSize(3); + assertThat(lines[2]).contains("\"R-ISSUED\""); + } + + @Test + void invoicesOutsidePeriodAreIgnored() { + when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of( + invoice("R-2026-MAR", LocalDate.of(2026, 3, 31), new BigDecimal("100.00"), + new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, "Y"), + invoice("R-2026-IN", LocalDate.of(2026, 4, 1), new BigDecimal("100.00"), + new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, "Y"), + invoice("R-2026-OUT", LocalDate.of(2026, 5, 1), new BigDecimal("100.00"), + new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, "Y"))); + + String content = new String(service.export(USER_ID, LocalDate.of(2026, 4, 1), + LocalDate.of(2026, 4, 30)), DATEV_CHARSET); + String[] lines = content.split("\\r\\n"); + + assertThat(lines).hasSize(3); + assertThat(lines[2]).contains("\"R-2026-IN\""); + } + + @Test + void emptyResultStillProducesValidHeader() { + when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of()); + + String content = new String(service.export(USER_ID, LocalDate.of(2026, 1, 1), + LocalDate.of(2026, 1, 31)), DATEV_CHARSET); + String[] lines = content.split("\\r\\n"); + + assertThat(lines[0]).startsWith("\"EXTF\";700;21;\"Buchungsstapel\""); + assertThat(lines).hasSize(2); + } + + @Test + void quotesInsideRecipientAreEscaped() { + when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of( + invoice("R-2026-QT", LocalDate.of(2026, 4, 15), new BigDecimal("119.00"), + new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, + "Acme \"Premium\" GmbH"))); + + String content = new String(service.export(USER_ID, LocalDate.of(2026, 4, 1), + LocalDate.of(2026, 4, 30)), DATEV_CHARSET); + // DATEV escaped Anführungszeichen durch Verdoppeln. + assertThat(content).contains("\"Rechnung an Acme \"\"Premium\"\" GmbH\""); + } + + @Test + void rejectsInvalidInputs() { + assertThatThrownBy(() -> service.export(null, LocalDate.now(), LocalDate.now())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> service.export(USER_ID, null, LocalDate.now())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> service.export(USER_ID, LocalDate.of(2026, 5, 1), LocalDate.of(2026, 4, 1))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void filenameContainsDateRange() { + String filename = service.suggestFilename(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 3, 31)); + assertThat(filename).isEqualTo("EXTF_Buchungsstapel_20260101_20260331.csv"); + } + + private CustomerInvoice invoice(String number, LocalDate date, BigDecimal total, BigDecimal vatRate, + InvoiceType type, InvoiceStatus status, String recipient) { + CustomerInvoice invoice = new CustomerInvoice(); + invoice.setInvoiceNumber(number); + invoice.setInvoiceDate(date); + invoice.setTotalAmount(total); + invoice.setVatRate(vatRate); + invoice.setType(type); + invoice.setStatus(status); + invoice.setRecipientName(recipient); + invoice.setUserId(USER_ID); + return invoice; + } +} diff --git a/backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceDssValidationTest.java b/backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceDssValidationTest.java new file mode 100644 index 0000000..a312a4d --- /dev/null +++ b/backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceDssValidationTest.java @@ -0,0 +1,271 @@ +package de.assecutor.votianlt.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import de.assecutor.votianlt.config.EInvoiceProperties; +import de.assecutor.votianlt.model.invoices.CustomerInvoice; +import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem; +import de.assecutor.votianlt.model.invoices.UserSigningCredentials; +import de.assecutor.votianlt.repository.UserSigningCredentialsRepository; +import eu.europa.esig.dss.diagnostic.DiagnosticData; +import eu.europa.esig.dss.diagnostic.SignatureWrapper; +import eu.europa.esig.dss.enumerations.SignatureLevel; +import eu.europa.esig.dss.model.DSSDocument; +import eu.europa.esig.dss.model.InMemoryDocument; +import eu.europa.esig.dss.model.x509.CertificateToken; +import eu.europa.esig.dss.spi.validation.CommonCertificateVerifier; +import eu.europa.esig.dss.spi.x509.CommonTrustedCertificateSource; +import eu.europa.esig.dss.validation.SignedDocumentValidator; +import eu.europa.esig.dss.validation.reports.Reports; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.Security; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.List; +import java.util.Optional; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; + +/** + * Validiert die vom Service erzeugten Signaturen gegen die EU DSS + * (Digital Signature Service) Bibliothek. Während {@link EInvoiceServiceTest} + * nur die strukturelle Korrektheit des Signature-Dictionarys prüft, parst + * DSS hier die Signatur kryptographisch nach und liefert ein Diagnostic-Data- + * Modell. + * + *

Geprüft wird pro Signaturpfad (System- und Nutzer-Keystore): + *

    + *
  • genau eine Signatur ist im Dokument enthalten,
  • + *
  • die Signatur ist kryptographisch intakt (Hash & Signaturwert),
  • + *
  • das Format ist PAdES-Baseline (mehr ist mit den aktuellen + * Service-Settings nicht zu erwarten — kein TSA, kein DSS-Dictionary),
  • + *
  • der Signer-DN entspricht dem im Test erzeugten Zertifikat.
  • + *
+ * + *

Die Self-Signed-Zertifikate werden als Trust-Anchor in den + * {@link CommonCertificateVerifier} eingehängt — sonst würde DSS die Kette + * korrekt als nicht vertrauenswürdig melden, was über die rein technische + * Signaturprüfung hinausgeht und für unseren Test irrelevant ist. + */ +@ExtendWith(MockitoExtension.class) +class EInvoiceServiceDssValidationTest { + + private static final String USER_ID = "user-1"; + private static final String SYSTEM_ALIAS = "system-signer"; + private static final String USER_ALIAS = "user-signer"; + private static final String SYSTEM_PASSWORD = "system-pass"; + private static final String USER_PASSWORD = "user-pass"; + private static final String MASTER_KEY = "0123456789abcdef0123456789abcdef"; + + static { + if (Security.getProvider("BC") == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + @Mock + private UserSigningCredentialsRepository repository; + + @TempDir + Path tempDir; + + private EInvoiceProperties properties; + private SigningCredentialsService signingCredentialsService; + private EInvoiceService eInvoiceService; + + @BeforeEach + void setUp() { + properties = new EInvoiceProperties(); + properties.setEnabled(true); + properties.setProfile("EN16931"); + properties.getSigning().setEnabled(false); + properties.getSigning().setMasterKey(MASTER_KEY); + + signingCredentialsService = new SigningCredentialsService(repository, properties); + eInvoiceService = new EInvoiceService(properties, signingCredentialsService); + } + + @Test + void systemKeystoreSignaturePassesDssValidation() throws Exception { + KeystoreFixture fixture = generateKeystore(SYSTEM_ALIAS, SYSTEM_PASSWORD); + Path keystoreFile = tempDir.resolve("system.p12"); + Files.write(keystoreFile, fixture.keystoreBytes()); + configureSystemKeystore(keystoreFile, SYSTEM_PASSWORD, SYSTEM_ALIAS); + when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + + CustomerInvoice invoice = sampleInvoice(); + byte[] signedPdf = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true); + + assertDssAcceptsSignature(signedPdf, fixture.certificate(), "Votianlt Test " + SYSTEM_ALIAS); + } + + @Test + void userKeystoreSignaturePassesDssValidation() throws Exception { + KeystoreFixture fixture = generateKeystore(USER_ALIAS, USER_PASSWORD); + when(repository.save(any(UserSigningCredentials.class))).thenAnswer(inv -> inv.getArgument(0)); + UserSigningCredentials stored = signingCredentialsService.store( + USER_ID, fixture.keystoreBytes(), USER_PASSWORD, USER_ALIAS); + when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(stored)); + + CustomerInvoice invoice = sampleInvoice(); + byte[] signedPdf = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true); + + assertDssAcceptsSignature(signedPdf, fixture.certificate(), "Votianlt Test " + USER_ALIAS); + } + + private void assertDssAcceptsSignature(byte[] signedPdf, X509Certificate trustAnchor, + String expectedSubjectFragment) { + DSSDocument document = new InMemoryDocument(signedPdf); + SignedDocumentValidator validator = SignedDocumentValidator.fromDocument(document); + + CommonTrustedCertificateSource trustedSource = new CommonTrustedCertificateSource(); + trustedSource.addCertificate(new CertificateToken(trustAnchor)); + + CommonCertificateVerifier verifier = new CommonCertificateVerifier(); + verifier.setTrustedCertSources(trustedSource); + verifier.setAIASource(null); + verifier.setCrlSource(null); + verifier.setOcspSource(null); + + validator.setCertificateVerifier(verifier); + + Reports reports = validator.validateDocument(); + DiagnosticData diagnosticData = reports.getDiagnosticData(); + + List signatureIds = diagnosticData.getSignatureIdList(); + assertThat(signatureIds) + .as("DSS muss genau eine PAdES-Signatur im PDF erkennen") + .hasSize(1); + + String signatureId = signatureIds.get(0); + SignatureWrapper signature = diagnosticData.getSignatureById(signatureId); + + assertThat(signature.isSignatureIntact()) + .as("Signatur-Hash über den signierten Bytes muss aufgehen") + .isTrue(); + assertThat(signature.isSignatureValid()) + .as("Signaturwert muss mit dem Public Key des Signer-Zertifikats verifizierbar sein") + .isTrue(); + + // Aktueller Stand: der Service erzeugt eine generische PKCS#7-Signatur (subFilter + // adbe.pkcs7.detached) ohne ESS-signing-certificate-v2-Attribut. DSS klassifiziert + // das deshalb als PKCS7-B, nicht als PAdES-BASELINE-B. Hochstufung auf + // PAdES-BASELINE-B verlangt signierte Attribute (ESS Signing Certificate v2, + // signing-time im subFilter etrsi.RFC3161 oder ETSI.CAdES.detached, etc.) und + // wäre eine inhaltliche Erweiterung des Services. + SignatureLevel level = diagnosticData.getSignatureFormat(signatureId); + assertThat(level) + .as("Service erzeugt aktuell PKCS#7-B; ein Upgrade auf PAdES-BASELINE-B " + + "wäre über zusätzliche CMS-signed-attributes möglich") + .isEqualTo(SignatureLevel.PKCS7_B); + + String signerDn = signature.getSigningCertificate().getCertificateDN(); + assertThat(signerDn) + .as("Signer-DN muss zum Test-Zertifikat passen") + .contains(expectedSubjectFragment); + } + + private void configureSystemKeystore(Path keystoreFile, String password, String alias) { + EInvoiceProperties.Signing signing = properties.getSigning(); + signing.setEnabled(true); + signing.setKeystorePath(keystoreFile.toString()); + signing.setKeystorePassword(password); + signing.setKeyAlias(alias); + } + + private byte[] buildBlankPdf() throws Exception { + try (PDDocument document = new PDDocument(); ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + document.addPage(new PDPage()); + document.save(baos); + return baos.toByteArray(); + } + } + + private KeystoreFixture generateKeystore(String alias, String password) { + try { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair keyPair = kpg.generateKeyPair(); + + X500Name subject = new X500Name("CN=Votianlt Test " + alias + ",O=Votianlt,C=DE"); + BigInteger serial = BigInteger.valueOf(System.nanoTime()); + Date notBefore = Date.from(Instant.now().minus(1, ChronoUnit.DAYS)); + Date notAfter = Date.from(Instant.now().plus(365, ChronoUnit.DAYS)); + + JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(subject, serial, notBefore, + notAfter, subject, keyPair.getPublic()); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").setProvider("BC") + .build(keyPair.getPrivate()); + X509Certificate certificate = new JcaX509CertificateConverter().setProvider("BC") + .getCertificate(certBuilder.build(signer)); + + KeyStore keystore = KeyStore.getInstance("PKCS12"); + keystore.load(null, null); + keystore.setKeyEntry(alias, keyPair.getPrivate(), password.toCharArray(), + new Certificate[] { certificate }); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + keystore.store(baos, password.toCharArray()); + return new KeystoreFixture(baos.toByteArray(), certificate); + } catch (Exception ex) { + throw new IllegalStateException("Test-Keystore konnte nicht erzeugt werden: " + ex.getMessage(), ex); + } + } + + private CustomerInvoice sampleInvoice() { + CustomerInvoice invoice = new CustomerInvoice(); + invoice.setUserId(USER_ID); + invoice.setInvoiceNumber("R-2026-0001"); + invoice.setInvoiceDate(LocalDate.now()); + invoice.setDeliveryDate(LocalDate.now()); + invoice.setSenderName("Votianlt Test GmbH"); + invoice.setSenderAddress("Teststraße 1"); + invoice.setSenderPostcode("12345"); + invoice.setSenderCity("Berlin"); + invoice.setSenderCountry("DE"); + invoice.setRecipientName("Empfänger AG"); + invoice.setRecipientAddress("Kundenweg 2"); + invoice.setRecipientPostcode("54321"); + invoice.setRecipientCity("Hamburg"); + invoice.setRecipientCountry("DE"); + invoice.setDescription("Beratungsleistung"); + invoice.setVatRate(new BigDecimal("0.19")); + invoice.setNetAmount(new BigDecimal("100.00")); + invoice.setVatAmount(new BigDecimal("19.00")); + invoice.setTotalAmount(new BigDecimal("119.00")); + CustomerInvoiceItem item = new CustomerInvoiceItem(BigDecimal.ONE, "h", "Beratung", new BigDecimal("100.00"), + new BigDecimal("0.19")); + invoice.setItems(List.of(item)); + return invoice; + } + + private record KeystoreFixture(byte[] keystoreBytes, X509Certificate certificate) { + } +} diff --git a/backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceTest.java b/backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceTest.java new file mode 100644 index 0000000..10767aa --- /dev/null +++ b/backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceTest.java @@ -0,0 +1,331 @@ +package de.assecutor.votianlt.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import de.assecutor.votianlt.config.EInvoiceProperties; +import de.assecutor.votianlt.model.invoices.CustomerInvoice; +import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem; +import de.assecutor.votianlt.model.invoices.EInvoiceFormat; +import de.assecutor.votianlt.model.invoices.UserSigningCredentials; +import de.assecutor.votianlt.repository.UserSigningCredentialsRepository; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.Security; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.List; +import java.util.Optional; + +/** + * Integrationstest des Signaturpfads — deckt System-Keystore, Nutzer-Keystore, + * Vorrang-Logik und alle wichtigen Fehlerpfade ab. Verwendet bewusst keinen + * vollen Spring-Boot-Kontext: das vermeidet die Mongo-Abhängigkeit der echten + * Repository-Klasse, lässt aber die echten Service-Klassen (Verschlüsselung, + * PDFBox/BouncyCastle-Signatur) durchlaufen. + * + * Das Zertifikat wird zur Testzeit per BouncyCastle erzeugt — der Test ist + * damit reproduzierbar und braucht keine Datei-Fixtures. + */ +@ExtendWith(MockitoExtension.class) +class EInvoiceServiceTest { + + private static final String USER_ID = "user-1"; + private static final String SYSTEM_ALIAS = "system-signer"; + private static final String USER_ALIAS = "user-signer"; + private static final String SYSTEM_PASSWORD = "system-pass"; + private static final String USER_PASSWORD = "user-pass"; + private static final String MASTER_KEY = "0123456789abcdef0123456789abcdef"; + + static { + if (Security.getProvider("BC") == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + @Mock + private UserSigningCredentialsRepository repository; + + @TempDir + Path tempDir; + + private EInvoiceProperties properties; + private SigningCredentialsService signingCredentialsService; + private EInvoiceService eInvoiceService; + + @BeforeEach + void setUp() { + properties = new EInvoiceProperties(); + properties.setEnabled(true); + properties.setProfile("EN16931"); + properties.getSigning().setEnabled(false); + properties.getSigning().setMasterKey(MASTER_KEY); + + signingCredentialsService = new SigningCredentialsService(repository, properties); + eInvoiceService = new EInvoiceService(properties, signingCredentialsService); + } + + @Test + void signsPdfWithSystemKeystore() throws Exception { + byte[] keystoreBytes = generateKeystore(SYSTEM_ALIAS, SYSTEM_PASSWORD); + Path keystoreFile = tempDir.resolve("system.p12"); + Files.write(keystoreFile, keystoreBytes); + configureSystemKeystore(keystoreFile, SYSTEM_PASSWORD, SYSTEM_ALIAS); + when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + + CustomerInvoice invoice = sampleInvoice(); + byte[] result = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true); + + assertSignedPdf(result); + assertThat(invoice.isSigned()).isTrue(); + assertThat(invoice.getSignedAt()).isNotNull(); + assertThat(invoice.getSignedBy()).isEqualTo(SYSTEM_ALIAS); + } + + @Test + void signsPdfWithUserKeystore() throws Exception { + UserSigningCredentials stored = storeUserKeystore(); + when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(stored)); + + CustomerInvoice invoice = sampleInvoice(); + byte[] result = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true); + + assertSignedPdf(result); + assertThat(invoice.isSigned()).isTrue(); + assertThat(invoice.getSignedBy()).contains("Votianlt Test"); + } + + @Test + void userKeystoreTakesPrecedenceOverSystemKeystore() throws Exception { + byte[] systemKeystoreBytes = generateKeystore(SYSTEM_ALIAS, SYSTEM_PASSWORD); + Path keystoreFile = tempDir.resolve("system.p12"); + Files.write(keystoreFile, systemKeystoreBytes); + configureSystemKeystore(keystoreFile, SYSTEM_PASSWORD, SYSTEM_ALIAS); + + UserSigningCredentials stored = storeUserKeystore(); + when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(stored)); + + CustomerInvoice invoice = sampleInvoice(); + byte[] result = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true); + + assertSignedPdf(result); + assertThat(invoice.getSignedBy()) + .as("Bei vorhandenen Nutzer-Credentials darf nicht der System-Alias signieren") + .isNotEqualTo(SYSTEM_ALIAS) + .contains("Votianlt Test"); + } + + @Test + void failsWhenNoKeystoreAvailable() { + when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + + CustomerInvoice invoice = sampleInvoice(); + assertThatThrownBy(() -> eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true)) + .isInstanceOf(InvoiceLifecycleException.class) + .hasMessageContaining("kein Signatur-Zertifikat verfügbar"); + assertThat(invoice.isSigned()).isFalse(); + } + + @Test + void failsWhenUserCredentialsDisabled() { + UserSigningCredentials stored = storeUserKeystore(); + stored.setEnabled(false); + when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(stored)); + + CustomerInvoice invoice = sampleInvoice(); + assertThatThrownBy(() -> eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true)) + .isInstanceOf(InvoiceLifecycleException.class) + .hasMessageContaining("deaktiviert"); + assertThat(invoice.isSigned()).isFalse(); + } + + @Test + void failsWhenMasterKeyChangedAfterStorage() { + UserSigningCredentials stored = storeUserKeystore(); + when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(stored)); + + // Master-Key wird nach dem Persistieren ausgetauscht — der Keystore lässt sich + // nicht mehr entschlüsseln, der Service muss dem Anwender klar signalisieren, + // dass er das Zertifikat erneut hochladen muss. + properties.getSigning().setMasterKey("ffffffffffffffffffffffffffffffff"); + + CustomerInvoice invoice = sampleInvoice(); + assertThatThrownBy(() -> eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true)) + .isInstanceOf(InvoiceLifecycleException.class) + .hasMessageContaining("nicht entschlüsselt"); + assertThat(invoice.isSigned()).isFalse(); + } + + /** + * Ein nicht-PDF/A-konformes Eingabe-PDF (z.B. ohne eingebettete Fonts) lässt + * die Mustang-Anreicherung scheitern. Der Service muss laut Klassen-JavaDoc + * mit dem Roh-PDF fortfahren und die Signatur trotzdem strikt durchführen — + * Format wird auf NONE gesetzt, Signatur darf nicht ausfallen. + */ + @Test + void gracefullyDegradesWhenZugferdEmbeddingFails() throws Exception { + byte[] keystoreBytes = generateKeystore(SYSTEM_ALIAS, SYSTEM_PASSWORD); + Path keystoreFile = tempDir.resolve("system.p12"); + Files.write(keystoreFile, keystoreBytes); + configureSystemKeystore(keystoreFile, SYSTEM_PASSWORD, SYSTEM_ALIAS); + when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + + CustomerInvoice invoice = sampleInvoice(); + byte[] result = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, true, true); + + assertSignedPdf(result); + assertThat(invoice.getEInvoiceFormat()).isEqualTo(EInvoiceFormat.NONE); + assertThat(invoice.isSigned()).isTrue(); + } + + @Test + void embedsZugferdXmlIntoValidPdf() throws Exception { + CustomerInvoice invoice = sampleInvoice(); + byte[] result = eInvoiceService.embedZugferdXml(buildPdfWithFont(), invoice); + + assertThat(result).isNotEmpty(); + // Das ZUGFeRD-XML wird als „factur-x.xml" eingebettet — schneller Smoke-Test + // über die UTF-8-Repräsentation des Containers. + String resultAsString = new String(result, java.nio.charset.StandardCharsets.ISO_8859_1); + assertThat(resultAsString).contains("factur-x.xml"); + } + + private void configureSystemKeystore(Path keystoreFile, String password, String alias) { + EInvoiceProperties.Signing signing = properties.getSigning(); + signing.setEnabled(true); + signing.setKeystorePath(keystoreFile.toString()); + signing.setKeystorePassword(password); + signing.setKeyAlias(alias); + } + + private UserSigningCredentials storeUserKeystore() { + byte[] userKeystore = generateKeystore(USER_ALIAS, USER_PASSWORD); + when(repository.save(any(UserSigningCredentials.class))).thenAnswer(invocation -> invocation.getArgument(0)); + return signingCredentialsService.store(USER_ID, userKeystore, USER_PASSWORD, USER_ALIAS); + } + + private void assertSignedPdf(byte[] pdfBytes) throws Exception { + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + List signatures = document.getSignatureDictionaries(); + assertThat(signatures).as("Signed PDF must contain a signature dictionary").isNotEmpty(); + PDSignature signature = signatures.get(0); + assertThat(signature.getFilter()).isEqualTo("Adobe.PPKLite"); + assertThat(signature.getSubFilter()).isEqualTo("adbe.pkcs7.detached"); + assertThat(signature.getContents(pdfBytes)).isNotEmpty(); + } + } + + private byte[] buildBlankPdf() throws Exception { + try (PDDocument document = new PDDocument(); ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + document.addPage(new PDPage()); + document.save(baos); + return baos.toByteArray(); + } + } + + private byte[] buildPdfWithFont() throws Exception { + try (PDDocument document = new PDDocument(); ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + PDPage page = new PDPage(); + document.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.newLineAtOffset(50, 750); + cs.showText("Test-Rechnung"); + cs.endText(); + } + document.save(baos); + return baos.toByteArray(); + } + } + + private byte[] generateKeystore(String alias, String password) { + try { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair keyPair = kpg.generateKeyPair(); + + X500Name subject = new X500Name("CN=Votianlt Test " + alias + ",O=Votianlt,C=DE"); + BigInteger serial = BigInteger.valueOf(System.nanoTime()); + Date notBefore = Date.from(Instant.now().minus(1, ChronoUnit.DAYS)); + Date notAfter = Date.from(Instant.now().plus(365, ChronoUnit.DAYS)); + + JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(subject, serial, notBefore, + notAfter, subject, keyPair.getPublic()); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").setProvider("BC") + .build(keyPair.getPrivate()); + X509Certificate certificate = new JcaX509CertificateConverter().setProvider("BC") + .getCertificate(certBuilder.build(signer)); + + KeyStore keystore = KeyStore.getInstance("PKCS12"); + keystore.load(null, null); + keystore.setKeyEntry(alias, keyPair.getPrivate(), password.toCharArray(), + new Certificate[] { certificate }); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + keystore.store(baos, password.toCharArray()); + return baos.toByteArray(); + } catch (Exception ex) { + throw new IllegalStateException("Test-Keystore konnte nicht erzeugt werden: " + ex.getMessage(), ex); + } + } + + private CustomerInvoice sampleInvoice() { + CustomerInvoice invoice = new CustomerInvoice(); + invoice.setUserId(USER_ID); + invoice.setInvoiceNumber("R-2026-0001"); + invoice.setInvoiceDate(LocalDate.now()); + invoice.setDeliveryDate(LocalDate.now()); + invoice.setSenderName("Votianlt Test GmbH"); + invoice.setSenderAddress("Teststraße 1"); + invoice.setSenderPostcode("12345"); + invoice.setSenderCity("Berlin"); + invoice.setSenderCountry("DE"); + invoice.setRecipientName("Empfänger AG"); + invoice.setRecipientAddress("Kundenweg 2"); + invoice.setRecipientPostcode("54321"); + invoice.setRecipientCity("Hamburg"); + invoice.setRecipientCountry("DE"); + invoice.setDescription("Beratungsleistung"); + invoice.setVatRate(new BigDecimal("0.19")); + invoice.setNetAmount(new BigDecimal("100.00")); + invoice.setVatAmount(new BigDecimal("19.00")); + invoice.setTotalAmount(new BigDecimal("119.00")); + CustomerInvoiceItem item = new CustomerInvoiceItem(BigDecimal.ONE, "h", "Beratung", new BigDecimal("100.00"), + new BigDecimal("0.19")); + invoice.setItems(List.of(item)); + return invoice; + } +} diff --git a/backend/src/test/java/de/assecutor/votianlt/service/InvoiceComplianceValidatorTest.java b/backend/src/test/java/de/assecutor/votianlt/service/InvoiceComplianceValidatorTest.java new file mode 100644 index 0000000..0f58cd1 --- /dev/null +++ b/backend/src/test/java/de/assecutor/votianlt/service/InvoiceComplianceValidatorTest.java @@ -0,0 +1,304 @@ +package de.assecutor.votianlt.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +import de.assecutor.votianlt.model.invoices.CustomerInvoice; +import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem; +import de.assecutor.votianlt.model.invoices.InvoiceType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +/** + * Pflichtangaben-Validierung nach § 14 UStG. Jeder Test mutiert exakt eine + * Eigenschaft der Referenzrechnung — so bleibt sichtbar, welche Regel gerade + * geprüft wird, und die Tests fungieren gleichzeitig als ausführbare + * Spezifikation. + */ +class InvoiceComplianceValidatorTest { + + private InvoiceComplianceValidator validator; + + @BeforeEach + void setUp() { + validator = new InvoiceComplianceValidator(); + } + + @Test + void acceptsCompleteInvoice() { + CustomerInvoice invoice = validInvoice(); + validator.validateForIssuance(invoice); + } + + @Test + void rejectsMissingInvoiceNumber() { + CustomerInvoice invoice = validInvoice(); + invoice.setInvoiceNumber(" "); + assertSingleViolation(invoice, "Rechnungsnummer fehlt"); + } + + @Test + void rejectsMissingInvoiceDate() { + CustomerInvoice invoice = validInvoice(); + invoice.setInvoiceDate(null); + assertSingleViolation(invoice, "Rechnungsdatum"); + } + + @Test + void rejectsMissingDeliveryDate() { + CustomerInvoice invoice = validInvoice(); + invoice.setDeliveryDate(null); + assertSingleViolation(invoice, "Leistungsdatum"); + } + + @Test + void rejectsMissingSenderName() { + CustomerInvoice invoice = validInvoice(); + invoice.setSenderName(null); + assertSingleViolation(invoice, "Name des Leistenden"); + } + + @Test + void rejectsIncompleteSenderAddress() { + CustomerInvoice invoice = validInvoice(); + invoice.setSenderPostcode(""); + assertSingleViolation(invoice, "Anschrift des Leistenden"); + } + + @Test + void rejectsMissingSenderTaxIdentification() { + CustomerInvoice invoice = validInvoice(); + invoice.setSenderTaxNumber(null); + invoice.setSenderVatId(null); + assertSingleViolation(invoice, "Steuernummer oder USt-IdNr"); + } + + @Test + void acceptsSenderWithOnlyTaxNumber() { + CustomerInvoice invoice = validInvoice(); + invoice.setSenderVatId(null); + validator.validateForIssuance(invoice); + } + + @Test + void acceptsSenderWithOnlyVatId() { + CustomerInvoice invoice = validInvoice(); + invoice.setSenderTaxNumber(null); + validator.validateForIssuance(invoice); + } + + @Test + void rejectsMissingRecipientName() { + CustomerInvoice invoice = validInvoice(); + invoice.setRecipientName(null); + assertSingleViolation(invoice, "Name des Leistungsempfängers"); + } + + @Test + void rejectsIncompleteRecipientAddress() { + CustomerInvoice invoice = validInvoice(); + invoice.setRecipientCity(null); + assertSingleViolation(invoice, "Anschrift des Leistungsempfängers"); + } + + @Test + void rejectsEmptyItems() { + CustomerInvoice invoice = validInvoice(); + invoice.setItems(new ArrayList<>()); + assertSingleViolation(invoice, "Keine Positionen erfasst"); + } + + @Test + void rejectsItemWithoutDescription() { + CustomerInvoice invoice = validInvoice(); + invoice.getItems().get(0).setDescription(""); + assertSingleViolation(invoice, "Bezeichnung der Leistung fehlt"); + } + + @Test + void rejectsItemWithZeroQuantity() { + CustomerInvoice invoice = validInvoice(); + CustomerInvoiceItem item = invoice.getItems().get(0); + item.setQuantity(BigDecimal.ZERO); + // Zwingt netTotal = 0, damit nur die Mengenregel anschlägt und nicht zusätzlich + // die Konsistenzprüfung der Summen. + item.setNetTotal(BigDecimal.ZERO); + invoice.setNetAmount(BigDecimal.ZERO); + invoice.setVatAmount(BigDecimal.ZERO); + invoice.setTotalAmount(BigDecimal.ZERO); + // VAT-Hinweis muss bei 0 % aber gesetzt sein, sonst lösen wir zwei Verstöße aus. + invoice.setVatRate(new BigDecimal("0.19")); + assertSingleViolation(invoice, "Menge muss größer 0"); + } + + @Test + void rejectsItemWithNegativeUnitPrice() { + CustomerInvoice invoice = validInvoice(); + invoice.getItems().get(0).setUnitPrice(new BigDecimal("-5.00")); + InvoiceComplianceException ex = catchThrowableOfType( + () -> validator.validateForIssuance(invoice), InvoiceComplianceException.class); + assertThat(ex).isNotNull(); + assertThat(ex.getViolations()).anyMatch(v -> v.contains("Einzelpreis")); + } + + @Test + void rejectsInconsistentTotals() { + CustomerInvoice invoice = validInvoice(); + invoice.setTotalAmount(new BigDecimal("999.99")); + InvoiceComplianceException ex = catchThrowableOfType( + () -> validator.validateForIssuance(invoice), InvoiceComplianceException.class); + assertThat(ex).isNotNull(); + assertThat(ex.getViolations()).anyMatch(v -> v.contains("Bruttobetrag passt nicht")); + } + + @Test + void rejectsItemSumMismatchingNet() { + CustomerInvoice invoice = validInvoice(); + // Items summieren sich weiterhin auf 100.00, aber das deklarierte Netto wird verstellt. + invoice.setNetAmount(new BigDecimal("80.00")); + invoice.setVatAmount(new BigDecimal("15.20")); + invoice.setTotalAmount(new BigDecimal("95.20")); + InvoiceComplianceException ex = catchThrowableOfType( + () -> validator.validateForIssuance(invoice), InvoiceComplianceException.class); + assertThat(ex).isNotNull(); + assertThat(ex.getViolations()).anyMatch(v -> v.contains("Summe der Positionen")); + } + + @Test + void rejectsMissingVatRate() { + CustomerInvoice invoice = validInvoice(); + invoice.setVatRate(null); + assertSingleViolation(invoice, "Steuersatz fehlt"); + } + + @Test + void rejectsZeroVatWithoutLegalNotice() { + CustomerInvoice invoice = zeroVatInvoice(); + invoice.setReverseChargeNote(null); + invoice.setLegalNotes(null); + assertSingleViolation(invoice, "Bei 0 % USt ist ein rechtlicher Hinweis erforderlich"); + } + + @Test + void acceptsZeroVatWithReverseChargeNote() { + CustomerInvoice invoice = zeroVatInvoice(); + invoice.setReverseChargeNote("Steuerschuldnerschaft des Leistungsempfängers (§ 13b UStG)."); + invoice.setLegalNotes(null); + validator.validateForIssuance(invoice); + } + + @Test + void acceptsZeroVatWithLegalNotes() { + CustomerInvoice invoice = zeroVatInvoice(); + invoice.setReverseChargeNote(null); + invoice.setLegalNotes("Kleinunternehmer im Sinne des § 19 Abs. 1 UStG — keine Umsatzsteuer ausgewiesen."); + validator.validateForIssuance(invoice); + } + + @Test + void rejectsMismatchingVatAmountForNonZeroRate() { + CustomerInvoice invoice = validInvoice(); + // Erwartet wären 19,00 € — wir tragen 25,00 € ein. + invoice.setVatAmount(new BigDecimal("25.00")); + invoice.setTotalAmount(new BigDecimal("125.00")); + InvoiceComplianceException ex = catchThrowableOfType( + () -> validator.validateForIssuance(invoice), InvoiceComplianceException.class); + assertThat(ex).isNotNull(); + assertThat(ex.getViolations()).anyMatch(v -> v.contains("Steuerbetrag")); + } + + @Test + void collectsAllViolationsInOnePass() { + CustomerInvoice invoice = validInvoice(); + invoice.setInvoiceNumber(null); + invoice.setSenderName(null); + invoice.setItems(new ArrayList<>()); + InvoiceComplianceException ex = catchThrowableOfType( + () -> validator.validateForIssuance(invoice), InvoiceComplianceException.class); + assertThat(ex).isNotNull(); + assertThat(ex.getViolations()) + .as("Validator soll alle Verstöße sammeln, nicht beim ersten abbrechen") + .hasSizeGreaterThanOrEqualTo(3); + } + + @Test + void cancellationIsNotValidatedHere() { + CustomerInvoice invoice = validInvoice(); + invoice.setType(InvoiceType.CANCELLATION); + invoice.setItems(new ArrayList<>()); // wäre für reguläre Rechnung ein Fehler + validator.validateForIssuance(invoice); + } + + @Test + void correctionIsNotValidatedHere() { + CustomerInvoice invoice = validInvoice(); + invoice.setType(InvoiceType.CORRECTION); + invoice.setSenderName(null); // wäre für reguläre Rechnung ein Fehler + validator.validateForIssuance(invoice); + } + + @Test + void nullInvoiceIsRejectedDirectly() { + assertThatThrownBy(() -> validator.validateForIssuance(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + private void assertSingleViolation(CustomerInvoice invoice, String fragment) { + InvoiceComplianceException ex = catchThrowableOfType( + () -> validator.validateForIssuance(invoice), InvoiceComplianceException.class); + assertThat(ex).as("erwartete InvoiceComplianceException").isNotNull(); + assertThat(ex.getViolations()) + .as("Verstoß mit Fragment '%s' erwartet, war: %s", fragment, ex.getViolations()) + .anyMatch(v -> v.contains(fragment)); + } + + private CustomerInvoice validInvoice() { + CustomerInvoice invoice = new CustomerInvoice(); + invoice.setType(InvoiceType.INVOICE); + invoice.setInvoiceNumber("R-2026-0001"); + invoice.setInvoiceDate(LocalDate.of(2026, 5, 3)); + invoice.setDeliveryDate(LocalDate.of(2026, 5, 3)); + + invoice.setSenderName("Votianlt Test GmbH"); + invoice.setSenderAddress("Teststraße 1"); + invoice.setSenderPostcode("12345"); + invoice.setSenderCity("Berlin"); + invoice.setSenderCountry("DE"); + invoice.setSenderTaxNumber("12/345/67890"); + invoice.setSenderVatId("DE123456789"); + + invoice.setRecipientName("Empfänger AG"); + invoice.setRecipientAddress("Kundenweg 2"); + invoice.setRecipientPostcode("54321"); + invoice.setRecipientCity("Hamburg"); + invoice.setRecipientCountry("DE"); + + CustomerInvoiceItem item = new CustomerInvoiceItem(BigDecimal.ONE, "h", "Beratung", + new BigDecimal("100.00"), new BigDecimal("0.19")); + invoice.setItems(new ArrayList<>(List.of(item))); + + invoice.setNetAmount(new BigDecimal("100.00")); + invoice.setVatRate(new BigDecimal("0.19")); + invoice.setVatAmount(new BigDecimal("19.00")); + invoice.setTotalAmount(new BigDecimal("119.00")); + return invoice; + } + + private CustomerInvoice zeroVatInvoice() { + CustomerInvoice invoice = validInvoice(); + invoice.setVatRate(BigDecimal.ZERO); + invoice.setVatAmount(BigDecimal.ZERO); + invoice.setTotalAmount(invoice.getNetAmount()); + CustomerInvoiceItem item = invoice.getItems().get(0); + item.setVatRate(BigDecimal.ZERO); + item.setVatAmount(BigDecimal.ZERO); + item.setGrossTotal(item.getNetTotal()); + return invoice; + } +} diff --git a/backend/src/test/java/de/assecutor/votianlt/service/InvoiceNumberAuditServiceTest.java b/backend/src/test/java/de/assecutor/votianlt/service/InvoiceNumberAuditServiceTest.java new file mode 100644 index 0000000..3249288 --- /dev/null +++ b/backend/src/test/java/de/assecutor/votianlt/service/InvoiceNumberAuditServiceTest.java @@ -0,0 +1,171 @@ +package de.assecutor.votianlt.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import de.assecutor.votianlt.model.invoices.CustomerInvoice; +import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation; +import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus; +import de.assecutor.votianlt.repository.InvoiceNumberReservationRepository; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +@ExtendWith(MockitoExtension.class) +class InvoiceNumberAuditServiceTest { + + @Mock + private InvoiceNumberReservationRepository repository; + + private InvoiceNumberAuditService service; + + private final ObjectId userId = new ObjectId(); + + @BeforeEach + void setUp() { + service = new InvoiceNumberAuditService(repository); + } + + @Test + void markUsedTransitionsExistingReservation() { + InvoiceNumberReservation reservation = reservation("R-2026-000010", 10L, + InvoiceNumberReservationStatus.RESERVED); + when(repository.findByUserIdAndNumber(userId, "R-2026-000010")).thenReturn(Optional.of(reservation)); + when(repository.save(any(InvoiceNumberReservation.class))).thenAnswer(inv -> inv.getArgument(0)); + + CustomerInvoice invoice = invoice("R-2026-000010", "invoice-id-42"); + service.markUsed(invoice); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InvoiceNumberReservation.class); + verify(repository).save(captor.capture()); + InvoiceNumberReservation saved = captor.getValue(); + assertThat(saved.getStatus()).isEqualTo(InvoiceNumberReservationStatus.USED); + assertThat(saved.getInvoiceId()).isEqualTo("invoice-id-42"); + assertThat(saved.getUsedAt()).isNotNull(); + } + + @Test + void markUsedBootstrapsReservationForLegacyInvoiceWithoutPriorReservation() { + when(repository.findByUserIdAndNumber(userId, "RE-2024-0007")).thenReturn(Optional.empty()); + when(repository.save(any(InvoiceNumberReservation.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.markUsed(invoice("RE-2024-0007", "legacy-invoice")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InvoiceNumberReservation.class); + verify(repository).save(captor.capture()); + InvoiceNumberReservation saved = captor.getValue(); + assertThat(saved.getStatus()).isEqualTo(InvoiceNumberReservationStatus.USED); + assertThat(saved.getNumber()).isEqualTo("RE-2024-0007"); + assertThat(saved.getSequence()).isEqualTo(7L); + assertThat(saved.getReservedBy()).contains("bootstrap"); + } + + @Test + void markUsedSwallowsRepositoryFailures() { + when(repository.findByUserIdAndNumber(any(), any())).thenThrow(new RuntimeException("Mongo down")); + // Erwartung: keine Exception nach außen — Festschreiben darf an Audit nicht scheitern. + service.markUsed(invoice("R-2026-1", "i-1")); + } + + @Test + void markUsedIgnoresInvoiceWithoutNumberOrUserId() { + CustomerInvoice missingNumber = new CustomerInvoice(); + missingNumber.setUserId(userId.toHexString()); + service.markUsed(missingNumber); + + CustomerInvoice missingUser = new CustomerInvoice(); + missingUser.setInvoiceNumber("R-1"); + service.markUsed(missingUser); + + verify(repository, never()).findByUserIdAndNumber(any(), any()); + verify(repository, never()).save(any()); + } + + @Test + void markVoidedRequiresReason() { + assertThatThrownBy(() -> service.markVoided(userId, "R-1", " ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Grund"); + assertThatThrownBy(() -> service.markVoided(userId, "R-1", null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void markVoidedTransitionsReservedToVoided() { + InvoiceNumberReservation reservation = reservation("R-2026-000005", 5L, + InvoiceNumberReservationStatus.RESERVED); + when(repository.findByUserIdAndNumber(userId, "R-2026-000005")).thenReturn(Optional.of(reservation)); + when(repository.save(any(InvoiceNumberReservation.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.markVoided(userId, "R-2026-000005", "Versehentlich vergeben, Kunde widerrufen"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InvoiceNumberReservation.class); + verify(repository).save(captor.capture()); + InvoiceNumberReservation saved = captor.getValue(); + assertThat(saved.getStatus()).isEqualTo(InvoiceNumberReservationStatus.VOIDED); + assertThat(saved.getVoidReason()).isEqualTo("Versehentlich vergeben, Kunde widerrufen"); + assertThat(saved.getVoidedAt()).isNotNull(); + } + + @Test + void markVoidedRefusesToOverwriteUsedReservation() { + InvoiceNumberReservation reservation = reservation("R-2026-000005", 5L, + InvoiceNumberReservationStatus.USED); + when(repository.findByUserIdAndNumber(userId, "R-2026-000005")).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> service.markVoided(userId, "R-2026-000005", "Test")) + .isInstanceOf(InvoiceLifecycleException.class) + .hasMessageContaining("ausgestellten Rechnung"); + } + + @Test + void markVoidedFailsWhenNoReservationFound() { + when(repository.findByUserIdAndNumber(userId, "R-NOPE")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.markVoided(userId, "R-NOPE", "irrelevant")) + .isInstanceOf(InvoiceLifecycleException.class) + .hasMessageContaining("Keine Reservierung"); + } + + @Test + void findUnusedReturnsOnlyReservedAndVoided() { + when(repository.findByUserIdOrderBySequenceAsc(userId)).thenReturn(List.of( + reservation("R-1", 1L, InvoiceNumberReservationStatus.USED), + reservation("R-2", 2L, InvoiceNumberReservationStatus.RESERVED), + reservation("R-3", 3L, InvoiceNumberReservationStatus.USED), + reservation("R-4", 4L, InvoiceNumberReservationStatus.VOIDED))); + + List unused = service.findUnused(userId); + + assertThat(unused).extracting(InvoiceNumberReservation::getNumber).containsExactly("R-2", "R-4"); + } + + private CustomerInvoice invoice(String number, String invoiceId) { + CustomerInvoice invoice = new CustomerInvoice(); + invoice.setId(invoiceId); + invoice.setInvoiceNumber(number); + invoice.setUserId(userId.toHexString()); + return invoice; + } + + private InvoiceNumberReservation reservation(String number, long sequence, + InvoiceNumberReservationStatus status) { + InvoiceNumberReservation reservation = new InvoiceNumberReservation(); + reservation.setUserId(userId); + reservation.setNumber(number); + reservation.setSequence(sequence); + reservation.setStatus(status); + return reservation; + } +}