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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 Aufmerksamkeitssignal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package de.assecutor.votianlt.model.invoices;
|
||||
|
||||
public enum InvoiceApprovalStatus {
|
||||
PENDING, APPROVED, REJECTED
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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("\"", "\"\"") + "\"";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 : "";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 & 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) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user