refactor: E-Rechnungs-Modul, Freigabe-Workflow und DATEV-Export entfernt

Entfernt ZUGFeRD/Factur-X-Anreicherung (Mustangproject), PAdES-Signatur
(BouncyCastle/DSS) inkl. nutzerseitiger Keystore-Verwaltung, den
Approval-Workflow für Storno-/Berichtigungsbelege sowie den DATEV-CSV-Export.
Navigation kehrt zur klassischen Rechnungsansicht zurück; Version auf 0.9.17.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 08:43:13 +02:00
parent d699609aa1
commit 31b18e1f52
33 changed files with 56 additions and 3693 deletions

View File

@@ -11,7 +11,7 @@
<packaging>jar</packaging> <packaging>jar</packaging>
<properties> <properties>
<revision>0.9.16</revision> <revision>0.9.17</revision>
<java.version>21</java.version> <java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target> <maven.compiler.target>21</maven.compiler.target>
@@ -184,20 +184,6 @@
<version>5.0.5</version> <version>5.0.5</version>
</dependency> </dependency>
<!-- BouncyCastle: CMS-Signatur für PAdES via Apache PDFBox -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
<version>1.78</version>
</dependency>
<!-- Mustangproject: ZUGFeRD/Factur-X/XRechnung E-Rechnung -->
<dependency>
<groupId>org.mustangproject</groupId>
<artifactId>library</artifactId>
<version>2.16.0</version>
</dependency>
<!-- Spring AI OpenAI (LM Studio kompatibel) --> <!-- Spring AI OpenAI (LM Studio kompatibel) -->
<dependency> <dependency>
<groupId>org.springframework.ai</groupId> <groupId>org.springframework.ai</groupId>
@@ -222,32 +208,6 @@
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- EU DSS: PAdES-Validierung der signierten Test-PDFs (nur Test-Scope) -->
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-pades-pdfbox</artifactId>
<version>6.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-validation</artifactId>
<version>6.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-utils-apache-commons</artifactId>
<version>6.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-crl-parser-x509crl</artifactId>
<version>6.2</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -1,181 +0,0 @@
package de.assecutor.votianlt.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* Konfiguration für ZUGFeRD-Anreicherung und PAdES-Signatur.
*
* Wird über {@code application.properties} mit dem Präfix
* {@code votianlt.einvoice} gesetzt. Beispiel:
*
* <pre>
* 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
* </pre>
*
* 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;
}
}
}

View File

@@ -1,118 +0,0 @@
package de.assecutor.votianlt.config;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.Set;
/**
* Stufe 2/3 der E-Rechnungs-Sicherheit:
*
* <ul>
* <li>Lädt den Master-Key beim Startup aus einer optional konfigurierten Secret-Datei
* (Docker-/K8s-Secrets-Style). Inhalt wird einmalig in {@code Signing.masterKey}
* gespiegelt die Datei wird zur Laufzeit nicht erneut gelesen.</li>
* <li>Prüft die Dateiberechtigungen und warnt, wenn die Datei für andere Nutzer lesbar
* oder schreibbar ist (POSIX-Filesystems).</li>
* <li>Loggt einen kompakten Sicherheits-Banner mit Konfigurations-Quelle, ohne den
* Inhalt des Keys zu offenbaren.</li>
* </ul>
*
* Der Master-Key kann auf drei Arten gesetzt werden, in Vorrang-Reihenfolge:
* <ol>
* <li>{@code votianlt.einvoice.signing.master-key-file} — Pfad zu einer Secret-Datei</li>
* <li>{@code votianlt.einvoice.signing.master-key} — direkt gesetzte Property
* (z.B. via Spring-Placeholder {@code ${VOTIANLT_EINVOICE_MASTER_KEY}})</li>
* <li>nichts gesetzt — Funktion deaktiviert, gespeicherte Keystores bleiben unentschlüsselbar</li>
* </ol>
*/
@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<PosixFilePermission> 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());
}
}
}

View File

@@ -72,29 +72,4 @@ public class User {
// Umsatzsteuer-Satz (als Dezimalwert, z.B. 0.19 für 19 %) // Umsatzsteuer-Satz (als Dezimalwert, z.B. 0.19 für 19 %)
private BigDecimal vatRate = new BigDecimal("0.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;
} }

View File

@@ -41,12 +41,6 @@ public class CustomerInvoice {
private BigDecimal paidAmount; private BigDecimal paidAmount;
private LocalDateTime lastPaymentAt; 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) // Pflichtangaben nach §14 UStG (German VAT law)
private String invoiceNumber; // Fortlaufende Rechnungsnummer private String invoiceNumber; // Fortlaufende Rechnungsnummer
private LocalDate invoiceDate; // Rechnungsdatum private LocalDate invoiceDate; // Rechnungsdatum
@@ -537,36 +531,4 @@ public class CustomerInvoice {
public void setLastPaymentAt(LocalDateTime lastPaymentAt) { public void setLastPaymentAt(LocalDateTime lastPaymentAt) {
this.lastPaymentAt = 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;
}
} }

View File

@@ -1,12 +0,0 @@
package de.assecutor.votianlt.model.invoices;
/**
* Markiert, in welchem E-Rechnung-Format eine gespeicherte Rechnung vorliegt.
*
* NONE reines, nicht maschinenlesbares PDF.
* ZUGFERD PDF/A-3 mit eingebetteter ZUGFeRD/Factur-X/EN16931-XML.
* XRECHNUNG PDF/A-3 mit eingebetteter XRechnung-XML (UN/CEFACT-Profil).
*/
public enum EInvoiceFormat {
NONE, ZUGFERD, XRECHNUNG
}

View File

@@ -1,11 +0,0 @@
package de.assecutor.votianlt.model.invoices;
/**
* Aktionstyp eines {@link InvoiceApprovalRequest}.
*
* CANCEL_INVOICE Storno einer ausgestellten Rechnung (R-17 ff.).
* CORRECT_INVOICE Berichtigungsbeleg zu einer ausgestellten Rechnung (R-12 ff.).
*/
public enum InvoiceApprovalAction {
CANCEL_INVOICE, CORRECT_INVOICE
}

View File

@@ -1,179 +0,0 @@
package de.assecutor.votianlt.model.invoices;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
/**
* Freigabe-Anfrage für eine kritische Rechnungsaktion gemäß R-42.
*
* Wird vom Lifecycle-Service angelegt, sobald ein Nutzer ohne
* Approver-Berechtigung mit aktivem {@code requireApprovalForCriticalInvoiceActions}-Flag
* einen Storno oder eine Berichtigung anstößt.
*
* Der Approver führt die Aktion über {@code approve} oder lehnt sie über {@code reject} ab.
* Die eigentliche Erzeugung des Storno-/Berichtigungsbelegs erfolgt erst nach Freigabe.
*/
@Document(collection = "invoiceApprovalRequests")
public class InvoiceApprovalRequest {
@Id
private String id;
private InvoiceApprovalAction action;
private InvoiceApprovalStatus status = InvoiceApprovalStatus.PENDING;
private String targetInvoiceId;
private String targetInvoiceNumber;
private String requestedByUserId;
private String requestedByDisplayName;
private LocalDateTime requestedAt;
private String reviewedByUserId;
private String reviewedByDisplayName;
private LocalDateTime reviewedAt;
private String reviewerComment;
/** Eingegebener Grund (R-36). */
private String reason;
/** Bei Berichtigung: Beschreibung der ergänzten/korrigierten Angaben (R-14). */
private String correctedFields;
/** Verweis auf den nach Freigabe tatsächlich erzeugten Folgebeleg. */
private String resultingInvoiceId;
private String resultingInvoiceNumber;
public InvoiceApprovalRequest() {
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public InvoiceApprovalAction getAction() {
return action;
}
public void setAction(InvoiceApprovalAction action) {
this.action = action;
}
public InvoiceApprovalStatus getStatus() {
return status;
}
public void setStatus(InvoiceApprovalStatus status) {
this.status = status;
}
public String getTargetInvoiceId() {
return targetInvoiceId;
}
public void setTargetInvoiceId(String targetInvoiceId) {
this.targetInvoiceId = targetInvoiceId;
}
public String getTargetInvoiceNumber() {
return targetInvoiceNumber;
}
public void setTargetInvoiceNumber(String targetInvoiceNumber) {
this.targetInvoiceNumber = targetInvoiceNumber;
}
public String getRequestedByUserId() {
return requestedByUserId;
}
public void setRequestedByUserId(String requestedByUserId) {
this.requestedByUserId = requestedByUserId;
}
public String getRequestedByDisplayName() {
return requestedByDisplayName;
}
public void setRequestedByDisplayName(String requestedByDisplayName) {
this.requestedByDisplayName = requestedByDisplayName;
}
public LocalDateTime getRequestedAt() {
return requestedAt;
}
public void setRequestedAt(LocalDateTime requestedAt) {
this.requestedAt = requestedAt;
}
public String getReviewedByUserId() {
return reviewedByUserId;
}
public void setReviewedByUserId(String reviewedByUserId) {
this.reviewedByUserId = reviewedByUserId;
}
public String getReviewedByDisplayName() {
return reviewedByDisplayName;
}
public void setReviewedByDisplayName(String reviewedByDisplayName) {
this.reviewedByDisplayName = reviewedByDisplayName;
}
public LocalDateTime getReviewedAt() {
return reviewedAt;
}
public void setReviewedAt(LocalDateTime reviewedAt) {
this.reviewedAt = reviewedAt;
}
public String getReviewerComment() {
return reviewerComment;
}
public void setReviewerComment(String reviewerComment) {
this.reviewerComment = reviewerComment;
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
public String getCorrectedFields() {
return correctedFields;
}
public void setCorrectedFields(String correctedFields) {
this.correctedFields = correctedFields;
}
public String getResultingInvoiceId() {
return resultingInvoiceId;
}
public void setResultingInvoiceId(String resultingInvoiceId) {
this.resultingInvoiceId = resultingInvoiceId;
}
public String getResultingInvoiceNumber() {
return resultingInvoiceNumber;
}
public void setResultingInvoiceNumber(String resultingInvoiceNumber) {
this.resultingInvoiceNumber = resultingInvoiceNumber;
}
}

View File

@@ -1,5 +0,0 @@
package de.assecutor.votianlt.model.invoices;
public enum InvoiceApprovalStatus {
PENDING, APPROVED, REJECTED
}

View File

@@ -1,163 +0,0 @@
package de.assecutor.votianlt.model.invoices;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
/**
* Pro Nutzer hinterlegte Signatur-Credentials für die Erzeugung von signierten
* (PAdES) E-Rechnungen.
*
* Sicherheitshinweise:
* <ul>
* <li>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.</li>
* <li>Das Keystore-Passwort wird ebenfalls AES-GCM-verschlüsselt persistiert.</li>
* <li>Beide Felder enthalten die Initialisierungs-IV als Präfix
* (12 Byte IV + Ciphertext+Tag).</li>
* <li>Der Master-Key darf nicht verloren gehen — andernfalls sind alle
* hinterlegten Keystores nicht mehr verwendbar und müssen vom Nutzer
* neu hochgeladen werden.</li>
* </ul>
*
* 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;
}
}

View File

@@ -1,255 +0,0 @@
package de.assecutor.votianlt.pages.base.ui.component;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.PasswordField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.component.upload.Upload;
import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
import com.vaadin.flow.i18n.I18NProvider;
import de.assecutor.votianlt.config.EInvoiceProperties;
import de.assecutor.votianlt.model.invoices.UserSigningCredentials;
import de.assecutor.votianlt.service.SigningCredentialsService;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Optional;
import java.util.function.Function;
/**
* UI-Panel zur Verwaltung der nutzerseitigen PKCS#12-Signatur-Credentials
* (Phase 5.5 / 5.6 der Rechnungsregeln).
*
* Wird in {@link de.assecutor.votianlt.pages.view.EditProfileView} eingebunden.
* Die Klasse ist absichtlich keine Vaadin-{@code @Route} und keine Spring-Bean —
* sie ist eine gewöhnliche Vaadin-Komponente, die mit den nötigen Services
* konstruiert und vom Profil-View positioniert wird.
*/
public class SigningCredentialsPanel extends VerticalLayout {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY);
private final SigningCredentialsService credentialsService;
private final EInvoiceProperties properties;
private final String userId;
private final Function<String, String> tr;
private final Div statusContainer = new Div();
public SigningCredentialsPanel(SigningCredentialsService credentialsService, EInvoiceProperties properties,
String userId, Function<String, String> 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<UserSigningCredentials> 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()));
}
}

View File

@@ -145,12 +145,8 @@ public final class MainLayout extends AppLayout {
// Add children to "Verwaltung" // Add children to "Verwaltung"
treeData.addItem(verwaltungItem, treeData.addItem(verwaltungItem,
new MenuTreeItem(getTranslation("nav.jobs"), "jobs", VaadinIcon.CLIPBOARD_TEXT)); 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, treeData.addItem(verwaltungItem,
new MenuTreeItem(getTranslation("nav.datev.export"), "datev-export", VaadinIcon.DOWNLOAD)); new MenuTreeItem(getTranslation("nav.invoices"), "invoices", VaadinIcon.FILE_TEXT));
treeData.addItem(verwaltungItem,
new MenuTreeItem(getTranslation("nav.approvals"), "approvals", VaadinIcon.CHECK_CIRCLE));
treeData.addItem(verwaltungItem, treeData.addItem(verwaltungItem,
new MenuTreeItem(getTranslation("nav.customers"), "customers", VaadinIcon.USERS)); new MenuTreeItem(getTranslation("nav.customers"), "customers", VaadinIcon.USERS));
treeData.addItem(verwaltungItem, treeData.addItem(verwaltungItem,

View File

@@ -1,205 +0,0 @@
package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route;
import de.assecutor.votianlt.model.invoices.InvoiceApprovalAction;
import de.assecutor.votianlt.model.invoices.InvoiceApprovalRequest;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import de.assecutor.votianlt.service.InvoiceApprovalService;
import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoicePermissionService;
import jakarta.annotation.security.RolesAllowed;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
/**
* Freigabe-Posteingang gemäß R-42. Listet offene Storno-/Berichtigungs-Anfragen
* und erlaubt Approver-berechtigten Nutzern die Freigabe oder Ablehnung.
*
* Wird über die Route {@code /approvals} erreicht. Der Zugriff ist nur für Nutzer
* mit Rolle {@code USER}/{@code ADMIN} formal offen — die feingranulare Prüfung
* erfolgt durch den {@link InvoicePermissionService}, der im Render-Pfad
* unberechtigte Nutzer mit einem entsprechenden Hinweis abblockt.
*/
@Route(value = "approvals", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER", "ADMIN" })
public class ApprovalsView extends VerticalLayout implements HasDynamicTitle {
private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm",
Locale.GERMANY);
private final InvoiceApprovalService invoiceApprovalService;
private final Grid<InvoiceApprovalRequest> 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");
}
}

View File

@@ -14,8 +14,7 @@ import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.NumberField; import com.vaadin.flow.component.textfield.NumberField;
import com.vaadin.flow.router.HasDynamicTitle; import com.vaadin.flow.router.HasDynamicTitle;
// Import bleibt auskommentiert, solange @Route deaktiviert ist (siehe unten). import com.vaadin.flow.router.Route;
// import com.vaadin.flow.router.Route;
import com.vaadin.flow.router.BeforeEvent; import com.vaadin.flow.router.BeforeEvent;
import com.vaadin.flow.router.HasUrlParameter; import com.vaadin.flow.router.HasUrlParameter;
import de.assecutor.votianlt.model.Customer; import de.assecutor.votianlt.model.Customer;
@@ -33,7 +32,6 @@ import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.repository.UserRepository; import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService; import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.EInvoiceService;
import de.assecutor.votianlt.service.InvoiceLifecycleException; import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoiceLifecycleService; import de.assecutor.votianlt.service.InvoiceLifecycleService;
import de.assecutor.votianlt.service.InvoiceTemplateService; import de.assecutor.votianlt.service.InvoiceTemplateService;
@@ -53,11 +51,7 @@ import java.util.Optional;
import com.vaadin.flow.component.dialog.Dialog; import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.IFrame; import com.vaadin.flow.component.html.IFrame;
// Route deaktiviert: das System erstellt keine eigenen Rechnungen mehr. @Route(value = "create_invoice", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
// 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" }) @RolesAllowed({ "USER" })
@Slf4j @Slf4j
public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter<String>, HasDynamicTitle { public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter<String>, HasDynamicTitle {
@@ -71,7 +65,6 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
private final UserInvoiceDataService userInvoiceDataService; private final UserInvoiceDataService userInvoiceDataService;
private final CustomerService customerService; private final CustomerService customerService;
private final InvoiceLifecycleService invoiceLifecycleService; private final InvoiceLifecycleService invoiceLifecycleService;
private final EInvoiceService eInvoiceService;
private User currentUser; private User currentUser;
private Job currentJob; private Job currentJob;
private List<ServiceRow> gridRows = new ArrayList<>(); private List<ServiceRow> gridRows = new ArrayList<>();
@@ -126,7 +119,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
UserRepository userRepository, CustomerInvoiceService customerInvoiceService, UserRepository userRepository, CustomerInvoiceService customerInvoiceService,
InvoiceTemplateService invoiceTemplateService, SecurityService securityService, InvoiceTemplateService invoiceTemplateService, SecurityService securityService,
UserInvoiceDataService userInvoiceDataService, CustomerService customerService, UserInvoiceDataService userInvoiceDataService, CustomerService customerService,
InvoiceLifecycleService invoiceLifecycleService, EInvoiceService eInvoiceService) { InvoiceLifecycleService invoiceLifecycleService) {
this.jobRepository = jobRepository; this.jobRepository = jobRepository;
this.serviceRepository = serviceRepository; this.serviceRepository = serviceRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
@@ -136,7 +129,6 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
this.userInvoiceDataService = userInvoiceDataService; this.userInvoiceDataService = userInvoiceDataService;
this.customerService = customerService; this.customerService = customerService;
this.invoiceLifecycleService = invoiceLifecycleService; this.invoiceLifecycleService = invoiceLifecycleService;
this.eInvoiceService = eInvoiceService;
setSizeFull(); setSizeFull();
setPadding(true); setPadding(true);
setSpacing(true); setSpacing(true);
@@ -594,17 +586,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
invoice.setVatAmount(vatAmount); invoice.setVatAmount(vatAmount);
invoice.setTotalAmount(totalAmount); invoice.setTotalAmount(totalAmount);
// ZUGFeRD-Anreicherung und PAdES-Signatur sind unabhängig: der Nutzer kann invoice.setPdfData(pdfBytes);
// 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). // Finalisierung mit Audit-Eintrag und Eindeutigkeitsprüfung der Rechnungsnummer (R-07/R-11/R-36).
CustomerInvoice savedInvoice = invoiceLifecycleService.createAndIssue(invoice, CustomerInvoice savedInvoice = invoiceLifecycleService.createAndIssue(invoice,

View File

@@ -1,117 +0,0 @@
package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.datepicker.DatePicker;
import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.H2;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.StreamRegistration;
import com.vaadin.flow.server.StreamResource;
import com.vaadin.flow.server.VaadinSession;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.DatevExportService;
import jakarta.annotation.security.RolesAllowed;
import java.io.ByteArrayInputStream;
import java.time.LocalDate;
/**
* Ersetzt das frühere Rechnungs-Modul: Anwender wählt einen Zeitraum und lädt
* einen DATEV-konformen Buchungsstapel mit allen festgeschriebenen Rechnungen
* dieses Zeitraums herunter. Erstellung/Bearbeitung von Rechnungen findet im
* System nicht mehr statt — die hier verwendeten Repositories enthalten
* ausschließlich Bestandsdaten.
*/
@Route(value = "datev-export", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER", "ADMIN" })
public class DatevExportView extends VerticalLayout implements HasDynamicTitle {
private final DatevExportService datevExportService;
private final SecurityService securityService;
private final DatePicker fromDate = new DatePicker();
private final DatePicker toDate = new DatePicker();
public DatevExportView(DatevExportService datevExportService, SecurityService securityService) {
this.datevExportService = datevExportService;
this.securityService = securityService;
setSizeFull();
setPadding(true);
setSpacing(true);
add(new H2(getTranslation("datev.export.title")));
add(new Paragraph(getTranslation("datev.export.description")));
LocalDate today = LocalDate.now();
fromDate.setLabel(getTranslation("datev.export.from"));
fromDate.setValue(today.withDayOfMonth(1).minusMonths(1));
toDate.setLabel(getTranslation("datev.export.to"));
toDate.setValue(today.withDayOfMonth(1).minusDays(1));
Button exportButton = new Button(getTranslation("datev.export.button"),
new Icon(VaadinIcon.DOWNLOAD));
exportButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
exportButton.addClickListener(e -> triggerDownload());
HorizontalLayout controls = new HorizontalLayout(fromDate, toDate, exportButton);
controls.setAlignItems(Alignment.END);
controls.setSpacing(true);
add(controls);
}
@Override
public String getPageTitle() {
return getTranslation("datev.export.title");
}
private void triggerDownload() {
LocalDate from = fromDate.getValue();
LocalDate to = toDate.getValue();
if (from == null || to == null) {
Notification.show(getTranslation("datev.export.error.dates"), 4000, Notification.Position.MIDDLE);
return;
}
if (to.isBefore(from)) {
Notification.show(getTranslation("datev.export.error.range"), 4000, Notification.Position.MIDDLE);
return;
}
String userId;
try {
userId = securityService.getCurrentDatabaseUser().getId().toHexString();
} catch (Exception ex) {
Notification.show(getTranslation("datev.export.error.user"), 4000, Notification.Position.MIDDLE);
return;
}
byte[] csv;
try {
csv = datevExportService.export(userId, from, to);
} catch (IllegalArgumentException ex) {
Notification.show(ex.getMessage(), 4000, Notification.Position.MIDDLE);
return;
}
String filename = datevExportService.suggestFilename(from, to);
StreamResource resource = new StreamResource(filename, () -> new ByteArrayInputStream(csv));
resource.setContentType("text/csv;charset=windows-1252");
StreamRegistration registration = VaadinSession.getCurrent().getResourceRegistry().registerResource(resource);
Anchor downloadAnchor = new Anchor(registration.getResourceUri().toString(), "");
downloadAnchor.getElement().setAttribute("download", filename);
downloadAnchor.getElement().getStyle().set("display", "none");
add(downloadAnchor);
downloadAnchor.getElement().executeJs("$0.click(); setTimeout(() => $0.remove(), 0);");
Notification.show(getTranslation("datev.export.success", filename), 3000, Notification.Position.BOTTOM_END);
}
}

View File

@@ -43,12 +43,9 @@ import de.assecutor.votianlt.pages.service.UserService;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService; import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
import de.assecutor.votianlt.repository.ServiceRepository; import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.security.SecurityService; 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.CustomerInvoiceService;
import de.assecutor.votianlt.service.InvoiceTemplateService; import de.assecutor.votianlt.service.InvoiceTemplateService;
import de.assecutor.votianlt.service.LanguageService; import de.assecutor.votianlt.service.LanguageService;
import de.assecutor.votianlt.service.SigningCredentialsService;
import com.vaadin.flow.component.grid.Grid; import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.combobox.ComboBox; import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.textfield.NumberField; import com.vaadin.flow.component.textfield.NumberField;
@@ -77,8 +74,6 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
private final UserInvoiceDataService userInvoiceDataService; private final UserInvoiceDataService userInvoiceDataService;
private final CustomerInvoiceService customerInvoiceService; private final CustomerInvoiceService customerInvoiceService;
private final InvoiceTemplateService invoiceTemplateService; private final InvoiceTemplateService invoiceTemplateService;
private final SigningCredentialsService signingCredentialsService;
private final EInvoiceProperties eInvoiceProperties;
private UserInvoiceData currentInvoiceData; private UserInvoiceData currentInvoiceData;
private Checkbox billingEnabled; private Checkbox billingEnabled;
private NumberField vatRateField; private NumberField vatRateField;
@@ -92,15 +87,12 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
public EditProfileView(UserService userService, UserInvoiceDataService userInvoiceDataService, public EditProfileView(UserService userService, UserInvoiceDataService userInvoiceDataService,
CustomerInvoiceService customerInvoiceService, InvoiceTemplateService invoiceTemplateService, CustomerInvoiceService customerInvoiceService, InvoiceTemplateService invoiceTemplateService,
LanguageService languageService, SecurityService securityService, ServiceRepository serviceRepository, LanguageService languageService, SecurityService securityService, ServiceRepository serviceRepository) {
SigningCredentialsService signingCredentialsService, EInvoiceProperties eInvoiceProperties) {
this.userInvoiceDataService = userInvoiceDataService; this.userInvoiceDataService = userInvoiceDataService;
this.customerInvoiceService = customerInvoiceService; this.customerInvoiceService = customerInvoiceService;
this.invoiceTemplateService = invoiceTemplateService; this.invoiceTemplateService = invoiceTemplateService;
this.currentUser = securityService.getCurrentDatabaseUser(); this.currentUser = securityService.getCurrentDatabaseUser();
this.serviceRepository = serviceRepository; this.serviceRepository = serviceRepository;
this.signingCredentialsService = signingCredentialsService;
this.eInvoiceProperties = eInvoiceProperties;
// Store the original language before any changes // Store the original language before any changes
this.originalLanguage = this.currentUser != null ? this.currentUser.getLanguage() : Language.DE; this.originalLanguage = this.currentUser != null ? this.currentUser.getLanguage() : Language.DE;
@@ -375,32 +367,11 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
} }
}); });
Checkbox eInvoiceCheckbox = new Checkbox(getTranslation("profile.settings.einvoice")); HorizontalLayout billingHeaderLayout = new HorizontalLayout(billingEnabled, prefixField, vatRateField);
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.setSpacing(true);
billingHeaderLayout.setAlignItems(FlexComponent.Alignment.BASELINE); billingHeaderLayout.setAlignItems(FlexComponent.Alignment.BASELINE);
billingTab.add(billingHeaderLayout); 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) // Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften)
final HorizontalLayout mainLayout = new HorizontalLayout(); final HorizontalLayout mainLayout = new HorizontalLayout();
mainLayout.setWidthFull(); mainLayout.setWidthFull();

View File

@@ -18,9 +18,7 @@ import com.vaadin.flow.component.textfield.NumberField;
import com.vaadin.flow.component.textfield.TextArea; import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField; import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.HasDynamicTitle; import com.vaadin.flow.router.HasDynamicTitle;
// Route deaktiviert (siehe Klassen-Header) — die Anwendung erstellt/bearbeitet keine import com.vaadin.flow.router.Route;
// 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.StreamRegistration;
import com.vaadin.flow.server.StreamResource; import com.vaadin.flow.server.StreamResource;
import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.User;
@@ -28,7 +26,6 @@ import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry; import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
import de.assecutor.votianlt.model.invoices.InvoiceStatus; import de.assecutor.votianlt.model.invoices.InvoiceStatus;
import de.assecutor.votianlt.model.invoices.InvoiceType; 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.DialogStylingHelper;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar; import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService; import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
@@ -36,7 +33,6 @@ import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import de.assecutor.votianlt.repository.UserRepository; import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService; import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.InvoiceApprovalService;
import de.assecutor.votianlt.service.InvoiceExportService; import de.assecutor.votianlt.service.InvoiceExportService;
import de.assecutor.votianlt.service.InvoiceLifecycleException; import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoiceLifecycleService; import de.assecutor.votianlt.service.InvoiceLifecycleService;
@@ -51,8 +47,7 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Optional; import java.util.Optional;
// @Route deaktiviert — Rechnungs-UI ist durch DATEV-Export ersetzt. Reaktivierung: @Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
// @Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER", "ADMIN" }) @RolesAllowed({ "USER", "ADMIN" })
public class InvoicesView extends VerticalLayout implements HasDynamicTitle { public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
@@ -66,14 +61,13 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
private final CustomerInvoiceService customerInvoiceService; private final CustomerInvoiceService customerInvoiceService;
private final InvoiceExportService invoiceExportService; private final InvoiceExportService invoiceExportService;
private final InvoicePermissionService invoicePermissionService; private final InvoicePermissionService invoicePermissionService;
private final InvoiceApprovalService invoiceApprovalService;
private final UserInvoiceDataService userInvoiceDataService; private final UserInvoiceDataService userInvoiceDataService;
private final UserRepository userRepository; private final UserRepository userRepository;
public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService, public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService,
InvoiceLifecycleService invoiceLifecycleService, CustomerInvoiceService customerInvoiceService, InvoiceLifecycleService invoiceLifecycleService, CustomerInvoiceService customerInvoiceService,
InvoiceExportService invoiceExportService, InvoicePermissionService invoicePermissionService, InvoiceExportService invoiceExportService, InvoicePermissionService invoicePermissionService,
InvoiceApprovalService invoiceApprovalService, UserInvoiceDataService userInvoiceDataService, UserInvoiceDataService userInvoiceDataService,
UserRepository userRepository) { UserRepository userRepository) {
this.customerInvoiceRepository = customerInvoiceRepository; this.customerInvoiceRepository = customerInvoiceRepository;
this.securityService = securityService; this.securityService = securityService;
@@ -81,7 +75,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
this.customerInvoiceService = customerInvoiceService; this.customerInvoiceService = customerInvoiceService;
this.invoiceExportService = invoiceExportService; this.invoiceExportService = invoiceExportService;
this.invoicePermissionService = invoicePermissionService; this.invoicePermissionService = invoicePermissionService;
this.invoiceApprovalService = invoiceApprovalService;
this.userInvoiceDataService = userInvoiceDataService; this.userInvoiceDataService = userInvoiceDataService;
this.userRepository = userRepository; this.userRepository = userRepository;
@@ -110,10 +103,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
.setHeader(getTranslation("invoices.column.date")).setAutoWidth(true); .setHeader(getTranslation("invoices.column.date")).setAutoWidth(true);
invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount")) invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount"))
.setAutoWidth(true); .setAutoWidth(true);
invoiceGrid.addComponentColumn(this::renderPaymentBadge)
.setHeader(getTranslation("invoices.column.payment")).setAutoWidth(true);
invoiceGrid.addColumn(this::formatOutstanding).setHeader(getTranslation("invoices.column.outstanding"))
.setAutoWidth(true);
invoiceGrid.addComponentColumn(this::renderActions) invoiceGrid.addComponentColumn(this::renderActions)
.setHeader(getTranslation("invoices.column.actions")).setAutoWidth(true).setFlexGrow(0); .setHeader(getTranslation("invoices.column.actions")).setAutoWidth(true).setFlexGrow(0);
@@ -165,29 +154,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
return badge; 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) { private Component renderTypeBadge(CustomerInvoice invoice) {
InvoiceType type = invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE; InvoiceType type = invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE;
HorizontalLayout layout = new HorizontalLayout(); HorizontalLayout layout = new HorizontalLayout();
@@ -202,21 +168,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
badge.getElement().getThemeList().add("warning"); badge.getElement().getThemeList().add("warning");
} }
layout.add(badge); 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; return layout;
} }
@@ -251,28 +202,17 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
} }
if (isLiveInvoice) { if (isLiveInvoice) {
boolean hasPendingRequest = !invoiceApprovalService
.findOpenForCurrentRequester().stream()
.filter(r -> invoice.getId().equals(r.getTargetInvoiceId()))
.toList().isEmpty();
if (invoicePermissionService.canCorrect(currentUser)) { if (invoicePermissionService.canCorrect(currentUser)) {
String label = invoicePermissionService.requiresApproval(currentUser) Button correctBtn = new Button(getTranslation("invoices.action.correct"),
? getTranslation("invoices.action.correct.request") e -> openCorrectionDialog(invoice));
: getTranslation("invoices.action.correct");
Button correctBtn = new Button(label, e -> openCorrectionDialog(invoice));
correctBtn.addThemeVariants(ButtonVariant.LUMO_SMALL); correctBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
correctBtn.setEnabled(!hasPendingRequest);
actions.add(correctBtn); actions.add(correctBtn);
} }
if (invoicePermissionService.canCancel(currentUser)) { if (invoicePermissionService.canCancel(currentUser)) {
String label = invoicePermissionService.requiresApproval(currentUser) Button cancelBtn = new Button(getTranslation("invoices.action.cancel"),
? getTranslation("invoices.action.cancel.request") e -> openCancellationDialog(invoice));
: getTranslation("invoices.action.cancel");
Button cancelBtn = new Button(label, e -> openCancellationDialog(invoice));
cancelBtn.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ERROR); cancelBtn.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ERROR);
cancelBtn.setEnabled(!hasPendingRequest);
actions.add(cancelBtn); actions.add(cancelBtn);
} }
} }
@@ -432,14 +372,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
User currentUser = invoicePermissionService.currentUser(); User currentUser = invoicePermissionService.currentUser();
try { try {
invoicePermissionService.requireCancel(currentUser); 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); User issuer = resolveIssuer(invoice);
String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId()); String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId());
LocalDate today = LocalDate.now(); LocalDate today = LocalDate.now();
@@ -506,15 +438,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
User currentUser = invoicePermissionService.currentUser(); User currentUser = invoicePermissionService.currentUser();
try { try {
invoicePermissionService.requireCorrect(currentUser); 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); User issuer = resolveIssuer(invoice);
String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId()); String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId());
LocalDate today = LocalDate.now(); LocalDate today = LocalDate.now();

View File

@@ -16,11 +16,9 @@ import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.data.renderer.ComponentRenderer; import com.vaadin.flow.data.renderer.ComponentRenderer;
import com.vaadin.flow.data.value.ValueChangeMode; import com.vaadin.flow.data.value.ValueChangeMode;
import com.vaadin.flow.router.HasDynamicTitle; import com.vaadin.flow.router.HasDynamicTitle;
// Route deaktiviert (siehe Klassen-Header). import com.vaadin.flow.router.Route;
// import com.vaadin.flow.router.Route;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar; import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
// import bleibt auskommentiert, solange die @Route oben deaktiviert ist: import de.assecutor.votianlt.pages.base.ui.view.MainLayout;
// import de.assecutor.votianlt.pages.base.ui.view.MainLayout;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import com.vaadin.flow.component.UI; import com.vaadin.flow.component.UI;
import com.vaadin.flow.server.StreamResource; import com.vaadin.flow.server.StreamResource;
@@ -43,8 +41,7 @@ import java.util.Locale;
* Modernisierte Optik: Responsive Karten, Lumo-Theme-Varianten, Status-Badges, * Modernisierte Optik: Responsive Karten, Lumo-Theme-Varianten, Status-Badges,
* Suche und leere Zustandsanzeige. * Suche und leere Zustandsanzeige.
*/ */
// @Route deaktiviert — Rechnungs-UI ist durch DATEV-Export ersetzt. Reaktivierung: @Route(value = "my-invoices", layout = MainLayout.class)
// @Route(value = "my-invoices", layout = MainLayout.class)
@RolesAllowed("USER") @RolesAllowed("USER")
public class MyInvoicesView extends Main implements HasDynamicTitle { public class MyInvoicesView extends Main implements HasDynamicTitle {

View File

@@ -50,6 +50,7 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
private final SecurityService securityService; private final SecurityService securityService;
private final ClientConnectionService clientConnectionService; private final ClientConnectionService clientConnectionService;
private final MessagingPublisher messagingPublisher; private final MessagingPublisher messagingPublisher;
private final CustomerInvoiceRepository customerInvoiceRepository;
private final Grid<Job> grid = new Grid<>(Job.class, false); private final Grid<Job> grid = new Grid<>(Job.class, false);
@Autowired @Autowired
@@ -61,6 +62,7 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
this.securityService = securityService; this.securityService = securityService;
this.clientConnectionService = clientConnectionService; this.clientConnectionService = clientConnectionService;
this.messagingPublisher = messagingPublisher; this.messagingPublisher = messagingPublisher;
this.customerInvoiceRepository = customerInvoiceRepository;
setSizeFull(); setSizeFull();
setPadding(true); setPadding(true);
setSpacing(true); setSpacing(true);
@@ -140,10 +142,38 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
return new com.vaadin.flow.component.html.Span(); return new com.vaadin.flow.component.html.Span();
}).setHeader("").setAutoWidth(true).setFlexGrow(0); }).setHeader("").setAutoWidth(true).setFlexGrow(0);
// Rechnungs-Aktionen entfernt: Das System erstellt/verwaltet keine Rechnungen // Invoice column - only show for completed jobs
// mehr aktiv aus der Jobs-Übersicht heraus. Bereits vorhandene Rechnungs-PDFs grid.addComponentColumn(job -> {
// (Bestandsdaten) bleiben über den DATEV-Export bzw. die Backend-Repositories if (job.getStatus() == JobStatus.COMPLETED) {
// zugänglich; ein dedizierter UI-Button im Jobs-Grid ist dafür nicht mehr nötig. if (hasInvoice(job)) {
Button invoiceBtn = new Button(new Icon(VaadinIcon.FILE_TEXT_O));
invoiceBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
invoiceBtn.setTooltipText(getTranslation("jobs.tooltip.showinvoice"));
invoiceBtn.addClickListener(e -> {
e.getSource().getElement().getNode();
customerInvoiceRepository.findById(job.getInvoiceId()).ifPresentOrElse(invoice -> {
if (invoice.getPdfData() != null) {
CreateInvoiceView.showSavedInvoiceDialog(invoice.getPdfData(),
invoice.getInvoiceNumber(), this);
} else {
getUI().ifPresent(ui -> ui.navigate("create_invoice/" + job.getId().toHexString()));
}
}, () -> getUI().ifPresent(ui -> ui.navigate("create_invoice/" + job.getId().toHexString())));
});
return invoiceBtn;
}
Button createInvoiceBtn = new Button(new Icon(VaadinIcon.DOLLAR));
createInvoiceBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SUCCESS);
createInvoiceBtn.setTooltipText(getTranslation("jobs.tooltip.createinvoice"));
createInvoiceBtn.addClickListener(e -> {
e.getSource().getElement().getNode();
getUI().ifPresent(ui -> ui.navigate("create_invoice/" + job.getId().toHexString()));
});
return createInvoiceBtn;
}
return new com.vaadin.flow.component.html.Span();
}).setHeader("").setWidth("60px").setFlexGrow(0);
// Delete column (last column, right side) // Delete column (last column, right side)
grid.addComponentColumn(job -> { grid.addComponentColumn(job -> {

View File

@@ -1,19 +0,0 @@
package de.assecutor.votianlt.repository;
import de.assecutor.votianlt.model.invoices.InvoiceApprovalRequest;
import de.assecutor.votianlt.model.invoices.InvoiceApprovalStatus;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface InvoiceApprovalRequestRepository extends MongoRepository<InvoiceApprovalRequest, String> {
List<InvoiceApprovalRequest> findByStatus(InvoiceApprovalStatus status);
List<InvoiceApprovalRequest> findByTargetInvoiceId(String targetInvoiceId);
List<InvoiceApprovalRequest> findByRequestedByUserIdAndStatus(String requestedByUserId,
InvoiceApprovalStatus status);
}

View File

@@ -1,15 +0,0 @@
package de.assecutor.votianlt.repository;
import de.assecutor.votianlt.model.invoices.UserSigningCredentials;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserSigningCredentialsRepository extends MongoRepository<UserSigningCredentials, String> {
Optional<UserSigningCredentials> findByUserId(String userId);
void deleteByUserId(String userId);
}

View File

@@ -1,230 +0,0 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.Charset;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
/**
* Erzeugt einen DATEV-kompatiblen Buchungsstapel (Format „DTVF"/„EXTF",
* Version 7) als CSV mit den festgeschriebenen Rechnungen eines Nutzers
* im gewählten Zeitraum. Die Datei lässt sich von DATEV Unternehmen
* Online sowie den meisten DATEV-importfähigen Drittprogrammen
* (z.B. sevDesk, lexoffice, GnuCash) als Buchungsstapel einlesen.
*
* <p>Inhaltliche Defaults — bewusst konservativ und für SKR03 ausgelegt;
* der Mandant kann sie im Abgleich mit dem Steuerberater später anpassen:
* <ul>
* <li>Sammeldebitor 10000 (Forderungen aus Lieferungen und Leistungen)</li>
* <li>Erlöskonto 8400 für 19 % USt, 8300 für 7 %, 8125 für innergemeinschaftliche
* Lieferungen / Reverse-Charge / sonstige steuerfreie Erlöse (rate = 0)</li>
* <li>Stornorechnungen werden mit umgekehrtem Soll/Haben-Kennzeichen gebucht.</li>
* </ul>
*
* <p>Formatdetails:
* <ul>
* <li>Zeichensatz: Windows-1252 (DATEV-Vorgabe).</li>
* <li>Feldtrenner: Semikolon, Texte in Anführungszeichen.</li>
* <li>Beträge mit Komma als Dezimaltrenner, ohne Tausender-Trenner.</li>
* <li>Zeilenumbruch: CRLF.</li>
* </ul>
*/
@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<CustomerInvoice> 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_<userId>_<von>_<bis>.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("\"", "\"\"") + "\"";
}
}

View File

@@ -1,472 +0,0 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.config.EInvoiceProperties;
import de.assecutor.votianlt.model.invoices.UserSigningCredentials;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
import de.assecutor.votianlt.model.invoices.EInvoiceFormat;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.io.IOUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureOptions;
import org.bouncycastle.cert.jcajce.JcaCertStore;
import org.bouncycastle.cms.CMSException;
import org.bouncycastle.cms.CMSProcessableByteArray;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.cms.CMSSignedDataGenerator;
import org.bouncycastle.cms.CMSTypedData;
import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
import org.bouncycastle.util.Store;
import org.mustangproject.Contact;
import org.mustangproject.Invoice;
import org.mustangproject.Item;
import org.mustangproject.Product;
import org.mustangproject.TradeParty;
import org.mustangproject.ZUGFeRD.PDFAConformanceLevel;
import org.mustangproject.ZUGFeRD.Profiles;
import org.mustangproject.ZUGFeRD.ZUGFeRDExporterFromA1;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.Provider;
import java.security.Security;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
/**
* Erzeugt ZUGFeRD/Factur-X-konforme E-Rechnungen mit Mustangproject und
* signiert das Ergebnis optional via PAdES (iText + BouncyCastle).
*
* Aufrufer übergeben ein bereits gerendertes PDF (z.B. aus
* {@link CustomerInvoiceService}) und die Rechnungsdaten. Der Service
* konvertiert das PDF in ein PDF/A-3 mit eingebettetem ZUGFeRD-XML und
* hängt anschließend sofern konfiguriert eine PAdES-Signatur an.
*
* Schlägt einer der Schritte fehl, wird das Original-PDF unverändert zurückgegeben
* und der Fehler protokolliert. So bleibt die Rechnungserstellung als Ganzes
* funktionsfähig, falls die E-Rechnung-Konfiguration unvollständig ist.
*/
@Service
public class EInvoiceService {
private static final Logger log = LoggerFactory.getLogger(EInvoiceService.class);
private static final String DEFAULT_UNIT_CODE = "C62"; // ISO 6E - "one"
private static final String DEFAULT_COUNTRY = "DE";
private final EInvoiceProperties properties;
private final SigningCredentialsService signingCredentialsService;
static {
ensureBouncyCastleProvider();
}
public EInvoiceService(EInvoiceProperties properties, SigningCredentialsService signingCredentialsService) {
this.properties = properties;
this.signingCredentialsService = signingCredentialsService;
}
public boolean isEInvoiceEnabledGlobally() {
return properties.isEnabled();
}
public boolean isSigningConfigured() {
return properties.getSigning().isFullyConfigured();
}
public boolean isUserSigningAvailable(String userId) {
return signingCredentialsService.loadActive(userId).isPresent();
}
/**
* Reichert ein gerendertes PDF mit ZUGFeRD-XML an und/oder signiert es.
* Markiert das Ergebnis im übergebenen {@link CustomerInvoice} (Format/Signatur),
* persistiert die Rechnung selbst aber nicht.
*
* @param withZugferd ZUGFeRD/Factur-X-XML einbetten (graceful: bei Fehler Roh-PDF)
* @param withSignature PAdES-Signatur erzeugen (strikt: wirft
* {@link InvoiceLifecycleException}, wenn Zertifikat fehlt
* oder nicht entschlüsselt werden kann)
*/
public byte[] enhanceAndSign(byte[] basePdf, CustomerInvoice invoice, boolean withZugferd,
boolean withSignature) {
byte[] result = basePdf;
if (withZugferd && properties.isEnabled()) {
try {
result = embedZugferdXml(result, invoice);
invoice.setEInvoiceFormat(resolveFormat(properties.getProfile()));
} catch (Exception ex) {
log.warn("ZUGFeRD-Anreicherung fehlgeschlagen, fahre mit Roh-PDF fort: {}", ex.getMessage(), ex);
invoice.setEInvoiceFormat(EInvoiceFormat.NONE);
}
} else {
invoice.setEInvoiceFormat(EInvoiceFormat.NONE);
}
if (withSignature) {
result = signOrFail(result, invoice);
}
return result;
}
/**
* Convenience-Variante: ZUGFeRD und Signatur. Bestand für Aufrufer, die nicht
* die feingranulare Variante nutzen wollen.
*/
public byte[] enhanceAndSign(byte[] basePdf, CustomerInvoice invoice) {
return enhanceAndSign(basePdf, invoice, true, true);
}
/**
* Signiert das PDF — bevorzugt mit dem nutzerseitigen Keystore, ansonsten mit dem
* systemweit konfigurierten. Wirft eine {@link InvoiceLifecycleException}, wenn:
* <ul>
* <li>der Nutzer Credentials hinterlegt hat, diese aber nicht entschlüsselt
* oder geladen werden können (z.B. fehlender/falscher Master-Key,
* beschädigter Keystore),</li>
* <li>der Nutzer keine Credentials hinterlegt hat und auch kein System-Keystore
* konfiguriert ist,</li>
* <li>die eigentliche Signatur-Operation fehlschlägt.</li>
* </ul>
* 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<UserSigningCredentials> 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<SigningCredentialsService.LoadedCredentials> 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<X509Certificate> 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<CustomerInvoiceItem> 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;
}
}

View File

@@ -1,219 +0,0 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceApprovalAction;
import de.assecutor.votianlt.model.invoices.InvoiceApprovalRequest;
import de.assecutor.votianlt.model.invoices.InvoiceApprovalStatus;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import de.assecutor.votianlt.repository.InvoiceApprovalRequestRepository;
import de.assecutor.votianlt.repository.UserRepository;
import org.bson.types.ObjectId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* Freigabe-Workflow für kritische Rechnungsaktionen gemäß R-42.
*
* Nutzer ohne Approver-Rolle, deren Profil
* {@link User#isRequireApprovalForCriticalInvoiceActions()} aktiv hat, erzeugen über diesen
* Service eine Freigabe-Anfrage. Erst nach Freigabe durch einen Approver wird der
* tatsächliche Storno- bzw. Berichtigungsbeleg über den
* {@link InvoiceLifecycleService} erstellt.
*/
@Service
public class InvoiceApprovalService {
private static final Logger log = LoggerFactory.getLogger(InvoiceApprovalService.class);
private final InvoiceApprovalRequestRepository requestRepository;
private final CustomerInvoiceRepository invoiceRepository;
private final InvoiceLifecycleService lifecycleService;
private final InvoicePermissionService permissionService;
private final CustomerInvoiceService customerInvoiceService;
private final UserInvoiceDataService userInvoiceDataService;
private final UserRepository userRepository;
public InvoiceApprovalService(InvoiceApprovalRequestRepository requestRepository,
CustomerInvoiceRepository invoiceRepository, InvoiceLifecycleService lifecycleService,
InvoicePermissionService permissionService, CustomerInvoiceService customerInvoiceService,
UserInvoiceDataService userInvoiceDataService, UserRepository userRepository) {
this.requestRepository = requestRepository;
this.invoiceRepository = invoiceRepository;
this.lifecycleService = lifecycleService;
this.permissionService = permissionService;
this.customerInvoiceService = customerInvoiceService;
this.userInvoiceDataService = userInvoiceDataService;
this.userRepository = userRepository;
}
public InvoiceApprovalRequest requestCancellation(String invoiceId, String reason) {
return submitRequest(invoiceId, InvoiceApprovalAction.CANCEL_INVOICE, reason, null);
}
public InvoiceApprovalRequest requestCorrection(String invoiceId, String correctedFields, String reason) {
return submitRequest(invoiceId, InvoiceApprovalAction.CORRECT_INVOICE, reason, correctedFields);
}
private InvoiceApprovalRequest submitRequest(String invoiceId, InvoiceApprovalAction action, String reason,
String correctedFields) {
if (reason == null || reason.isBlank()) {
throw new InvoiceLifecycleException("Bitte einen Grund für die Anfrage angeben.");
}
CustomerInvoice invoice = invoiceRepository.findById(invoiceId)
.orElseThrow(() -> new IllegalStateException("Rechnung nicht gefunden: " + invoiceId));
User requester = permissionService.currentUser();
InvoiceApprovalRequest request = new InvoiceApprovalRequest();
request.setAction(action);
request.setStatus(InvoiceApprovalStatus.PENDING);
request.setTargetInvoiceId(invoice.getId());
request.setTargetInvoiceNumber(invoice.getInvoiceNumber());
request.setRequestedByUserId(requester.getId() != null ? requester.getId().toHexString() : null);
request.setRequestedByDisplayName(displayName(requester));
request.setRequestedAt(LocalDateTime.now());
request.setReason(reason);
request.setCorrectedFields(correctedFields);
InvoiceApprovalRequest saved = requestRepository.save(request);
log.info("Freigabe-Anfrage {} angelegt für Rechnung {} ({}).", saved.getId(), invoice.getInvoiceNumber(),
action);
return saved;
}
public InvoiceApprovalRequest approve(String requestId, String reviewerComment) {
InvoiceApprovalRequest request = requireRequest(requestId);
User reviewer = permissionService.currentUser();
permissionService.requireApprove(reviewer);
if (request.getStatus() != InvoiceApprovalStatus.PENDING) {
throw new InvoiceLifecycleException("Diese Anfrage wurde bereits bearbeitet.");
}
CustomerInvoice originalInvoice = invoiceRepository.findById(request.getTargetInvoiceId()).orElseThrow(
() -> new IllegalStateException("Rechnung nicht gefunden: " + request.getTargetInvoiceId()));
User issuer = resolveInvoiceIssuer(originalInvoice, reviewer);
CustomerInvoice resulting;
try {
resulting = switch (request.getAction()) {
case CANCEL_INVOICE -> executeCancellation(originalInvoice, issuer, request, reviewer);
case CORRECT_INVOICE -> executeCorrection(originalInvoice, issuer, request, reviewer);
};
} catch (InvoiceLifecycleException ex) {
log.warn("Lifecycle-Verstoß bei Freigabe {}: {}", requestId, ex.getMessage());
throw ex;
} catch (RuntimeException ex) {
log.error("Freigabe-Aktion {} für Anfrage {} fehlgeschlagen: {}", request.getAction(), requestId,
ex.getMessage(), ex);
throw ex;
} catch (Exception ex) {
log.error("Freigabe-Aktion {} für Anfrage {} fehlgeschlagen: {}", request.getAction(), requestId,
ex.getMessage(), ex);
throw new InvoiceLifecycleException("Freigabe konnte nicht ausgeführt werden: " + ex.getMessage(), ex);
}
request.setStatus(InvoiceApprovalStatus.APPROVED);
request.setReviewedByUserId(reviewer.getId() != null ? reviewer.getId().toHexString() : null);
request.setReviewedByDisplayName(displayName(reviewer));
request.setReviewedAt(LocalDateTime.now());
request.setReviewerComment(reviewerComment);
request.setResultingInvoiceId(resulting.getId());
request.setResultingInvoiceNumber(resulting.getInvoiceNumber());
return requestRepository.save(request);
}
public InvoiceApprovalRequest reject(String requestId, String reviewerComment) {
InvoiceApprovalRequest request = requireRequest(requestId);
User reviewer = permissionService.currentUser();
permissionService.requireApprove(reviewer);
if (request.getStatus() != InvoiceApprovalStatus.PENDING) {
throw new InvoiceLifecycleException("Diese Anfrage wurde bereits bearbeitet.");
}
request.setStatus(InvoiceApprovalStatus.REJECTED);
request.setReviewedByUserId(reviewer.getId() != null ? reviewer.getId().toHexString() : null);
request.setReviewedByDisplayName(displayName(reviewer));
request.setReviewedAt(LocalDateTime.now());
request.setReviewerComment(reviewerComment);
return requestRepository.save(request);
}
public List<InvoiceApprovalRequest> findPending() {
return requestRepository.findByStatus(InvoiceApprovalStatus.PENDING);
}
public List<InvoiceApprovalRequest> 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 : "";
}
}

View File

@@ -2,7 +2,6 @@ package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.invoices.CustomerInvoice; 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.InvoiceAuditAction;
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry; import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
import de.assecutor.votianlt.model.invoices.InvoiceStatus; import de.assecutor.votianlt.model.invoices.InvoiceStatus;
@@ -43,16 +42,14 @@ public class InvoiceLifecycleService {
private final CustomerInvoiceRepository invoiceRepository; private final CustomerInvoiceRepository invoiceRepository;
private final SecurityService securityService; private final SecurityService securityService;
private final EInvoiceService eInvoiceService;
private final InvoiceComplianceValidator complianceValidator; private final InvoiceComplianceValidator complianceValidator;
private final InvoiceNumberAuditService numberAuditService; private final InvoiceNumberAuditService numberAuditService;
public InvoiceLifecycleService(CustomerInvoiceRepository invoiceRepository, SecurityService securityService, public InvoiceLifecycleService(CustomerInvoiceRepository invoiceRepository, SecurityService securityService,
EInvoiceService eInvoiceService, InvoiceComplianceValidator complianceValidator, InvoiceComplianceValidator complianceValidator,
InvoiceNumberAuditService numberAuditService) { InvoiceNumberAuditService numberAuditService) {
this.invoiceRepository = invoiceRepository; this.invoiceRepository = invoiceRepository;
this.securityService = securityService; this.securityService = securityService;
this.eInvoiceService = eInvoiceService;
this.complianceValidator = complianceValidator; this.complianceValidator = complianceValidator;
this.numberAuditService = numberAuditService; this.numberAuditService = numberAuditService;
} }
@@ -212,7 +209,7 @@ public class InvoiceLifecycleService {
cancellation.setTotalAmount(negate(original.getTotalAmount())); cancellation.setTotalAmount(negate(original.getTotalAmount()));
cancellation.setDescription("Stornorechnung zu Rechnung " + original.getInvoiceNumber()); cancellation.setDescription("Stornorechnung zu Rechnung " + original.getInvoiceNumber());
cancellation.setPdfData(applyEInvoiceIfApplicable(pdfData, cancellation, original)); cancellation.setPdfData(pdfData);
cancellation.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason)); cancellation.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason); InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason);
issuedEntry.setResultingInvoiceNumber(cancellationNumber); issuedEntry.setResultingInvoiceNumber(cancellationNumber);
@@ -297,7 +294,7 @@ public class InvoiceLifecycleService {
correction.setDescription( correction.setDescription(
correctedFields == null || correctedFields.isBlank() ? descriptionPrefix correctedFields == null || correctedFields.isBlank() ? descriptionPrefix
: descriptionPrefix + "" + correctedFields); : descriptionPrefix + "" + correctedFields);
correction.setPdfData(applyEInvoiceIfApplicable(pdfData, correction, original)); correction.setPdfData(pdfData);
correction.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason)); correction.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason); InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason);
issuedEntry.setResultingInvoiceNumber(correctionNumber); issuedEntry.setResultingInvoiceNumber(correctionNumber);
@@ -536,37 +533,4 @@ public class InvoiceLifecycleService {
private BigDecimal negate(BigDecimal value) { private BigDecimal negate(BigDecimal value) {
return value != null ? value.negate() : null; 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;
}
}
} }

View File

@@ -50,24 +50,6 @@ public class InvoicePermissionService {
return hasAnyInvoiceRole(user, InvoiceRoles.ACCOUNTANT, InvoiceRoles.APPROVER) || isUnscoped(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) { public void requireCreate(User user) {
if (!canCreateOrIssue(user)) { if (!canCreateOrIssue(user)) {
throw new InvoiceLifecycleException( throw new InvoiceLifecycleException(
@@ -102,12 +84,6 @@ public class InvoicePermissionService {
} }
} }
public void requireApprove(User user) {
if (!canApproveRequests(user)) {
throw new InvoiceLifecycleException("Sie haben keine Freigabe-Berechtigung.");
}
}
/** /**
* Convenience: prüft, ob der Nutzer Eigentümer einer Rechnung ist (oder Admin). * Convenience: prüft, ob der Nutzer Eigentümer einer Rechnung ist (oder Admin).
* Wird genutzt, um Cross-Tenant-Zugriffe zu verhindern. * Wird genutzt, um Cross-Tenant-Zugriffe zu verhindern.

View File

@@ -1,237 +0,0 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.config.EInvoiceProperties;
import de.assecutor.votianlt.model.invoices.UserSigningCredentials;
import de.assecutor.votianlt.repository.UserSigningCredentialsRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.Optional;
/**
* Verwaltet pro Nutzer hinterlegte PKCS#12-Signatur-Credentials.
*
* Beim Speichern wird der Keystore validiert (Alias vorhanden, privater Schlüssel
* extrahierbar, Zertifikat noch gültig) und anschließend mitsamt Passwort über
* AES-256-GCM unter dem konfigurierten Master-Key verschlüsselt.
*
* Beim Laden wird der Keystore zur Laufzeit entschlüsselt und in eine
* {@link LoadedCredentials}-Instanz verpackt; die Klar-Werte verlassen nie diese
* Service-Schicht.
*/
@Service
public class SigningCredentialsService {
private static final Logger log = LoggerFactory.getLogger(SigningCredentialsService.class);
private final UserSigningCredentialsRepository repository;
private final EInvoiceProperties properties;
public SigningCredentialsService(UserSigningCredentialsRepository repository, EInvoiceProperties properties) {
this.repository = repository;
this.properties = properties;
}
public Optional<UserSigningCredentials> 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<LoadedCredentials> 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<LoadedCredentials> 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;
}
}
}

View File

@@ -95,34 +95,4 @@ spring.ai.openai.read-timeout=120s
spring.ai.mcp.server.enabled=true spring.ai.mcp.server.enabled=true
spring.ai.mcp.server.name=votianlt-mcp-server spring.ai.mcp.server.name=votianlt-mcp-server
spring.ai.mcp.server.version=1.0.0 spring.ai.mcp.server.version=1.0.0
spring.ai.mcp.server.sse-message-endpoint=/mcp/message 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:}

View File

@@ -9,17 +9,6 @@ nav.customers=Adressbuch
nav.appusers=App-Nutzer nav.appusers=App-Nutzer
nav.statistics=Statistiken nav.statistics=Statistiken
nav.invoices=Rechnungen 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.messages=Nachrichten
nav.profile=Mein Profil nav.profile=Mein Profil
nav.myinvoices=Rechnungen nav.myinvoices=Rechnungen
@@ -58,36 +47,6 @@ profile.settings.digitalprocess.info=Aufträge werden digital über die App abge
profile.settings.locateappuser=App-Nutzer orten profile.settings.locateappuser=App-Nutzer orten
profile.settings.locateappuser.info=Standort der App-Nutzer wird regelmäßig übertragen profile.settings.locateappuser.info=Standort der App-Nutzer wird regelmäßig übertragen
profile.settings.vatrate=Umsatzsteuer 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.account=Konto
profile.security=Sicherheit profile.security=Sicherheit
profile.security.twofactor=Zwei-Faktor-Authentifizierung profile.security.twofactor=Zwei-Faktor-Authentifizierung
@@ -798,8 +757,6 @@ invoices.audit.action.replaced=Ersetzt durch neue Rechnung
invoices.audit.action.deleted_draft=Entwurf gelöscht invoices.audit.action.deleted_draft=Entwurf gelöscht
invoices.audit.action.payment_recorded=Zahlung erfasst invoices.audit.action.payment_recorded=Zahlung erfasst
invoices.audit.resulting=Erzeugter Folgebeleg: {0} invoices.audit.resulting=Erzeugter Folgebeleg: {0}
invoices.column.payment=Zahlung
invoices.column.outstanding=Offen
invoices.payment.unpaid=Offen invoices.payment.unpaid=Offen
invoices.payment.partially_paid=Teilzahlung invoices.payment.partially_paid=Teilzahlung
invoices.payment.paid=Bezahlt invoices.payment.paid=Bezahlt
@@ -815,28 +772,6 @@ invoices.payment.reference=Zahlungsreferenz (z.B. Kontoauszug, Buchungs-Nr.)
invoices.payment.reason=Anmerkung invoices.payment.reason=Anmerkung
invoices.payment.confirm=Zahlung erfassen invoices.payment.confirm=Zahlung erfassen
invoices.notification.payment=Zahlung erfasst. 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 # My Invoices
myinvoices.title=Rechnungen myinvoices.title=Rechnungen

View File

@@ -9,17 +9,6 @@ nav.customers=Address Book
nav.appusers=App Users nav.appusers=App Users
nav.statistics=Statistics nav.statistics=Statistics
nav.invoices=Invoices 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.messages=Messages
nav.profile=My Profile nav.profile=My Profile
nav.myinvoices=Invoices nav.myinvoices=Invoices
@@ -58,36 +47,6 @@ profile.settings.digitalprocess.info=Jobs are processed digitally via the app
profile.settings.locateappuser=Locate App Users profile.settings.locateappuser=Locate App Users
profile.settings.locateappuser.info=App user location is transmitted regularly profile.settings.locateappuser.info=App user location is transmitted regularly
profile.settings.vatrate=VAT rate 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.account=Account
profile.security=Security profile.security=Security
profile.security.twofactor=Two-Factor Authentication profile.security.twofactor=Two-Factor Authentication
@@ -798,8 +757,6 @@ invoices.audit.action.replaced=Replaced by new invoice
invoices.audit.action.deleted_draft=Draft deleted invoices.audit.action.deleted_draft=Draft deleted
invoices.audit.action.payment_recorded=Payment recorded invoices.audit.action.payment_recorded=Payment recorded
invoices.audit.resulting=Resulting document: {0} invoices.audit.resulting=Resulting document: {0}
invoices.column.payment=Payment
invoices.column.outstanding=Outstanding
invoices.payment.unpaid=Open invoices.payment.unpaid=Open
invoices.payment.partially_paid=Partially paid invoices.payment.partially_paid=Partially paid
invoices.payment.paid=Paid invoices.payment.paid=Paid
@@ -815,28 +772,6 @@ invoices.payment.reference=Payment reference (e.g. statement, booking ID)
invoices.payment.reason=Note invoices.payment.reason=Note
invoices.payment.confirm=Record payment invoices.payment.confirm=Record payment
invoices.notification.payment=Payment recorded. 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 # My Invoices
myinvoices.title=Invoices myinvoices.title=Invoices

View File

@@ -1,173 +0,0 @@
package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.nio.charset.Charset;
import java.time.LocalDate;
import java.util.List;
@ExtendWith(MockitoExtension.class)
class DatevExportServiceTest {
private static final Charset DATEV_CHARSET = Charset.forName("Windows-1252");
private static final String USER_ID = "user-1";
@Mock
private CustomerInvoiceRepository invoiceRepository;
private DatevExportService service;
@BeforeEach
void setUp() {
service = new DatevExportService(invoiceRepository);
}
@Test
void exportContainsHeaderColumnsAndOneRowPerInvoice() {
when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of(
invoice("R-2026-001", LocalDate.of(2026, 4, 5), new BigDecimal("119.00"),
new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, "Kunde A"),
invoice("R-2026-002", LocalDate.of(2026, 4, 12), new BigDecimal("214.00"),
new BigDecimal("0.07"), InvoiceType.INVOICE, InvoiceStatus.SENT, "Kunde B")));
byte[] csv = service.export(USER_ID, LocalDate.of(2026, 4, 1), LocalDate.of(2026, 4, 30));
String content = new String(csv, DATEV_CHARSET);
String[] lines = content.split("\\r\\n");
assertThat(lines[0]).startsWith("\"EXTF\";700;21;\"Buchungsstapel\"");
assertThat(lines[0]).contains("20260401;20260430");
assertThat(lines[1]).startsWith("Umsatz (ohne Soll/Haben-Kz);Soll/Haben-Kennzeichen;WKZ Umsatz;Konto;");
assertThat(lines).hasSize(4); // header + col-header + 2 invoices
assertThat(lines[2]).startsWith("119,00;S;\"EUR\";10000;8400;;0504;\"R-2026-001\";");
assertThat(lines[3]).startsWith("214,00;S;\"EUR\";10000;8300;;1204;\"R-2026-002\";");
}
@Test
void cancellationFlipsSollHabenAndUsesAbsoluteAmount() {
when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of(
invoice("S-2026-007", LocalDate.of(2026, 4, 20), new BigDecimal("-119.00"),
new BigDecimal("0.19"), InvoiceType.CANCELLATION, InvoiceStatus.ISSUED, "Kunde A")));
String content = new String(service.export(USER_ID, LocalDate.of(2026, 4, 1),
LocalDate.of(2026, 4, 30)), DATEV_CHARSET);
String[] lines = content.split("\\r\\n");
assertThat(lines[2]).startsWith("119,00;H;\"EUR\";10000;8400;;");
assertThat(lines[2]).contains("\"Storno: Kunde A\"");
}
@Test
void zeroVatMapsToReverseChargeAccount() {
when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of(
invoice("R-2026-009", LocalDate.of(2026, 4, 7), new BigDecimal("500.00"),
BigDecimal.ZERO, InvoiceType.INVOICE, InvoiceStatus.ISSUED, "EU-Kunde")));
String content = new String(service.export(USER_ID, LocalDate.of(2026, 4, 1),
LocalDate.of(2026, 4, 30)), DATEV_CHARSET);
String[] lines = content.split("\\r\\n");
assertThat(lines[2]).contains(";8125;");
}
@Test
void draftsAreExcluded() {
when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of(
invoice("R-DRAFT", LocalDate.of(2026, 4, 5), new BigDecimal("100.00"),
new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.DRAFT, "X"),
invoice("R-ISSUED", LocalDate.of(2026, 4, 6), new BigDecimal("100.00"),
new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, "Y")));
String content = new String(service.export(USER_ID, LocalDate.of(2026, 4, 1),
LocalDate.of(2026, 4, 30)), DATEV_CHARSET);
String[] lines = content.split("\\r\\n");
assertThat(lines).hasSize(3);
assertThat(lines[2]).contains("\"R-ISSUED\"");
}
@Test
void invoicesOutsidePeriodAreIgnored() {
when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of(
invoice("R-2026-MAR", LocalDate.of(2026, 3, 31), new BigDecimal("100.00"),
new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, "Y"),
invoice("R-2026-IN", LocalDate.of(2026, 4, 1), new BigDecimal("100.00"),
new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, "Y"),
invoice("R-2026-OUT", LocalDate.of(2026, 5, 1), new BigDecimal("100.00"),
new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, "Y")));
String content = new String(service.export(USER_ID, LocalDate.of(2026, 4, 1),
LocalDate.of(2026, 4, 30)), DATEV_CHARSET);
String[] lines = content.split("\\r\\n");
assertThat(lines).hasSize(3);
assertThat(lines[2]).contains("\"R-2026-IN\"");
}
@Test
void emptyResultStillProducesValidHeader() {
when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of());
String content = new String(service.export(USER_ID, LocalDate.of(2026, 1, 1),
LocalDate.of(2026, 1, 31)), DATEV_CHARSET);
String[] lines = content.split("\\r\\n");
assertThat(lines[0]).startsWith("\"EXTF\";700;21;\"Buchungsstapel\"");
assertThat(lines).hasSize(2);
}
@Test
void quotesInsideRecipientAreEscaped() {
when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of(
invoice("R-2026-QT", LocalDate.of(2026, 4, 15), new BigDecimal("119.00"),
new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED,
"Acme \"Premium\" GmbH")));
String content = new String(service.export(USER_ID, LocalDate.of(2026, 4, 1),
LocalDate.of(2026, 4, 30)), DATEV_CHARSET);
// DATEV escaped Anführungszeichen durch Verdoppeln.
assertThat(content).contains("\"Rechnung an Acme \"\"Premium\"\" GmbH\"");
}
@Test
void rejectsInvalidInputs() {
assertThatThrownBy(() -> service.export(null, LocalDate.now(), LocalDate.now()))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> service.export(USER_ID, null, LocalDate.now()))
.isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> service.export(USER_ID, LocalDate.of(2026, 5, 1), LocalDate.of(2026, 4, 1)))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void filenameContainsDateRange() {
String filename = service.suggestFilename(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 3, 31));
assertThat(filename).isEqualTo("EXTF_Buchungsstapel_20260101_20260331.csv");
}
private CustomerInvoice invoice(String number, LocalDate date, BigDecimal total, BigDecimal vatRate,
InvoiceType type, InvoiceStatus status, String recipient) {
CustomerInvoice invoice = new CustomerInvoice();
invoice.setInvoiceNumber(number);
invoice.setInvoiceDate(date);
invoice.setTotalAmount(total);
invoice.setVatRate(vatRate);
invoice.setType(type);
invoice.setStatus(status);
invoice.setRecipientName(recipient);
invoice.setUserId(USER_ID);
return invoice;
}
}

View File

@@ -1,271 +0,0 @@
package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import de.assecutor.votianlt.config.EInvoiceProperties;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
import de.assecutor.votianlt.model.invoices.UserSigningCredentials;
import de.assecutor.votianlt.repository.UserSigningCredentialsRepository;
import eu.europa.esig.dss.diagnostic.DiagnosticData;
import eu.europa.esig.dss.diagnostic.SignatureWrapper;
import eu.europa.esig.dss.enumerations.SignatureLevel;
import eu.europa.esig.dss.model.DSSDocument;
import eu.europa.esig.dss.model.InMemoryDocument;
import eu.europa.esig.dss.model.x509.CertificateToken;
import eu.europa.esig.dss.spi.validation.CommonCertificateVerifier;
import eu.europa.esig.dss.spi.x509.CommonTrustedCertificateSource;
import eu.europa.esig.dss.validation.SignedDocumentValidator;
import eu.europa.esig.dss.validation.reports.Reports;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.Security;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
/**
* Validiert die vom Service erzeugten Signaturen gegen die EU DSS
* (Digital Signature Service) Bibliothek. Während {@link EInvoiceServiceTest}
* nur die strukturelle Korrektheit des Signature-Dictionarys prüft, parst
* DSS hier die Signatur kryptographisch nach und liefert ein Diagnostic-Data-
* Modell.
*
* <p>Geprüft wird pro Signaturpfad (System- und Nutzer-Keystore):
* <ul>
* <li>genau eine Signatur ist im Dokument enthalten,</li>
* <li>die Signatur ist kryptographisch intakt (Hash &amp; Signaturwert),</li>
* <li>das Format ist PAdES-Baseline (mehr ist mit den aktuellen
* Service-Settings nicht zu erwarten — kein TSA, kein DSS-Dictionary),</li>
* <li>der Signer-DN entspricht dem im Test erzeugten Zertifikat.</li>
* </ul>
*
* <p>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<String> 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) {
}
}

View File

@@ -1,331 +0,0 @@
package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import de.assecutor.votianlt.config.EInvoiceProperties;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
import de.assecutor.votianlt.model.invoices.EInvoiceFormat;
import de.assecutor.votianlt.model.invoices.UserSigningCredentials;
import de.assecutor.votianlt.repository.UserSigningCredentialsRepository;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.Security;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.List;
import java.util.Optional;
/**
* Integrationstest des Signaturpfads — deckt System-Keystore, Nutzer-Keystore,
* Vorrang-Logik und alle wichtigen Fehlerpfade ab. Verwendet bewusst keinen
* vollen Spring-Boot-Kontext: das vermeidet die Mongo-Abhängigkeit der echten
* Repository-Klasse, lässt aber die echten Service-Klassen (Verschlüsselung,
* PDFBox/BouncyCastle-Signatur) durchlaufen.
*
* Das Zertifikat wird zur Testzeit per BouncyCastle erzeugt — der Test ist
* damit reproduzierbar und braucht keine Datei-Fixtures.
*/
@ExtendWith(MockitoExtension.class)
class EInvoiceServiceTest {
private static final String USER_ID = "user-1";
private static final String SYSTEM_ALIAS = "system-signer";
private static final String USER_ALIAS = "user-signer";
private static final String SYSTEM_PASSWORD = "system-pass";
private static final String USER_PASSWORD = "user-pass";
private static final String MASTER_KEY = "0123456789abcdef0123456789abcdef";
static {
if (Security.getProvider("BC") == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
@Mock
private UserSigningCredentialsRepository repository;
@TempDir
Path tempDir;
private EInvoiceProperties properties;
private SigningCredentialsService signingCredentialsService;
private EInvoiceService eInvoiceService;
@BeforeEach
void setUp() {
properties = new EInvoiceProperties();
properties.setEnabled(true);
properties.setProfile("EN16931");
properties.getSigning().setEnabled(false);
properties.getSigning().setMasterKey(MASTER_KEY);
signingCredentialsService = new SigningCredentialsService(repository, properties);
eInvoiceService = new EInvoiceService(properties, signingCredentialsService);
}
@Test
void signsPdfWithSystemKeystore() throws Exception {
byte[] keystoreBytes = generateKeystore(SYSTEM_ALIAS, SYSTEM_PASSWORD);
Path keystoreFile = tempDir.resolve("system.p12");
Files.write(keystoreFile, keystoreBytes);
configureSystemKeystore(keystoreFile, SYSTEM_PASSWORD, SYSTEM_ALIAS);
when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty());
CustomerInvoice invoice = sampleInvoice();
byte[] result = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true);
assertSignedPdf(result);
assertThat(invoice.isSigned()).isTrue();
assertThat(invoice.getSignedAt()).isNotNull();
assertThat(invoice.getSignedBy()).isEqualTo(SYSTEM_ALIAS);
}
@Test
void signsPdfWithUserKeystore() throws Exception {
UserSigningCredentials stored = storeUserKeystore();
when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(stored));
CustomerInvoice invoice = sampleInvoice();
byte[] result = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true);
assertSignedPdf(result);
assertThat(invoice.isSigned()).isTrue();
assertThat(invoice.getSignedBy()).contains("Votianlt Test");
}
@Test
void userKeystoreTakesPrecedenceOverSystemKeystore() throws Exception {
byte[] systemKeystoreBytes = generateKeystore(SYSTEM_ALIAS, SYSTEM_PASSWORD);
Path keystoreFile = tempDir.resolve("system.p12");
Files.write(keystoreFile, systemKeystoreBytes);
configureSystemKeystore(keystoreFile, SYSTEM_PASSWORD, SYSTEM_ALIAS);
UserSigningCredentials stored = storeUserKeystore();
when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(stored));
CustomerInvoice invoice = sampleInvoice();
byte[] result = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true);
assertSignedPdf(result);
assertThat(invoice.getSignedBy())
.as("Bei vorhandenen Nutzer-Credentials darf nicht der System-Alias signieren")
.isNotEqualTo(SYSTEM_ALIAS)
.contains("Votianlt Test");
}
@Test
void failsWhenNoKeystoreAvailable() {
when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty());
CustomerInvoice invoice = sampleInvoice();
assertThatThrownBy(() -> eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true))
.isInstanceOf(InvoiceLifecycleException.class)
.hasMessageContaining("kein Signatur-Zertifikat verfügbar");
assertThat(invoice.isSigned()).isFalse();
}
@Test
void failsWhenUserCredentialsDisabled() {
UserSigningCredentials stored = storeUserKeystore();
stored.setEnabled(false);
when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(stored));
CustomerInvoice invoice = sampleInvoice();
assertThatThrownBy(() -> eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true))
.isInstanceOf(InvoiceLifecycleException.class)
.hasMessageContaining("deaktiviert");
assertThat(invoice.isSigned()).isFalse();
}
@Test
void failsWhenMasterKeyChangedAfterStorage() {
UserSigningCredentials stored = storeUserKeystore();
when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(stored));
// Master-Key wird nach dem Persistieren ausgetauscht — der Keystore lässt sich
// nicht mehr entschlüsseln, der Service muss dem Anwender klar signalisieren,
// dass er das Zertifikat erneut hochladen muss.
properties.getSigning().setMasterKey("ffffffffffffffffffffffffffffffff");
CustomerInvoice invoice = sampleInvoice();
assertThatThrownBy(() -> eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true))
.isInstanceOf(InvoiceLifecycleException.class)
.hasMessageContaining("nicht entschlüsselt");
assertThat(invoice.isSigned()).isFalse();
}
/**
* Ein nicht-PDF/A-konformes Eingabe-PDF (z.B. ohne eingebettete Fonts) lässt
* die Mustang-Anreicherung scheitern. Der Service muss laut Klassen-JavaDoc
* mit dem Roh-PDF fortfahren und die Signatur trotzdem strikt durchführen —
* Format wird auf NONE gesetzt, Signatur darf nicht ausfallen.
*/
@Test
void gracefullyDegradesWhenZugferdEmbeddingFails() throws Exception {
byte[] keystoreBytes = generateKeystore(SYSTEM_ALIAS, SYSTEM_PASSWORD);
Path keystoreFile = tempDir.resolve("system.p12");
Files.write(keystoreFile, keystoreBytes);
configureSystemKeystore(keystoreFile, SYSTEM_PASSWORD, SYSTEM_ALIAS);
when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty());
CustomerInvoice invoice = sampleInvoice();
byte[] result = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, true, true);
assertSignedPdf(result);
assertThat(invoice.getEInvoiceFormat()).isEqualTo(EInvoiceFormat.NONE);
assertThat(invoice.isSigned()).isTrue();
}
@Test
void embedsZugferdXmlIntoValidPdf() throws Exception {
CustomerInvoice invoice = sampleInvoice();
byte[] result = eInvoiceService.embedZugferdXml(buildPdfWithFont(), invoice);
assertThat(result).isNotEmpty();
// Das ZUGFeRD-XML wird als „factur-x.xml" eingebettet — schneller Smoke-Test
// über die UTF-8-Repräsentation des Containers.
String resultAsString = new String(result, java.nio.charset.StandardCharsets.ISO_8859_1);
assertThat(resultAsString).contains("factur-x.xml");
}
private void configureSystemKeystore(Path keystoreFile, String password, String alias) {
EInvoiceProperties.Signing signing = properties.getSigning();
signing.setEnabled(true);
signing.setKeystorePath(keystoreFile.toString());
signing.setKeystorePassword(password);
signing.setKeyAlias(alias);
}
private UserSigningCredentials storeUserKeystore() {
byte[] userKeystore = generateKeystore(USER_ALIAS, USER_PASSWORD);
when(repository.save(any(UserSigningCredentials.class))).thenAnswer(invocation -> invocation.getArgument(0));
return signingCredentialsService.store(USER_ID, userKeystore, USER_PASSWORD, USER_ALIAS);
}
private void assertSignedPdf(byte[] pdfBytes) throws Exception {
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
List<PDSignature> 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;
}
}