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>
<properties>
<revision>0.9.16</revision>
<revision>0.9.17</revision>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
@@ -184,20 +184,6 @@
<version>5.0.5</version>
</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) -->
<dependency>
<groupId>org.springframework.ai</groupId>
@@ -222,32 +208,6 @@
<scope>test</scope>
</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>
<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 %)
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 LocalDateTime lastPaymentAt;
// E-Rechnung / Signatur-Marker (Mustangproject + iText sign)
private EInvoiceFormat eInvoiceFormat = EInvoiceFormat.NONE;
private boolean signed = false;
private LocalDateTime signedAt;
private String signedBy;
// Pflichtangaben nach §14 UStG (German VAT law)
private String invoiceNumber; // Fortlaufende Rechnungsnummer
private LocalDate invoiceDate; // Rechnungsdatum
@@ -537,36 +531,4 @@ public class CustomerInvoice {
public void setLastPaymentAt(LocalDateTime lastPaymentAt) {
this.lastPaymentAt = lastPaymentAt;
}
public EInvoiceFormat getEInvoiceFormat() {
return eInvoiceFormat;
}
public void setEInvoiceFormat(EInvoiceFormat eInvoiceFormat) {
this.eInvoiceFormat = eInvoiceFormat;
}
public boolean isSigned() {
return signed;
}
public void setSigned(boolean signed) {
this.signed = signed;
}
public LocalDateTime getSignedAt() {
return signedAt;
}
public void setSignedAt(LocalDateTime signedAt) {
this.signedAt = signedAt;
}
public String getSignedBy() {
return signedBy;
}
public void setSignedBy(String signedBy) {
this.signedBy = signedBy;
}
}

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"
treeData.addItem(verwaltungItem,
new MenuTreeItem(getTranslation("nav.jobs"), "jobs", VaadinIcon.CLIPBOARD_TEXT));
// Eigenes Rechnungs-Modul ist deaktiviert (siehe CreateInvoiceView/InvoicesView).
// Die rechnungsrelevanten Daten werden ausschließlich per DATEV-CSV exportiert.
treeData.addItem(verwaltungItem,
new MenuTreeItem(getTranslation("nav.datev.export"), "datev-export", VaadinIcon.DOWNLOAD));
treeData.addItem(verwaltungItem,
new MenuTreeItem(getTranslation("nav.approvals"), "approvals", VaadinIcon.CHECK_CIRCLE));
new MenuTreeItem(getTranslation("nav.invoices"), "invoices", VaadinIcon.FILE_TEXT));
treeData.addItem(verwaltungItem,
new MenuTreeItem(getTranslation("nav.customers"), "customers", VaadinIcon.USERS));
treeData.addItem(verwaltungItem,

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.router.HasDynamicTitle;
// Import bleibt auskommentiert, solange @Route deaktiviert ist (siehe unten).
// import com.vaadin.flow.router.Route;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.router.BeforeEvent;
import com.vaadin.flow.router.HasUrlParameter;
import de.assecutor.votianlt.model.Customer;
@@ -33,7 +32,6 @@ import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.EInvoiceService;
import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoiceLifecycleService;
import de.assecutor.votianlt.service.InvoiceTemplateService;
@@ -53,11 +51,7 @@ import java.util.Optional;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.IFrame;
// Route deaktiviert: das System erstellt keine eigenen Rechnungen mehr.
// Code bleibt erhalten — die statische Methode showSavedInvoiceDialog(...) wird weiterhin
// genutzt, um vorhandene Rechnungs-PDFs anzuzeigen, und der DATEV-Export greift auf
// dieselben Backend-Services zu. Reaktivierung: nächste Zeile @Route entkommentieren.
// @Route(value = "create_invoice", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@Route(value = "create_invoice", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER" })
@Slf4j
public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter<String>, HasDynamicTitle {
@@ -71,7 +65,6 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
private final UserInvoiceDataService userInvoiceDataService;
private final CustomerService customerService;
private final InvoiceLifecycleService invoiceLifecycleService;
private final EInvoiceService eInvoiceService;
private User currentUser;
private Job currentJob;
private List<ServiceRow> gridRows = new ArrayList<>();
@@ -126,7 +119,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
UserRepository userRepository, CustomerInvoiceService customerInvoiceService,
InvoiceTemplateService invoiceTemplateService, SecurityService securityService,
UserInvoiceDataService userInvoiceDataService, CustomerService customerService,
InvoiceLifecycleService invoiceLifecycleService, EInvoiceService eInvoiceService) {
InvoiceLifecycleService invoiceLifecycleService) {
this.jobRepository = jobRepository;
this.serviceRepository = serviceRepository;
this.userRepository = userRepository;
@@ -136,7 +129,6 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
this.userInvoiceDataService = userInvoiceDataService;
this.customerService = customerService;
this.invoiceLifecycleService = invoiceLifecycleService;
this.eInvoiceService = eInvoiceService;
setSizeFull();
setPadding(true);
setSpacing(true);
@@ -594,17 +586,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
invoice.setVatAmount(vatAmount);
invoice.setTotalAmount(totalAmount);
// ZUGFeRD-Anreicherung und PAdES-Signatur sind unabhängig: der Nutzer kann
// beides einzeln im Profil aktivieren. Signatur ist strikt fehlt das
// Zertifikat, schlägt das Speichern hier mit einer InvoiceLifecycleException
// fehl und wird unten als Notification angezeigt.
boolean withZugferd = eInvoiceService.isEInvoiceEnabledGlobally() && user.isEinvoiceEnabled();
boolean withSignature = user.isSignInvoicesEnabled();
byte[] finalPdf = pdfBytes;
if (withZugferd || withSignature) {
finalPdf = eInvoiceService.enhanceAndSign(pdfBytes, invoice, withZugferd, withSignature);
}
invoice.setPdfData(finalPdf);
invoice.setPdfData(pdfBytes);
// Finalisierung mit Audit-Eintrag und Eindeutigkeitsprüfung der Rechnungsnummer (R-07/R-11/R-36).
CustomerInvoice savedInvoice = invoiceLifecycleService.createAndIssue(invoice,

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.repository.ServiceRepository;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.config.EInvoiceProperties;
import de.assecutor.votianlt.pages.base.ui.component.SigningCredentialsPanel;
import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.InvoiceTemplateService;
import de.assecutor.votianlt.service.LanguageService;
import de.assecutor.votianlt.service.SigningCredentialsService;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.textfield.NumberField;
@@ -77,8 +74,6 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
private final UserInvoiceDataService userInvoiceDataService;
private final CustomerInvoiceService customerInvoiceService;
private final InvoiceTemplateService invoiceTemplateService;
private final SigningCredentialsService signingCredentialsService;
private final EInvoiceProperties eInvoiceProperties;
private UserInvoiceData currentInvoiceData;
private Checkbox billingEnabled;
private NumberField vatRateField;
@@ -92,15 +87,12 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
public EditProfileView(UserService userService, UserInvoiceDataService userInvoiceDataService,
CustomerInvoiceService customerInvoiceService, InvoiceTemplateService invoiceTemplateService,
LanguageService languageService, SecurityService securityService, ServiceRepository serviceRepository,
SigningCredentialsService signingCredentialsService, EInvoiceProperties eInvoiceProperties) {
LanguageService languageService, SecurityService securityService, ServiceRepository serviceRepository) {
this.userInvoiceDataService = userInvoiceDataService;
this.customerInvoiceService = customerInvoiceService;
this.invoiceTemplateService = invoiceTemplateService;
this.currentUser = securityService.getCurrentDatabaseUser();
this.serviceRepository = serviceRepository;
this.signingCredentialsService = signingCredentialsService;
this.eInvoiceProperties = eInvoiceProperties;
// Store the original language before any changes
this.originalLanguage = this.currentUser != null ? this.currentUser.getLanguage() : Language.DE;
@@ -375,32 +367,11 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
}
});
Checkbox eInvoiceCheckbox = new Checkbox(getTranslation("profile.settings.einvoice"));
eInvoiceCheckbox.setHelperText(getTranslation("profile.settings.einvoice.helper"));
eInvoiceCheckbox.setValue(currentUser.isEinvoiceEnabled());
eInvoiceCheckbox.addValueChangeListener(
e -> currentUser.setEinvoiceEnabled(Boolean.TRUE.equals(e.getValue())));
Checkbox signInvoicesCheckbox = new Checkbox(getTranslation("profile.settings.signinvoices"));
signInvoicesCheckbox.setHelperText(getTranslation("profile.settings.signinvoices.helper"));
signInvoicesCheckbox.setValue(currentUser.isSignInvoicesEnabled());
signInvoicesCheckbox.addValueChangeListener(
e -> currentUser.setSignInvoicesEnabled(Boolean.TRUE.equals(e.getValue())));
HorizontalLayout billingHeaderLayout = new HorizontalLayout(billingEnabled, prefixField, vatRateField,
eInvoiceCheckbox, signInvoicesCheckbox);
HorizontalLayout billingHeaderLayout = new HorizontalLayout(billingEnabled, prefixField, vatRateField);
billingHeaderLayout.setSpacing(true);
billingHeaderLayout.setAlignItems(FlexComponent.Alignment.BASELINE);
billingTab.add(billingHeaderLayout);
// Signatur-Credentials (Phase 5.5/5.6)
if (currentUser != null && currentUser.getId() != null) {
SigningCredentialsPanel signingPanel = new SigningCredentialsPanel(signingCredentialsService,
eInvoiceProperties, currentUser.getId().toHexString(), this::getTranslation);
signingPanel.setMaxWidth("760px");
billingTab.add(signingPanel);
}
// Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften)
final HorizontalLayout mainLayout = new HorizontalLayout();
mainLayout.setWidthFull();

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.TextField;
import com.vaadin.flow.router.HasDynamicTitle;
// Route deaktiviert (siehe Klassen-Header) — die Anwendung erstellt/bearbeitet keine
// Rechnungen mehr selbst, der Bestand wird per DATEV-Export weiterverarbeitet.
// import com.vaadin.flow.router.Route;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.StreamRegistration;
import com.vaadin.flow.server.StreamResource;
import de.assecutor.votianlt.model.User;
@@ -28,7 +26,6 @@ import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import de.assecutor.votianlt.model.invoices.PaymentStatus;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
@@ -36,7 +33,6 @@ import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.InvoiceApprovalService;
import de.assecutor.votianlt.service.InvoiceExportService;
import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoiceLifecycleService;
@@ -51,8 +47,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Optional;
// @Route deaktiviert — Rechnungs-UI ist durch DATEV-Export ersetzt. Reaktivierung:
// @Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER", "ADMIN" })
public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
@@ -66,14 +61,13 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
private final CustomerInvoiceService customerInvoiceService;
private final InvoiceExportService invoiceExportService;
private final InvoicePermissionService invoicePermissionService;
private final InvoiceApprovalService invoiceApprovalService;
private final UserInvoiceDataService userInvoiceDataService;
private final UserRepository userRepository;
public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService,
InvoiceLifecycleService invoiceLifecycleService, CustomerInvoiceService customerInvoiceService,
InvoiceExportService invoiceExportService, InvoicePermissionService invoicePermissionService,
InvoiceApprovalService invoiceApprovalService, UserInvoiceDataService userInvoiceDataService,
UserInvoiceDataService userInvoiceDataService,
UserRepository userRepository) {
this.customerInvoiceRepository = customerInvoiceRepository;
this.securityService = securityService;
@@ -81,7 +75,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
this.customerInvoiceService = customerInvoiceService;
this.invoiceExportService = invoiceExportService;
this.invoicePermissionService = invoicePermissionService;
this.invoiceApprovalService = invoiceApprovalService;
this.userInvoiceDataService = userInvoiceDataService;
this.userRepository = userRepository;
@@ -110,10 +103,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
.setHeader(getTranslation("invoices.column.date")).setAutoWidth(true);
invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount"))
.setAutoWidth(true);
invoiceGrid.addComponentColumn(this::renderPaymentBadge)
.setHeader(getTranslation("invoices.column.payment")).setAutoWidth(true);
invoiceGrid.addColumn(this::formatOutstanding).setHeader(getTranslation("invoices.column.outstanding"))
.setAutoWidth(true);
invoiceGrid.addComponentColumn(this::renderActions)
.setHeader(getTranslation("invoices.column.actions")).setAutoWidth(true).setFlexGrow(0);
@@ -165,29 +154,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
return badge;
}
private Component renderPaymentBadge(CustomerInvoice invoice) {
PaymentStatus status = invoice.getPaymentStatus() != null ? invoice.getPaymentStatus() : PaymentStatus.UNPAID;
Span badge = new Span(getTranslation("invoices.payment." + status.name().toLowerCase(Locale.ROOT)));
badge.getElement().getThemeList().add("badge");
switch (status) {
case PAID -> badge.getElement().getThemeList().add("success");
case PARTIALLY_PAID -> badge.getElement().getThemeList().add("contrast");
case OVERPAID -> badge.getElement().getThemeList().add("warning");
case REFUND_DUE -> badge.getElement().getThemeList().add("error");
default -> {
}
}
return badge;
}
private String formatOutstanding(CustomerInvoice invoice) {
if (invoice.getTotalAmount() == null) {
return "";
}
java.math.BigDecimal outstanding = invoiceLifecycleService.computeOutstandingAmount(invoice);
return java.text.NumberFormat.getCurrencyInstance(Locale.GERMANY).format(outstanding);
}
private Component renderTypeBadge(CustomerInvoice invoice) {
InvoiceType type = invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE;
HorizontalLayout layout = new HorizontalLayout();
@@ -202,21 +168,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
badge.getElement().getThemeList().add("warning");
}
layout.add(badge);
if (invoice.getEInvoiceFormat() != null
&& invoice.getEInvoiceFormat() != de.assecutor.votianlt.model.invoices.EInvoiceFormat.NONE) {
Span eInvoiceBadge = new Span("ZUGFeRD");
eInvoiceBadge.getElement().getThemeList().add("badge");
eInvoiceBadge.getElement().getThemeList().add("primary");
eInvoiceBadge.setTitle(getTranslation("invoices.einvoice.tooltip"));
layout.add(eInvoiceBadge);
}
if (invoice.isSigned()) {
Span signedBadge = new Span("" + getTranslation("invoices.einvoice.signed"));
signedBadge.getElement().getThemeList().add("badge");
signedBadge.getElement().getThemeList().add("success");
layout.add(signedBadge);
}
return layout;
}
@@ -251,28 +202,17 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
}
if (isLiveInvoice) {
boolean hasPendingRequest = !invoiceApprovalService
.findOpenForCurrentRequester().stream()
.filter(r -> invoice.getId().equals(r.getTargetInvoiceId()))
.toList().isEmpty();
if (invoicePermissionService.canCorrect(currentUser)) {
String label = invoicePermissionService.requiresApproval(currentUser)
? getTranslation("invoices.action.correct.request")
: getTranslation("invoices.action.correct");
Button correctBtn = new Button(label, e -> openCorrectionDialog(invoice));
Button correctBtn = new Button(getTranslation("invoices.action.correct"),
e -> openCorrectionDialog(invoice));
correctBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
correctBtn.setEnabled(!hasPendingRequest);
actions.add(correctBtn);
}
if (invoicePermissionService.canCancel(currentUser)) {
String label = invoicePermissionService.requiresApproval(currentUser)
? getTranslation("invoices.action.cancel.request")
: getTranslation("invoices.action.cancel");
Button cancelBtn = new Button(label, e -> openCancellationDialog(invoice));
Button cancelBtn = new Button(getTranslation("invoices.action.cancel"),
e -> openCancellationDialog(invoice));
cancelBtn.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ERROR);
cancelBtn.setEnabled(!hasPendingRequest);
actions.add(cancelBtn);
}
}
@@ -432,14 +372,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
User currentUser = invoicePermissionService.currentUser();
try {
invoicePermissionService.requireCancel(currentUser);
if (invoicePermissionService.requiresApproval(currentUser)) {
invoiceApprovalService.requestCancellation(invoice.getId(), reason);
dialog.close();
Notification.show(getTranslation("invoices.notification.requested"), 4000,
Notification.Position.BOTTOM_END);
loadInvoices();
return;
}
User issuer = resolveIssuer(invoice);
String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId());
LocalDate today = LocalDate.now();
@@ -506,15 +438,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
User currentUser = invoicePermissionService.currentUser();
try {
invoicePermissionService.requireCorrect(currentUser);
if (invoicePermissionService.requiresApproval(currentUser)) {
String requestReason = reason != null && !reason.isBlank() ? reason : correctedFields;
invoiceApprovalService.requestCorrection(invoice.getId(), correctedFields, requestReason);
dialog.close();
Notification.show(getTranslation("invoices.notification.requested"), 4000,
Notification.Position.BOTTOM_END);
loadInvoices();
return;
}
User issuer = resolveIssuer(invoice);
String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId());
LocalDate today = LocalDate.now();

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

View File

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

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.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.EInvoiceFormat;
import de.assecutor.votianlt.model.invoices.InvoiceAuditAction;
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
@@ -43,16 +42,14 @@ public class InvoiceLifecycleService {
private final CustomerInvoiceRepository invoiceRepository;
private final SecurityService securityService;
private final EInvoiceService eInvoiceService;
private final InvoiceComplianceValidator complianceValidator;
private final InvoiceNumberAuditService numberAuditService;
public InvoiceLifecycleService(CustomerInvoiceRepository invoiceRepository, SecurityService securityService,
EInvoiceService eInvoiceService, InvoiceComplianceValidator complianceValidator,
InvoiceComplianceValidator complianceValidator,
InvoiceNumberAuditService numberAuditService) {
this.invoiceRepository = invoiceRepository;
this.securityService = securityService;
this.eInvoiceService = eInvoiceService;
this.complianceValidator = complianceValidator;
this.numberAuditService = numberAuditService;
}
@@ -212,7 +209,7 @@ public class InvoiceLifecycleService {
cancellation.setTotalAmount(negate(original.getTotalAmount()));
cancellation.setDescription("Stornorechnung zu Rechnung " + original.getInvoiceNumber());
cancellation.setPdfData(applyEInvoiceIfApplicable(pdfData, cancellation, original));
cancellation.setPdfData(pdfData);
cancellation.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason);
issuedEntry.setResultingInvoiceNumber(cancellationNumber);
@@ -297,7 +294,7 @@ public class InvoiceLifecycleService {
correction.setDescription(
correctedFields == null || correctedFields.isBlank() ? descriptionPrefix
: descriptionPrefix + "" + correctedFields);
correction.setPdfData(applyEInvoiceIfApplicable(pdfData, correction, original));
correction.setPdfData(pdfData);
correction.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason);
issuedEntry.setResultingInvoiceNumber(correctionNumber);
@@ -536,37 +533,4 @@ public class InvoiceLifecycleService {
private BigDecimal negate(BigDecimal value) {
return value != null ? value.negate() : null;
}
/**
* Reichert ein PDF mit ZUGFeRD-XML an und signiert es, falls Mustangproject systemweit
* aktiviert ist und das Original bereits ein E-Rechnungsformat hatte. So bleibt das
* Format eines Storno- oder Berichtigungsbelegs konsistent zur Originalrechnung.
*
* Fehlt das Signatur-Zertifikat oder kann es nicht entschlüsselt werden, wird die
* {@link InvoiceLifecycleException} bewusst durchgereicht — der Anwender soll die
* Storno-/Berichtigungs-Aktion korrigieren können (Zertifikat hochladen,
* Master-Key prüfen). Andere Fehlerklassen (z.B. PDF-Strukturfehler bei der
* ZUGFeRD-Anreicherung) bleiben graceful: das Roh-PDF wird zurückgegeben.
*/
private byte[] applyEInvoiceIfApplicable(byte[] pdfData, CustomerInvoice followUp, CustomerInvoice original) {
if (pdfData == null || pdfData.length == 0 || eInvoiceService == null || original == null) {
return pdfData;
}
boolean originalHadZugferd = original.getEInvoiceFormat() != null
&& original.getEInvoiceFormat() != EInvoiceFormat.NONE;
boolean originalWasSigned = original.isSigned();
boolean wantsZugferd = originalHadZugferd && eInvoiceService.isEInvoiceEnabledGlobally();
if (!wantsZugferd && !originalWasSigned) {
return pdfData;
}
try {
return eInvoiceService.enhanceAndSign(pdfData, followUp, wantsZugferd, originalWasSigned);
} catch (InvoiceLifecycleException ex) {
// Signatur-/Zertifikatsproblem dem Anwender sichtbar machen
throw ex;
} catch (Exception ex) {
log.warn("E-Invoice-Anreicherung des Folgebelegs fehlgeschlagen: {}", ex.getMessage(), ex);
return pdfData;
}
}
}

View File

@@ -50,24 +50,6 @@ public class InvoicePermissionService {
return hasAnyInvoiceRole(user, InvoiceRoles.ACCOUNTANT, InvoiceRoles.APPROVER) || isUnscoped(user);
}
public boolean canApproveRequests(User user) {
return hasAnyInvoiceRole(user, InvoiceRoles.APPROVER) || isAdmin(user);
}
/**
* Liefert {@code true}, wenn die Aktion dieses Nutzers vor Ausführung eine Freigabe benötigt
* (R-42). Approver können ihre eigenen Aktionen direkt ausführen.
*/
public boolean requiresApproval(User user) {
if (user == null) {
return false;
}
if (canApproveRequests(user)) {
return false;
}
return user.isRequireApprovalForCriticalInvoiceActions();
}
public void requireCreate(User user) {
if (!canCreateOrIssue(user)) {
throw new InvoiceLifecycleException(
@@ -102,12 +84,6 @@ public class InvoicePermissionService {
}
}
public void requireApprove(User user) {
if (!canApproveRequests(user)) {
throw new InvoiceLifecycleException("Sie haben keine Freigabe-Berechtigung.");
}
}
/**
* Convenience: prüft, ob der Nutzer Eigentümer einer Rechnung ist (oder Admin).
* Wird genutzt, um Cross-Tenant-Zugriffe zu verhindern.

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

@@ -96,33 +96,3 @@ spring.ai.mcp.server.enabled=true
spring.ai.mcp.server.name=votianlt-mcp-server
spring.ai.mcp.server.version=1.0.0
spring.ai.mcp.server.sse-message-endpoint=/mcp/message
# ===========================================
# E-Rechnung (ZUGFeRD/Factur-X) und PAdES-Signatur
# ===========================================
# Aktivieren Sie die ZUGFeRD-Anreicherung systemweit. Pro Nutzer entscheidet
# zusätzlich das Profilfeld eInvoiceEnabled, ob es tatsächlich angewendet wird.
votianlt.einvoice.enabled=false
votianlt.einvoice.profile=EN16931
# PAdES-Signatur: nur aktiv, wenn unten ein gültiger Keystore konfiguriert ist.
votianlt.einvoice.signing.enabled=false
votianlt.einvoice.signing.keystore-path=
votianlt.einvoice.signing.keystore-password=
votianlt.einvoice.signing.key-alias=
votianlt.einvoice.signing.reason=Rechnung
votianlt.einvoice.signing.location=
votianlt.einvoice.signing.contact=
# Master-Key (>= 16 Zeichen) zum Verschlüsseln nutzerseitig hinterlegter PKCS#12-Keystores.
# Verlust dieses Keys macht alle gespeicherten Nutzer-Keystores unbrauchbar.
#
# SICHERHEITSEMPFEHLUNG (Stufe 2/3):
# - Den Key NIEMALS inline hier hinterlegen — nutzen Sie ENV oder eine Secret-Datei.
# - ENV-Variante: VOTIANLT_EINVOICE_SIGNING_MASTER_KEY=...
# (oder Lower-Case-Equivalent via Spring Relaxed Binding)
# - Secret-Datei-Variante: master-key-file zeigt auf eine Datei (Docker-/K8s-Secret),
# chmod 600 auf Bare-Metal/VM-Deployments.
# - Die Spring-Placeholder-Syntax ${VAR:default} liest die ENV automatisch.
# - application.properties selbst sollte nicht weltlesbar sein (chmod 600).
votianlt.einvoice.signing.master-key=${VOTIANLT_EINVOICE_SIGNING_MASTER_KEY:}
votianlt.einvoice.signing.master-key-file=${VOTIANLT_EINVOICE_SIGNING_MASTER_KEY_FILE:}

View File

@@ -9,17 +9,6 @@ nav.customers=Adressbuch
nav.appusers=App-Nutzer
nav.statistics=Statistiken
nav.invoices=Rechnungen
nav.datev.export=DATEV-Export
nav.approvals=Freigaben
datev.export.title=DATEV-Export
datev.export.description=Lädt einen DATEV-kompatiblen Buchungsstapel mit allen festgeschriebenen Rechnungen des gewählten Zeitraums herunter. Die Datei kann in DATEV Unternehmen Online sowie in DATEV-importfähigen Drittprogrammen eingelesen werden.
datev.export.from=Von
datev.export.to=Bis
datev.export.button=Rechnungen exportieren
datev.export.success=Export erstellt: {0}
datev.export.error.dates=Bitte Von- und Bis-Datum auswählen.
datev.export.error.range=Das Bis-Datum darf nicht vor dem Von-Datum liegen.
datev.export.error.user=Aktueller Nutzer konnte nicht ermittelt werden.
nav.messages=Nachrichten
nav.profile=Mein Profil
nav.myinvoices=Rechnungen
@@ -58,36 +47,6 @@ profile.settings.digitalprocess.info=Aufträge werden digital über die App abge
profile.settings.locateappuser=App-Nutzer orten
profile.settings.locateappuser.info=Standort der App-Nutzer wird regelmäßig übertragen
profile.settings.vatrate=Umsatzsteuer
profile.settings.einvoice=ZUGFeRD-E-Rechnung erstellen
profile.settings.einvoice.helper=Erzeugt PDF/A-3 mit eingebettetem XRechnung/ZUGFeRD-XML (sofern systemweit aktiviert).
profile.settings.signinvoices=Rechnungen digital signieren
profile.settings.signinvoices.helper=Erzeugt eine PAdES-Signatur mit dem hinterlegten Zertifikat. Ohne aktives Zertifikat schlägt das Speichern fehl.
profile.signing.title=Signatur-Zertifikat
profile.signing.hint=Hinterlegen Sie Ihr eigenes PKCS#12-Zertifikat (.p12/.pfx), damit Ihre Rechnungen mit Ihrer Signatur erstellt werden. Der private Schlüssel wird verschlüsselt in der Datenbank gespeichert.
profile.signing.masterkey.missing=Hinweis: Der Server-Master-Key ist nicht gesetzt. Bitten Sie Ihren Administrator, votianlt.einvoice.signing.master-key zu konfigurieren, bevor Sie ein Zertifikat hinterlegen.
profile.signing.none=Es ist noch kein eigenes Signatur-Zertifikat hinterlegt. Beim Signieren wird der systemweit konfigurierte Schlüssel verwendet.
profile.signing.metadata.alias=Alias
profile.signing.metadata.subject=Inhaber
profile.signing.metadata.issuer=Aussteller
profile.signing.metadata.serial=Seriennummer
profile.signing.metadata.validity=Gültig
profile.signing.expired=Zertifikat abgelaufen
profile.signing.expiring=Läuft in den nächsten 30 Tagen ab
profile.signing.enabled=Eigenes Zertifikat zum Signieren verwenden
profile.signing.toggle.saved=Einstellung gespeichert.
profile.signing.delete=Zertifikat entfernen
profile.signing.deleted=Signatur-Zertifikat entfernt.
profile.signing.upload.title=Zertifikat hochladen
profile.signing.upload.drop=PKCS#12-Datei hier ablegen oder klicken
profile.signing.upload.received=Datei empfangen — bitte Alias und Passwort angeben.
profile.signing.upload.required=Bitte zuerst eine Zertifikatsdatei hochladen.
profile.signing.upload.save=Speichern
profile.signing.alias=Schlüssel-Alias
profile.signing.alias.required=Bitte den Alias des Schlüssels angeben.
profile.signing.password=Keystore-Passwort
profile.signing.password.required=Bitte das Keystore-Passwort angeben.
profile.signing.saved=Signatur-Zertifikat gespeichert.
profile.signing.error=Speichern fehlgeschlagen
profile.account=Konto
profile.security=Sicherheit
profile.security.twofactor=Zwei-Faktor-Authentifizierung
@@ -798,8 +757,6 @@ invoices.audit.action.replaced=Ersetzt durch neue Rechnung
invoices.audit.action.deleted_draft=Entwurf gelöscht
invoices.audit.action.payment_recorded=Zahlung erfasst
invoices.audit.resulting=Erzeugter Folgebeleg: {0}
invoices.column.payment=Zahlung
invoices.column.outstanding=Offen
invoices.payment.unpaid=Offen
invoices.payment.partially_paid=Teilzahlung
invoices.payment.paid=Bezahlt
@@ -815,28 +772,6 @@ invoices.payment.reference=Zahlungsreferenz (z.B. Kontoauszug, Buchungs-Nr.)
invoices.payment.reason=Anmerkung
invoices.payment.confirm=Zahlung erfassen
invoices.notification.payment=Zahlung erfasst.
invoices.einvoice.tooltip=PDF/A-3 mit eingebettetem ZUGFeRD/XRechnung-XML
invoices.einvoice.signed=Signiert
invoices.action.cancel.request=Storno beantragen
invoices.action.correct.request=Berichtigung beantragen
invoices.notification.requested=Freigabe-Anfrage erstellt. Bitte auf Freigabe warten.
approvals.title=Freigaben
approvals.no.permission=Sie haben keine Berechtigung, Freigaben zu bearbeiten.
approvals.column.requested=Beantragt am
approvals.column.requester=Beantragt von
approvals.column.invoice=Rechnung
approvals.column.action=Aktion
approvals.column.reason=Grund
approvals.action.approve=Freigeben
approvals.action.reject=Ablehnen
approvals.confirm.approve.title=Anfrage zu Rechnung {0} freigeben
approvals.confirm.reject.title=Anfrage zu Rechnung {0} ablehnen
approvals.review.fields=Berichtigte Angaben
approvals.review.reason=Grund
approvals.review.comment=Kommentar (optional)
approvals.notification.approved=Anfrage freigegeben — Folgebeleg wurde erstellt.
approvals.notification.rejected=Anfrage abgelehnt.
page.title.approvals=Freigaben
# My Invoices
myinvoices.title=Rechnungen

View File

@@ -9,17 +9,6 @@ nav.customers=Address Book
nav.appusers=App Users
nav.statistics=Statistics
nav.invoices=Invoices
nav.datev.export=DATEV Export
nav.approvals=Approvals
datev.export.title=DATEV Export
datev.export.description=Downloads a DATEV-compatible booking batch containing all finalized invoices for the selected period. The file can be imported into DATEV Unternehmen Online as well as DATEV-compatible third-party tools.
datev.export.from=From
datev.export.to=To
datev.export.button=Export invoices
datev.export.success=Export created: {0}
datev.export.error.dates=Please pick both From and To dates.
datev.export.error.range=To date must not be before From date.
datev.export.error.user=Could not determine the current user.
nav.messages=Messages
nav.profile=My Profile
nav.myinvoices=Invoices
@@ -58,36 +47,6 @@ profile.settings.digitalprocess.info=Jobs are processed digitally via the app
profile.settings.locateappuser=Locate App Users
profile.settings.locateappuser.info=App user location is transmitted regularly
profile.settings.vatrate=VAT rate
profile.settings.einvoice=Create ZUGFeRD e-invoice
profile.settings.einvoice.helper=Generates PDF/A-3 with embedded XRechnung/ZUGFeRD XML (when enabled system-wide).
profile.settings.signinvoices=Digitally sign invoices
profile.settings.signinvoices.helper=Adds a PAdES signature using the configured certificate. Saving fails if no active certificate is available.
profile.signing.title=Signing certificate
profile.signing.hint=Upload your own PKCS#12 certificate (.p12/.pfx) so invoices are signed with your signature. The private key is stored encrypted in the database.
profile.signing.masterkey.missing=Note: The server master key is not configured. Ask your administrator to set votianlt.einvoice.signing.master-key before uploading a certificate.
profile.signing.none=No personal signing certificate stored yet. The system-wide key will be used when signing.
profile.signing.metadata.alias=Alias
profile.signing.metadata.subject=Subject
profile.signing.metadata.issuer=Issuer
profile.signing.metadata.serial=Serial number
profile.signing.metadata.validity=Valid
profile.signing.expired=Certificate expired
profile.signing.expiring=Expires within the next 30 days
profile.signing.enabled=Use my certificate for signing
profile.signing.toggle.saved=Setting saved.
profile.signing.delete=Remove certificate
profile.signing.deleted=Signing certificate removed.
profile.signing.upload.title=Upload certificate
profile.signing.upload.drop=Drop your PKCS#12 file here or click to upload
profile.signing.upload.received=File received — please provide alias and password.
profile.signing.upload.required=Please upload a certificate file first.
profile.signing.upload.save=Save
profile.signing.alias=Key alias
profile.signing.alias.required=Please provide the key alias.
profile.signing.password=Keystore password
profile.signing.password.required=Please provide the keystore password.
profile.signing.saved=Signing certificate saved.
profile.signing.error=Save failed
profile.account=Account
profile.security=Security
profile.security.twofactor=Two-Factor Authentication
@@ -798,8 +757,6 @@ invoices.audit.action.replaced=Replaced by new invoice
invoices.audit.action.deleted_draft=Draft deleted
invoices.audit.action.payment_recorded=Payment recorded
invoices.audit.resulting=Resulting document: {0}
invoices.column.payment=Payment
invoices.column.outstanding=Outstanding
invoices.payment.unpaid=Open
invoices.payment.partially_paid=Partially paid
invoices.payment.paid=Paid
@@ -815,28 +772,6 @@ invoices.payment.reference=Payment reference (e.g. statement, booking ID)
invoices.payment.reason=Note
invoices.payment.confirm=Record payment
invoices.notification.payment=Payment recorded.
invoices.einvoice.tooltip=PDF/A-3 with embedded ZUGFeRD/XRechnung XML
invoices.einvoice.signed=Signed
invoices.action.cancel.request=Request cancellation
invoices.action.correct.request=Request correction
invoices.notification.requested=Approval request created. Please wait for approval.
approvals.title=Approvals
approvals.no.permission=You do not have permission to handle approvals.
approvals.column.requested=Requested at
approvals.column.requester=Requested by
approvals.column.invoice=Invoice
approvals.column.action=Action
approvals.column.reason=Reason
approvals.action.approve=Approve
approvals.action.reject=Reject
approvals.confirm.approve.title=Approve request for invoice {0}
approvals.confirm.reject.title=Reject request for invoice {0}
approvals.review.fields=Corrected information
approvals.review.reason=Reason
approvals.review.comment=Comment (optional)
approvals.notification.approved=Request approved — follow-up document created.
approvals.notification.rejected=Request rejected.
page.title.approvals=Approvals
# My Invoices
myinvoices.title=Invoices

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;
}
}