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>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<revision>0.9.16</revision>
|
<revision>0.9.17</revision>
|
||||||
<java.version>21</java.version>
|
<java.version>21</java.version>
|
||||||
<maven.compiler.source>21</maven.compiler.source>
|
<maven.compiler.source>21</maven.compiler.source>
|
||||||
<maven.compiler.target>21</maven.compiler.target>
|
<maven.compiler.target>21</maven.compiler.target>
|
||||||
@@ -184,20 +184,6 @@
|
|||||||
<version>5.0.5</version>
|
<version>5.0.5</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- BouncyCastle: CMS-Signatur für PAdES via Apache PDFBox -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.bouncycastle</groupId>
|
|
||||||
<artifactId>bcpkix-jdk18on</artifactId>
|
|
||||||
<version>1.78</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Mustangproject: ZUGFeRD/Factur-X/XRechnung E-Rechnung -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.mustangproject</groupId>
|
|
||||||
<artifactId>library</artifactId>
|
|
||||||
<version>2.16.0</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Spring AI OpenAI (LM Studio kompatibel) -->
|
<!-- Spring AI OpenAI (LM Studio kompatibel) -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.ai</groupId>
|
<groupId>org.springframework.ai</groupId>
|
||||||
@@ -222,32 +208,6 @@
|
|||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- EU DSS: PAdES-Validierung der signierten Test-PDFs (nur Test-Scope) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
|
|
||||||
<artifactId>dss-pades-pdfbox</artifactId>
|
|
||||||
<version>6.2</version>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
|
|
||||||
<artifactId>dss-validation</artifactId>
|
|
||||||
<version>6.2</version>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
|
|
||||||
<artifactId>dss-utils-apache-commons</artifactId>
|
|
||||||
<version>6.2</version>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
|
|
||||||
<artifactId>dss-crl-parser-x509crl</artifactId>
|
|
||||||
<version>6.2</version>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -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 %)
|
// Umsatzsteuer-Satz (als Dezimalwert, z.B. 0.19 für 19 %)
|
||||||
private BigDecimal vatRate = new BigDecimal("0.19");
|
private BigDecimal vatRate = new BigDecimal("0.19");
|
||||||
|
|
||||||
/**
|
|
||||||
* Optionaler Freigabe-Workflow für kritische Rechnungsvorgänge (R-42).
|
|
||||||
*
|
|
||||||
* Ist das Flag aktiv, müssen Storno- und Berichtigungsbelege durch einen Nutzer mit
|
|
||||||
* {@link de.assecutor.votianlt.security.InvoiceRoles#APPROVER}-Rolle freigegeben werden,
|
|
||||||
* bevor sie tatsächlich erzeugt werden.
|
|
||||||
*/
|
|
||||||
private boolean requireApprovalForCriticalInvoiceActions = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aktiviert ZUGFeRD/Factur-X-Anreicherung beim Speichern der Rechnungs-PDFs.
|
|
||||||
* Greift nur, wenn auch systemweit über
|
|
||||||
* {@link de.assecutor.votianlt.config.EInvoiceProperties} aktiviert ist.
|
|
||||||
*/
|
|
||||||
private boolean einvoiceEnabled = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aktiviert die digitale PAdES-Signatur der erzeugten Rechnungs-PDFs.
|
|
||||||
* Funktioniert unabhängig von {@link #einvoiceEnabled}: signierte Standard-PDFs
|
|
||||||
* sind ebenso möglich wie signierte ZUGFeRD-PDFs.
|
|
||||||
* Voraussetzung: ein hinterlegtes Nutzer-Zertifikat oder ein systemweit
|
|
||||||
* konfigurierter Keystore.
|
|
||||||
*/
|
|
||||||
private boolean signInvoicesEnabled = false;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,12 +41,6 @@ public class CustomerInvoice {
|
|||||||
private BigDecimal paidAmount;
|
private BigDecimal paidAmount;
|
||||||
private LocalDateTime lastPaymentAt;
|
private LocalDateTime lastPaymentAt;
|
||||||
|
|
||||||
// E-Rechnung / Signatur-Marker (Mustangproject + iText sign)
|
|
||||||
private EInvoiceFormat eInvoiceFormat = EInvoiceFormat.NONE;
|
|
||||||
private boolean signed = false;
|
|
||||||
private LocalDateTime signedAt;
|
|
||||||
private String signedBy;
|
|
||||||
|
|
||||||
// Pflichtangaben nach §14 UStG (German VAT law)
|
// Pflichtangaben nach §14 UStG (German VAT law)
|
||||||
private String invoiceNumber; // Fortlaufende Rechnungsnummer
|
private String invoiceNumber; // Fortlaufende Rechnungsnummer
|
||||||
private LocalDate invoiceDate; // Rechnungsdatum
|
private LocalDate invoiceDate; // Rechnungsdatum
|
||||||
@@ -537,36 +531,4 @@ public class CustomerInvoice {
|
|||||||
public void setLastPaymentAt(LocalDateTime lastPaymentAt) {
|
public void setLastPaymentAt(LocalDateTime lastPaymentAt) {
|
||||||
this.lastPaymentAt = lastPaymentAt;
|
this.lastPaymentAt = lastPaymentAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public EInvoiceFormat getEInvoiceFormat() {
|
|
||||||
return eInvoiceFormat;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setEInvoiceFormat(EInvoiceFormat eInvoiceFormat) {
|
|
||||||
this.eInvoiceFormat = eInvoiceFormat;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isSigned() {
|
|
||||||
return signed;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setSigned(boolean signed) {
|
|
||||||
this.signed = signed;
|
|
||||||
}
|
|
||||||
|
|
||||||
public LocalDateTime getSignedAt() {
|
|
||||||
return signedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setSignedAt(LocalDateTime signedAt) {
|
|
||||||
this.signedAt = signedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getSignedBy() {
|
|
||||||
return signedBy;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setSignedBy(String signedBy) {
|
|
||||||
this.signedBy = signedBy;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
// Add children to "Verwaltung"
|
||||||
treeData.addItem(verwaltungItem,
|
treeData.addItem(verwaltungItem,
|
||||||
new MenuTreeItem(getTranslation("nav.jobs"), "jobs", VaadinIcon.CLIPBOARD_TEXT));
|
new MenuTreeItem(getTranslation("nav.jobs"), "jobs", VaadinIcon.CLIPBOARD_TEXT));
|
||||||
// Eigenes Rechnungs-Modul ist deaktiviert (siehe CreateInvoiceView/InvoicesView).
|
|
||||||
// Die rechnungsrelevanten Daten werden ausschließlich per DATEV-CSV exportiert.
|
|
||||||
treeData.addItem(verwaltungItem,
|
treeData.addItem(verwaltungItem,
|
||||||
new MenuTreeItem(getTranslation("nav.datev.export"), "datev-export", VaadinIcon.DOWNLOAD));
|
new MenuTreeItem(getTranslation("nav.invoices"), "invoices", VaadinIcon.FILE_TEXT));
|
||||||
treeData.addItem(verwaltungItem,
|
|
||||||
new MenuTreeItem(getTranslation("nav.approvals"), "approvals", VaadinIcon.CHECK_CIRCLE));
|
|
||||||
treeData.addItem(verwaltungItem,
|
treeData.addItem(verwaltungItem,
|
||||||
new MenuTreeItem(getTranslation("nav.customers"), "customers", VaadinIcon.USERS));
|
new MenuTreeItem(getTranslation("nav.customers"), "customers", VaadinIcon.USERS));
|
||||||
treeData.addItem(verwaltungItem,
|
treeData.addItem(verwaltungItem,
|
||||||
|
|||||||
@@ -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.component.textfield.NumberField;
|
||||||
|
|
||||||
import com.vaadin.flow.router.HasDynamicTitle;
|
import com.vaadin.flow.router.HasDynamicTitle;
|
||||||
// Import bleibt auskommentiert, solange @Route deaktiviert ist (siehe unten).
|
import com.vaadin.flow.router.Route;
|
||||||
// import com.vaadin.flow.router.Route;
|
|
||||||
import com.vaadin.flow.router.BeforeEvent;
|
import com.vaadin.flow.router.BeforeEvent;
|
||||||
import com.vaadin.flow.router.HasUrlParameter;
|
import com.vaadin.flow.router.HasUrlParameter;
|
||||||
import de.assecutor.votianlt.model.Customer;
|
import de.assecutor.votianlt.model.Customer;
|
||||||
@@ -33,7 +32,6 @@ import de.assecutor.votianlt.repository.ServiceRepository;
|
|||||||
import de.assecutor.votianlt.repository.UserRepository;
|
import de.assecutor.votianlt.repository.UserRepository;
|
||||||
import de.assecutor.votianlt.security.SecurityService;
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
||||||
import de.assecutor.votianlt.service.EInvoiceService;
|
|
||||||
import de.assecutor.votianlt.service.InvoiceLifecycleException;
|
import de.assecutor.votianlt.service.InvoiceLifecycleException;
|
||||||
import de.assecutor.votianlt.service.InvoiceLifecycleService;
|
import de.assecutor.votianlt.service.InvoiceLifecycleService;
|
||||||
import de.assecutor.votianlt.service.InvoiceTemplateService;
|
import de.assecutor.votianlt.service.InvoiceTemplateService;
|
||||||
@@ -53,11 +51,7 @@ import java.util.Optional;
|
|||||||
import com.vaadin.flow.component.dialog.Dialog;
|
import com.vaadin.flow.component.dialog.Dialog;
|
||||||
import com.vaadin.flow.component.html.IFrame;
|
import com.vaadin.flow.component.html.IFrame;
|
||||||
|
|
||||||
// Route deaktiviert: das System erstellt keine eigenen Rechnungen mehr.
|
@Route(value = "create_invoice", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
|
||||||
// Code bleibt erhalten — die statische Methode showSavedInvoiceDialog(...) wird weiterhin
|
|
||||||
// genutzt, um vorhandene Rechnungs-PDFs anzuzeigen, und der DATEV-Export greift auf
|
|
||||||
// dieselben Backend-Services zu. Reaktivierung: nächste Zeile @Route entkommentieren.
|
|
||||||
// @Route(value = "create_invoice", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
|
|
||||||
@RolesAllowed({ "USER" })
|
@RolesAllowed({ "USER" })
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter<String>, HasDynamicTitle {
|
public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter<String>, HasDynamicTitle {
|
||||||
@@ -71,7 +65,6 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
private final UserInvoiceDataService userInvoiceDataService;
|
private final UserInvoiceDataService userInvoiceDataService;
|
||||||
private final CustomerService customerService;
|
private final CustomerService customerService;
|
||||||
private final InvoiceLifecycleService invoiceLifecycleService;
|
private final InvoiceLifecycleService invoiceLifecycleService;
|
||||||
private final EInvoiceService eInvoiceService;
|
|
||||||
private User currentUser;
|
private User currentUser;
|
||||||
private Job currentJob;
|
private Job currentJob;
|
||||||
private List<ServiceRow> gridRows = new ArrayList<>();
|
private List<ServiceRow> gridRows = new ArrayList<>();
|
||||||
@@ -126,7 +119,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
UserRepository userRepository, CustomerInvoiceService customerInvoiceService,
|
UserRepository userRepository, CustomerInvoiceService customerInvoiceService,
|
||||||
InvoiceTemplateService invoiceTemplateService, SecurityService securityService,
|
InvoiceTemplateService invoiceTemplateService, SecurityService securityService,
|
||||||
UserInvoiceDataService userInvoiceDataService, CustomerService customerService,
|
UserInvoiceDataService userInvoiceDataService, CustomerService customerService,
|
||||||
InvoiceLifecycleService invoiceLifecycleService, EInvoiceService eInvoiceService) {
|
InvoiceLifecycleService invoiceLifecycleService) {
|
||||||
this.jobRepository = jobRepository;
|
this.jobRepository = jobRepository;
|
||||||
this.serviceRepository = serviceRepository;
|
this.serviceRepository = serviceRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
@@ -136,7 +129,6 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
this.userInvoiceDataService = userInvoiceDataService;
|
this.userInvoiceDataService = userInvoiceDataService;
|
||||||
this.customerService = customerService;
|
this.customerService = customerService;
|
||||||
this.invoiceLifecycleService = invoiceLifecycleService;
|
this.invoiceLifecycleService = invoiceLifecycleService;
|
||||||
this.eInvoiceService = eInvoiceService;
|
|
||||||
setSizeFull();
|
setSizeFull();
|
||||||
setPadding(true);
|
setPadding(true);
|
||||||
setSpacing(true);
|
setSpacing(true);
|
||||||
@@ -594,17 +586,7 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
|
|||||||
invoice.setVatAmount(vatAmount);
|
invoice.setVatAmount(vatAmount);
|
||||||
invoice.setTotalAmount(totalAmount);
|
invoice.setTotalAmount(totalAmount);
|
||||||
|
|
||||||
// ZUGFeRD-Anreicherung und PAdES-Signatur sind unabhängig: der Nutzer kann
|
invoice.setPdfData(pdfBytes);
|
||||||
// beides einzeln im Profil aktivieren. Signatur ist strikt – fehlt das
|
|
||||||
// Zertifikat, schlägt das Speichern hier mit einer InvoiceLifecycleException
|
|
||||||
// fehl und wird unten als Notification angezeigt.
|
|
||||||
boolean withZugferd = eInvoiceService.isEInvoiceEnabledGlobally() && user.isEinvoiceEnabled();
|
|
||||||
boolean withSignature = user.isSignInvoicesEnabled();
|
|
||||||
byte[] finalPdf = pdfBytes;
|
|
||||||
if (withZugferd || withSignature) {
|
|
||||||
finalPdf = eInvoiceService.enhanceAndSign(pdfBytes, invoice, withZugferd, withSignature);
|
|
||||||
}
|
|
||||||
invoice.setPdfData(finalPdf);
|
|
||||||
|
|
||||||
// Finalisierung mit Audit-Eintrag und Eindeutigkeitsprüfung der Rechnungsnummer (R-07/R-11/R-36).
|
// Finalisierung mit Audit-Eintrag und Eindeutigkeitsprüfung der Rechnungsnummer (R-07/R-11/R-36).
|
||||||
CustomerInvoice savedInvoice = invoiceLifecycleService.createAndIssue(invoice,
|
CustomerInvoice savedInvoice = invoiceLifecycleService.createAndIssue(invoice,
|
||||||
|
|||||||
@@ -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.pages.service.UserInvoiceDataService;
|
||||||
import de.assecutor.votianlt.repository.ServiceRepository;
|
import de.assecutor.votianlt.repository.ServiceRepository;
|
||||||
import de.assecutor.votianlt.security.SecurityService;
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
import de.assecutor.votianlt.config.EInvoiceProperties;
|
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.SigningCredentialsPanel;
|
|
||||||
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
||||||
import de.assecutor.votianlt.service.InvoiceTemplateService;
|
import de.assecutor.votianlt.service.InvoiceTemplateService;
|
||||||
import de.assecutor.votianlt.service.LanguageService;
|
import de.assecutor.votianlt.service.LanguageService;
|
||||||
import de.assecutor.votianlt.service.SigningCredentialsService;
|
|
||||||
import com.vaadin.flow.component.grid.Grid;
|
import com.vaadin.flow.component.grid.Grid;
|
||||||
import com.vaadin.flow.component.combobox.ComboBox;
|
import com.vaadin.flow.component.combobox.ComboBox;
|
||||||
import com.vaadin.flow.component.textfield.NumberField;
|
import com.vaadin.flow.component.textfield.NumberField;
|
||||||
@@ -77,8 +74,6 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
private final UserInvoiceDataService userInvoiceDataService;
|
private final UserInvoiceDataService userInvoiceDataService;
|
||||||
private final CustomerInvoiceService customerInvoiceService;
|
private final CustomerInvoiceService customerInvoiceService;
|
||||||
private final InvoiceTemplateService invoiceTemplateService;
|
private final InvoiceTemplateService invoiceTemplateService;
|
||||||
private final SigningCredentialsService signingCredentialsService;
|
|
||||||
private final EInvoiceProperties eInvoiceProperties;
|
|
||||||
private UserInvoiceData currentInvoiceData;
|
private UserInvoiceData currentInvoiceData;
|
||||||
private Checkbox billingEnabled;
|
private Checkbox billingEnabled;
|
||||||
private NumberField vatRateField;
|
private NumberField vatRateField;
|
||||||
@@ -92,15 +87,12 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
|
|
||||||
public EditProfileView(UserService userService, UserInvoiceDataService userInvoiceDataService,
|
public EditProfileView(UserService userService, UserInvoiceDataService userInvoiceDataService,
|
||||||
CustomerInvoiceService customerInvoiceService, InvoiceTemplateService invoiceTemplateService,
|
CustomerInvoiceService customerInvoiceService, InvoiceTemplateService invoiceTemplateService,
|
||||||
LanguageService languageService, SecurityService securityService, ServiceRepository serviceRepository,
|
LanguageService languageService, SecurityService securityService, ServiceRepository serviceRepository) {
|
||||||
SigningCredentialsService signingCredentialsService, EInvoiceProperties eInvoiceProperties) {
|
|
||||||
this.userInvoiceDataService = userInvoiceDataService;
|
this.userInvoiceDataService = userInvoiceDataService;
|
||||||
this.customerInvoiceService = customerInvoiceService;
|
this.customerInvoiceService = customerInvoiceService;
|
||||||
this.invoiceTemplateService = invoiceTemplateService;
|
this.invoiceTemplateService = invoiceTemplateService;
|
||||||
this.currentUser = securityService.getCurrentDatabaseUser();
|
this.currentUser = securityService.getCurrentDatabaseUser();
|
||||||
this.serviceRepository = serviceRepository;
|
this.serviceRepository = serviceRepository;
|
||||||
this.signingCredentialsService = signingCredentialsService;
|
|
||||||
this.eInvoiceProperties = eInvoiceProperties;
|
|
||||||
|
|
||||||
// Store the original language before any changes
|
// Store the original language before any changes
|
||||||
this.originalLanguage = this.currentUser != null ? this.currentUser.getLanguage() : Language.DE;
|
this.originalLanguage = this.currentUser != null ? this.currentUser.getLanguage() : Language.DE;
|
||||||
@@ -375,32 +367,11 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Checkbox eInvoiceCheckbox = new Checkbox(getTranslation("profile.settings.einvoice"));
|
HorizontalLayout billingHeaderLayout = new HorizontalLayout(billingEnabled, prefixField, vatRateField);
|
||||||
eInvoiceCheckbox.setHelperText(getTranslation("profile.settings.einvoice.helper"));
|
|
||||||
eInvoiceCheckbox.setValue(currentUser.isEinvoiceEnabled());
|
|
||||||
eInvoiceCheckbox.addValueChangeListener(
|
|
||||||
e -> currentUser.setEinvoiceEnabled(Boolean.TRUE.equals(e.getValue())));
|
|
||||||
|
|
||||||
Checkbox signInvoicesCheckbox = new Checkbox(getTranslation("profile.settings.signinvoices"));
|
|
||||||
signInvoicesCheckbox.setHelperText(getTranslation("profile.settings.signinvoices.helper"));
|
|
||||||
signInvoicesCheckbox.setValue(currentUser.isSignInvoicesEnabled());
|
|
||||||
signInvoicesCheckbox.addValueChangeListener(
|
|
||||||
e -> currentUser.setSignInvoicesEnabled(Boolean.TRUE.equals(e.getValue())));
|
|
||||||
|
|
||||||
HorizontalLayout billingHeaderLayout = new HorizontalLayout(billingEnabled, prefixField, vatRateField,
|
|
||||||
eInvoiceCheckbox, signInvoicesCheckbox);
|
|
||||||
billingHeaderLayout.setSpacing(true);
|
billingHeaderLayout.setSpacing(true);
|
||||||
billingHeaderLayout.setAlignItems(FlexComponent.Alignment.BASELINE);
|
billingHeaderLayout.setAlignItems(FlexComponent.Alignment.BASELINE);
|
||||||
billingTab.add(billingHeaderLayout);
|
billingTab.add(billingHeaderLayout);
|
||||||
|
|
||||||
// Signatur-Credentials (Phase 5.5/5.6)
|
|
||||||
if (currentUser != null && currentUser.getId() != null) {
|
|
||||||
SigningCredentialsPanel signingPanel = new SigningCredentialsPanel(signingCredentialsService,
|
|
||||||
eInvoiceProperties, currentUser.getId().toHexString(), this::getTranslation);
|
|
||||||
signingPanel.setMaxWidth("760px");
|
|
||||||
billingTab.add(signingPanel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften)
|
// Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften)
|
||||||
final HorizontalLayout mainLayout = new HorizontalLayout();
|
final HorizontalLayout mainLayout = new HorizontalLayout();
|
||||||
mainLayout.setWidthFull();
|
mainLayout.setWidthFull();
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ import com.vaadin.flow.component.textfield.NumberField;
|
|||||||
import com.vaadin.flow.component.textfield.TextArea;
|
import com.vaadin.flow.component.textfield.TextArea;
|
||||||
import com.vaadin.flow.component.textfield.TextField;
|
import com.vaadin.flow.component.textfield.TextField;
|
||||||
import com.vaadin.flow.router.HasDynamicTitle;
|
import com.vaadin.flow.router.HasDynamicTitle;
|
||||||
// Route deaktiviert (siehe Klassen-Header) — die Anwendung erstellt/bearbeitet keine
|
import com.vaadin.flow.router.Route;
|
||||||
// Rechnungen mehr selbst, der Bestand wird per DATEV-Export weiterverarbeitet.
|
|
||||||
// import com.vaadin.flow.router.Route;
|
|
||||||
import com.vaadin.flow.server.StreamRegistration;
|
import com.vaadin.flow.server.StreamRegistration;
|
||||||
import com.vaadin.flow.server.StreamResource;
|
import com.vaadin.flow.server.StreamResource;
|
||||||
import de.assecutor.votianlt.model.User;
|
import de.assecutor.votianlt.model.User;
|
||||||
@@ -28,7 +26,6 @@ import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
|||||||
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
|
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
|
||||||
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
|
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
|
||||||
import de.assecutor.votianlt.model.invoices.InvoiceType;
|
import de.assecutor.votianlt.model.invoices.InvoiceType;
|
||||||
import de.assecutor.votianlt.model.invoices.PaymentStatus;
|
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
|
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
|
||||||
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
|
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
|
||||||
@@ -36,7 +33,6 @@ import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
|
|||||||
import de.assecutor.votianlt.repository.UserRepository;
|
import de.assecutor.votianlt.repository.UserRepository;
|
||||||
import de.assecutor.votianlt.security.SecurityService;
|
import de.assecutor.votianlt.security.SecurityService;
|
||||||
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
import de.assecutor.votianlt.service.CustomerInvoiceService;
|
||||||
import de.assecutor.votianlt.service.InvoiceApprovalService;
|
|
||||||
import de.assecutor.votianlt.service.InvoiceExportService;
|
import de.assecutor.votianlt.service.InvoiceExportService;
|
||||||
import de.assecutor.votianlt.service.InvoiceLifecycleException;
|
import de.assecutor.votianlt.service.InvoiceLifecycleException;
|
||||||
import de.assecutor.votianlt.service.InvoiceLifecycleService;
|
import de.assecutor.votianlt.service.InvoiceLifecycleService;
|
||||||
@@ -51,8 +47,7 @@ import java.util.List;
|
|||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
// @Route deaktiviert — Rechnungs-UI ist durch DATEV-Export ersetzt. Reaktivierung:
|
@Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
|
||||||
// @Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
|
|
||||||
@RolesAllowed({ "USER", "ADMIN" })
|
@RolesAllowed({ "USER", "ADMIN" })
|
||||||
public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
|
public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
|
||||||
|
|
||||||
@@ -66,14 +61,13 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
private final CustomerInvoiceService customerInvoiceService;
|
private final CustomerInvoiceService customerInvoiceService;
|
||||||
private final InvoiceExportService invoiceExportService;
|
private final InvoiceExportService invoiceExportService;
|
||||||
private final InvoicePermissionService invoicePermissionService;
|
private final InvoicePermissionService invoicePermissionService;
|
||||||
private final InvoiceApprovalService invoiceApprovalService;
|
|
||||||
private final UserInvoiceDataService userInvoiceDataService;
|
private final UserInvoiceDataService userInvoiceDataService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService,
|
public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService,
|
||||||
InvoiceLifecycleService invoiceLifecycleService, CustomerInvoiceService customerInvoiceService,
|
InvoiceLifecycleService invoiceLifecycleService, CustomerInvoiceService customerInvoiceService,
|
||||||
InvoiceExportService invoiceExportService, InvoicePermissionService invoicePermissionService,
|
InvoiceExportService invoiceExportService, InvoicePermissionService invoicePermissionService,
|
||||||
InvoiceApprovalService invoiceApprovalService, UserInvoiceDataService userInvoiceDataService,
|
UserInvoiceDataService userInvoiceDataService,
|
||||||
UserRepository userRepository) {
|
UserRepository userRepository) {
|
||||||
this.customerInvoiceRepository = customerInvoiceRepository;
|
this.customerInvoiceRepository = customerInvoiceRepository;
|
||||||
this.securityService = securityService;
|
this.securityService = securityService;
|
||||||
@@ -81,7 +75,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
this.customerInvoiceService = customerInvoiceService;
|
this.customerInvoiceService = customerInvoiceService;
|
||||||
this.invoiceExportService = invoiceExportService;
|
this.invoiceExportService = invoiceExportService;
|
||||||
this.invoicePermissionService = invoicePermissionService;
|
this.invoicePermissionService = invoicePermissionService;
|
||||||
this.invoiceApprovalService = invoiceApprovalService;
|
|
||||||
this.userInvoiceDataService = userInvoiceDataService;
|
this.userInvoiceDataService = userInvoiceDataService;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
|
|
||||||
@@ -110,10 +103,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
.setHeader(getTranslation("invoices.column.date")).setAutoWidth(true);
|
.setHeader(getTranslation("invoices.column.date")).setAutoWidth(true);
|
||||||
invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount"))
|
invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount"))
|
||||||
.setAutoWidth(true);
|
.setAutoWidth(true);
|
||||||
invoiceGrid.addComponentColumn(this::renderPaymentBadge)
|
|
||||||
.setHeader(getTranslation("invoices.column.payment")).setAutoWidth(true);
|
|
||||||
invoiceGrid.addColumn(this::formatOutstanding).setHeader(getTranslation("invoices.column.outstanding"))
|
|
||||||
.setAutoWidth(true);
|
|
||||||
invoiceGrid.addComponentColumn(this::renderActions)
|
invoiceGrid.addComponentColumn(this::renderActions)
|
||||||
.setHeader(getTranslation("invoices.column.actions")).setAutoWidth(true).setFlexGrow(0);
|
.setHeader(getTranslation("invoices.column.actions")).setAutoWidth(true).setFlexGrow(0);
|
||||||
|
|
||||||
@@ -165,29 +154,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
return badge;
|
return badge;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Component renderPaymentBadge(CustomerInvoice invoice) {
|
|
||||||
PaymentStatus status = invoice.getPaymentStatus() != null ? invoice.getPaymentStatus() : PaymentStatus.UNPAID;
|
|
||||||
Span badge = new Span(getTranslation("invoices.payment." + status.name().toLowerCase(Locale.ROOT)));
|
|
||||||
badge.getElement().getThemeList().add("badge");
|
|
||||||
switch (status) {
|
|
||||||
case PAID -> badge.getElement().getThemeList().add("success");
|
|
||||||
case PARTIALLY_PAID -> badge.getElement().getThemeList().add("contrast");
|
|
||||||
case OVERPAID -> badge.getElement().getThemeList().add("warning");
|
|
||||||
case REFUND_DUE -> badge.getElement().getThemeList().add("error");
|
|
||||||
default -> {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return badge;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String formatOutstanding(CustomerInvoice invoice) {
|
|
||||||
if (invoice.getTotalAmount() == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
java.math.BigDecimal outstanding = invoiceLifecycleService.computeOutstandingAmount(invoice);
|
|
||||||
return java.text.NumberFormat.getCurrencyInstance(Locale.GERMANY).format(outstanding);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Component renderTypeBadge(CustomerInvoice invoice) {
|
private Component renderTypeBadge(CustomerInvoice invoice) {
|
||||||
InvoiceType type = invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE;
|
InvoiceType type = invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE;
|
||||||
HorizontalLayout layout = new HorizontalLayout();
|
HorizontalLayout layout = new HorizontalLayout();
|
||||||
@@ -202,21 +168,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
badge.getElement().getThemeList().add("warning");
|
badge.getElement().getThemeList().add("warning");
|
||||||
}
|
}
|
||||||
layout.add(badge);
|
layout.add(badge);
|
||||||
|
|
||||||
if (invoice.getEInvoiceFormat() != null
|
|
||||||
&& invoice.getEInvoiceFormat() != de.assecutor.votianlt.model.invoices.EInvoiceFormat.NONE) {
|
|
||||||
Span eInvoiceBadge = new Span("ZUGFeRD");
|
|
||||||
eInvoiceBadge.getElement().getThemeList().add("badge");
|
|
||||||
eInvoiceBadge.getElement().getThemeList().add("primary");
|
|
||||||
eInvoiceBadge.setTitle(getTranslation("invoices.einvoice.tooltip"));
|
|
||||||
layout.add(eInvoiceBadge);
|
|
||||||
}
|
|
||||||
if (invoice.isSigned()) {
|
|
||||||
Span signedBadge = new Span("✓ " + getTranslation("invoices.einvoice.signed"));
|
|
||||||
signedBadge.getElement().getThemeList().add("badge");
|
|
||||||
signedBadge.getElement().getThemeList().add("success");
|
|
||||||
layout.add(signedBadge);
|
|
||||||
}
|
|
||||||
return layout;
|
return layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,28 +202,17 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isLiveInvoice) {
|
if (isLiveInvoice) {
|
||||||
boolean hasPendingRequest = !invoiceApprovalService
|
|
||||||
.findOpenForCurrentRequester().stream()
|
|
||||||
.filter(r -> invoice.getId().equals(r.getTargetInvoiceId()))
|
|
||||||
.toList().isEmpty();
|
|
||||||
|
|
||||||
if (invoicePermissionService.canCorrect(currentUser)) {
|
if (invoicePermissionService.canCorrect(currentUser)) {
|
||||||
String label = invoicePermissionService.requiresApproval(currentUser)
|
Button correctBtn = new Button(getTranslation("invoices.action.correct"),
|
||||||
? getTranslation("invoices.action.correct.request")
|
e -> openCorrectionDialog(invoice));
|
||||||
: getTranslation("invoices.action.correct");
|
|
||||||
Button correctBtn = new Button(label, e -> openCorrectionDialog(invoice));
|
|
||||||
correctBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
|
correctBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
|
||||||
correctBtn.setEnabled(!hasPendingRequest);
|
|
||||||
actions.add(correctBtn);
|
actions.add(correctBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invoicePermissionService.canCancel(currentUser)) {
|
if (invoicePermissionService.canCancel(currentUser)) {
|
||||||
String label = invoicePermissionService.requiresApproval(currentUser)
|
Button cancelBtn = new Button(getTranslation("invoices.action.cancel"),
|
||||||
? getTranslation("invoices.action.cancel.request")
|
e -> openCancellationDialog(invoice));
|
||||||
: getTranslation("invoices.action.cancel");
|
|
||||||
Button cancelBtn = new Button(label, e -> openCancellationDialog(invoice));
|
|
||||||
cancelBtn.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ERROR);
|
cancelBtn.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ERROR);
|
||||||
cancelBtn.setEnabled(!hasPendingRequest);
|
|
||||||
actions.add(cancelBtn);
|
actions.add(cancelBtn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -432,14 +372,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
User currentUser = invoicePermissionService.currentUser();
|
User currentUser = invoicePermissionService.currentUser();
|
||||||
try {
|
try {
|
||||||
invoicePermissionService.requireCancel(currentUser);
|
invoicePermissionService.requireCancel(currentUser);
|
||||||
if (invoicePermissionService.requiresApproval(currentUser)) {
|
|
||||||
invoiceApprovalService.requestCancellation(invoice.getId(), reason);
|
|
||||||
dialog.close();
|
|
||||||
Notification.show(getTranslation("invoices.notification.requested"), 4000,
|
|
||||||
Notification.Position.BOTTOM_END);
|
|
||||||
loadInvoices();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
User issuer = resolveIssuer(invoice);
|
User issuer = resolveIssuer(invoice);
|
||||||
String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId());
|
String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId());
|
||||||
LocalDate today = LocalDate.now();
|
LocalDate today = LocalDate.now();
|
||||||
@@ -506,15 +438,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
User currentUser = invoicePermissionService.currentUser();
|
User currentUser = invoicePermissionService.currentUser();
|
||||||
try {
|
try {
|
||||||
invoicePermissionService.requireCorrect(currentUser);
|
invoicePermissionService.requireCorrect(currentUser);
|
||||||
if (invoicePermissionService.requiresApproval(currentUser)) {
|
|
||||||
String requestReason = reason != null && !reason.isBlank() ? reason : correctedFields;
|
|
||||||
invoiceApprovalService.requestCorrection(invoice.getId(), correctedFields, requestReason);
|
|
||||||
dialog.close();
|
|
||||||
Notification.show(getTranslation("invoices.notification.requested"), 4000,
|
|
||||||
Notification.Position.BOTTOM_END);
|
|
||||||
loadInvoices();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
User issuer = resolveIssuer(invoice);
|
User issuer = resolveIssuer(invoice);
|
||||||
String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId());
|
String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId());
|
||||||
LocalDate today = LocalDate.now();
|
LocalDate today = LocalDate.now();
|
||||||
|
|||||||
@@ -16,11 +16,9 @@ import com.vaadin.flow.component.textfield.TextField;
|
|||||||
import com.vaadin.flow.data.renderer.ComponentRenderer;
|
import com.vaadin.flow.data.renderer.ComponentRenderer;
|
||||||
import com.vaadin.flow.data.value.ValueChangeMode;
|
import com.vaadin.flow.data.value.ValueChangeMode;
|
||||||
import com.vaadin.flow.router.HasDynamicTitle;
|
import com.vaadin.flow.router.HasDynamicTitle;
|
||||||
// Route deaktiviert (siehe Klassen-Header).
|
import com.vaadin.flow.router.Route;
|
||||||
// import com.vaadin.flow.router.Route;
|
|
||||||
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
|
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
|
||||||
// import bleibt auskommentiert, solange die @Route oben deaktiviert ist:
|
import de.assecutor.votianlt.pages.base.ui.view.MainLayout;
|
||||||
// import de.assecutor.votianlt.pages.base.ui.view.MainLayout;
|
|
||||||
import jakarta.annotation.security.RolesAllowed;
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
import com.vaadin.flow.component.UI;
|
import com.vaadin.flow.component.UI;
|
||||||
import com.vaadin.flow.server.StreamResource;
|
import com.vaadin.flow.server.StreamResource;
|
||||||
@@ -43,8 +41,7 @@ import java.util.Locale;
|
|||||||
* Modernisierte Optik: Responsive Karten, Lumo-Theme-Varianten, Status-Badges,
|
* Modernisierte Optik: Responsive Karten, Lumo-Theme-Varianten, Status-Badges,
|
||||||
* Suche und leere Zustandsanzeige.
|
* Suche und leere Zustandsanzeige.
|
||||||
*/
|
*/
|
||||||
// @Route deaktiviert — Rechnungs-UI ist durch DATEV-Export ersetzt. Reaktivierung:
|
@Route(value = "my-invoices", layout = MainLayout.class)
|
||||||
// @Route(value = "my-invoices", layout = MainLayout.class)
|
|
||||||
@RolesAllowed("USER")
|
@RolesAllowed("USER")
|
||||||
public class MyInvoicesView extends Main implements HasDynamicTitle {
|
public class MyInvoicesView extends Main implements HasDynamicTitle {
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
private final SecurityService securityService;
|
private final SecurityService securityService;
|
||||||
private final ClientConnectionService clientConnectionService;
|
private final ClientConnectionService clientConnectionService;
|
||||||
private final MessagingPublisher messagingPublisher;
|
private final MessagingPublisher messagingPublisher;
|
||||||
|
private final CustomerInvoiceRepository customerInvoiceRepository;
|
||||||
private final Grid<Job> grid = new Grid<>(Job.class, false);
|
private final Grid<Job> grid = new Grid<>(Job.class, false);
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@@ -61,6 +62,7 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
this.securityService = securityService;
|
this.securityService = securityService;
|
||||||
this.clientConnectionService = clientConnectionService;
|
this.clientConnectionService = clientConnectionService;
|
||||||
this.messagingPublisher = messagingPublisher;
|
this.messagingPublisher = messagingPublisher;
|
||||||
|
this.customerInvoiceRepository = customerInvoiceRepository;
|
||||||
setSizeFull();
|
setSizeFull();
|
||||||
setPadding(true);
|
setPadding(true);
|
||||||
setSpacing(true);
|
setSpacing(true);
|
||||||
@@ -140,10 +142,38 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle {
|
|||||||
return new com.vaadin.flow.component.html.Span();
|
return new com.vaadin.flow.component.html.Span();
|
||||||
}).setHeader("").setAutoWidth(true).setFlexGrow(0);
|
}).setHeader("").setAutoWidth(true).setFlexGrow(0);
|
||||||
|
|
||||||
// Rechnungs-Aktionen entfernt: Das System erstellt/verwaltet keine Rechnungen
|
// Invoice column - only show for completed jobs
|
||||||
// mehr aktiv aus der Jobs-Übersicht heraus. Bereits vorhandene Rechnungs-PDFs
|
grid.addComponentColumn(job -> {
|
||||||
// (Bestandsdaten) bleiben über den DATEV-Export bzw. die Backend-Repositories
|
if (job.getStatus() == JobStatus.COMPLETED) {
|
||||||
// zugänglich; ein dedizierter UI-Button im Jobs-Grid ist dafür nicht mehr nötig.
|
if (hasInvoice(job)) {
|
||||||
|
Button invoiceBtn = new Button(new Icon(VaadinIcon.FILE_TEXT_O));
|
||||||
|
invoiceBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
|
||||||
|
invoiceBtn.setTooltipText(getTranslation("jobs.tooltip.showinvoice"));
|
||||||
|
invoiceBtn.addClickListener(e -> {
|
||||||
|
e.getSource().getElement().getNode();
|
||||||
|
customerInvoiceRepository.findById(job.getInvoiceId()).ifPresentOrElse(invoice -> {
|
||||||
|
if (invoice.getPdfData() != null) {
|
||||||
|
CreateInvoiceView.showSavedInvoiceDialog(invoice.getPdfData(),
|
||||||
|
invoice.getInvoiceNumber(), this);
|
||||||
|
} else {
|
||||||
|
getUI().ifPresent(ui -> ui.navigate("create_invoice/" + job.getId().toHexString()));
|
||||||
|
}
|
||||||
|
}, () -> getUI().ifPresent(ui -> ui.navigate("create_invoice/" + job.getId().toHexString())));
|
||||||
|
});
|
||||||
|
return invoiceBtn;
|
||||||
|
}
|
||||||
|
|
||||||
|
Button createInvoiceBtn = new Button(new Icon(VaadinIcon.DOLLAR));
|
||||||
|
createInvoiceBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SUCCESS);
|
||||||
|
createInvoiceBtn.setTooltipText(getTranslation("jobs.tooltip.createinvoice"));
|
||||||
|
createInvoiceBtn.addClickListener(e -> {
|
||||||
|
e.getSource().getElement().getNode();
|
||||||
|
getUI().ifPresent(ui -> ui.navigate("create_invoice/" + job.getId().toHexString()));
|
||||||
|
});
|
||||||
|
return createInvoiceBtn;
|
||||||
|
}
|
||||||
|
return new com.vaadin.flow.component.html.Span();
|
||||||
|
}).setHeader("").setWidth("60px").setFlexGrow(0);
|
||||||
|
|
||||||
// Delete column (last column, right side)
|
// Delete column (last column, right side)
|
||||||
grid.addComponentColumn(job -> {
|
grid.addComponentColumn(job -> {
|
||||||
|
|||||||
@@ -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.User;
|
||||||
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
|
||||||
import de.assecutor.votianlt.model.invoices.EInvoiceFormat;
|
|
||||||
import de.assecutor.votianlt.model.invoices.InvoiceAuditAction;
|
import de.assecutor.votianlt.model.invoices.InvoiceAuditAction;
|
||||||
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
|
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
|
||||||
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
|
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
|
||||||
@@ -43,16 +42,14 @@ public class InvoiceLifecycleService {
|
|||||||
|
|
||||||
private final CustomerInvoiceRepository invoiceRepository;
|
private final CustomerInvoiceRepository invoiceRepository;
|
||||||
private final SecurityService securityService;
|
private final SecurityService securityService;
|
||||||
private final EInvoiceService eInvoiceService;
|
|
||||||
private final InvoiceComplianceValidator complianceValidator;
|
private final InvoiceComplianceValidator complianceValidator;
|
||||||
private final InvoiceNumberAuditService numberAuditService;
|
private final InvoiceNumberAuditService numberAuditService;
|
||||||
|
|
||||||
public InvoiceLifecycleService(CustomerInvoiceRepository invoiceRepository, SecurityService securityService,
|
public InvoiceLifecycleService(CustomerInvoiceRepository invoiceRepository, SecurityService securityService,
|
||||||
EInvoiceService eInvoiceService, InvoiceComplianceValidator complianceValidator,
|
InvoiceComplianceValidator complianceValidator,
|
||||||
InvoiceNumberAuditService numberAuditService) {
|
InvoiceNumberAuditService numberAuditService) {
|
||||||
this.invoiceRepository = invoiceRepository;
|
this.invoiceRepository = invoiceRepository;
|
||||||
this.securityService = securityService;
|
this.securityService = securityService;
|
||||||
this.eInvoiceService = eInvoiceService;
|
|
||||||
this.complianceValidator = complianceValidator;
|
this.complianceValidator = complianceValidator;
|
||||||
this.numberAuditService = numberAuditService;
|
this.numberAuditService = numberAuditService;
|
||||||
}
|
}
|
||||||
@@ -212,7 +209,7 @@ public class InvoiceLifecycleService {
|
|||||||
cancellation.setTotalAmount(negate(original.getTotalAmount()));
|
cancellation.setTotalAmount(negate(original.getTotalAmount()));
|
||||||
|
|
||||||
cancellation.setDescription("Stornorechnung zu Rechnung " + original.getInvoiceNumber());
|
cancellation.setDescription("Stornorechnung zu Rechnung " + original.getInvoiceNumber());
|
||||||
cancellation.setPdfData(applyEInvoiceIfApplicable(pdfData, cancellation, original));
|
cancellation.setPdfData(pdfData);
|
||||||
cancellation.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
|
cancellation.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
|
||||||
InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason);
|
InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason);
|
||||||
issuedEntry.setResultingInvoiceNumber(cancellationNumber);
|
issuedEntry.setResultingInvoiceNumber(cancellationNumber);
|
||||||
@@ -297,7 +294,7 @@ public class InvoiceLifecycleService {
|
|||||||
correction.setDescription(
|
correction.setDescription(
|
||||||
correctedFields == null || correctedFields.isBlank() ? descriptionPrefix
|
correctedFields == null || correctedFields.isBlank() ? descriptionPrefix
|
||||||
: descriptionPrefix + " — " + correctedFields);
|
: descriptionPrefix + " — " + correctedFields);
|
||||||
correction.setPdfData(applyEInvoiceIfApplicable(pdfData, correction, original));
|
correction.setPdfData(pdfData);
|
||||||
correction.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
|
correction.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
|
||||||
InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason);
|
InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason);
|
||||||
issuedEntry.setResultingInvoiceNumber(correctionNumber);
|
issuedEntry.setResultingInvoiceNumber(correctionNumber);
|
||||||
@@ -536,37 +533,4 @@ public class InvoiceLifecycleService {
|
|||||||
private BigDecimal negate(BigDecimal value) {
|
private BigDecimal negate(BigDecimal value) {
|
||||||
return value != null ? value.negate() : null;
|
return value != null ? value.negate() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reichert ein PDF mit ZUGFeRD-XML an und signiert es, falls Mustangproject systemweit
|
|
||||||
* aktiviert ist und das Original bereits ein E-Rechnungsformat hatte. So bleibt das
|
|
||||||
* Format eines Storno- oder Berichtigungsbelegs konsistent zur Originalrechnung.
|
|
||||||
*
|
|
||||||
* Fehlt das Signatur-Zertifikat oder kann es nicht entschlüsselt werden, wird die
|
|
||||||
* {@link InvoiceLifecycleException} bewusst durchgereicht — der Anwender soll die
|
|
||||||
* Storno-/Berichtigungs-Aktion korrigieren können (Zertifikat hochladen,
|
|
||||||
* Master-Key prüfen). Andere Fehlerklassen (z.B. PDF-Strukturfehler bei der
|
|
||||||
* ZUGFeRD-Anreicherung) bleiben graceful: das Roh-PDF wird zurückgegeben.
|
|
||||||
*/
|
|
||||||
private byte[] applyEInvoiceIfApplicable(byte[] pdfData, CustomerInvoice followUp, CustomerInvoice original) {
|
|
||||||
if (pdfData == null || pdfData.length == 0 || eInvoiceService == null || original == null) {
|
|
||||||
return pdfData;
|
|
||||||
}
|
|
||||||
boolean originalHadZugferd = original.getEInvoiceFormat() != null
|
|
||||||
&& original.getEInvoiceFormat() != EInvoiceFormat.NONE;
|
|
||||||
boolean originalWasSigned = original.isSigned();
|
|
||||||
boolean wantsZugferd = originalHadZugferd && eInvoiceService.isEInvoiceEnabledGlobally();
|
|
||||||
if (!wantsZugferd && !originalWasSigned) {
|
|
||||||
return pdfData;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return eInvoiceService.enhanceAndSign(pdfData, followUp, wantsZugferd, originalWasSigned);
|
|
||||||
} catch (InvoiceLifecycleException ex) {
|
|
||||||
// Signatur-/Zertifikatsproblem dem Anwender sichtbar machen
|
|
||||||
throw ex;
|
|
||||||
} catch (Exception ex) {
|
|
||||||
log.warn("E-Invoice-Anreicherung des Folgebelegs fehlgeschlagen: {}", ex.getMessage(), ex);
|
|
||||||
return pdfData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,24 +50,6 @@ public class InvoicePermissionService {
|
|||||||
return hasAnyInvoiceRole(user, InvoiceRoles.ACCOUNTANT, InvoiceRoles.APPROVER) || isUnscoped(user);
|
return hasAnyInvoiceRole(user, InvoiceRoles.ACCOUNTANT, InvoiceRoles.APPROVER) || isUnscoped(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean canApproveRequests(User user) {
|
|
||||||
return hasAnyInvoiceRole(user, InvoiceRoles.APPROVER) || isAdmin(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Liefert {@code true}, wenn die Aktion dieses Nutzers vor Ausführung eine Freigabe benötigt
|
|
||||||
* (R-42). Approver können ihre eigenen Aktionen direkt ausführen.
|
|
||||||
*/
|
|
||||||
public boolean requiresApproval(User user) {
|
|
||||||
if (user == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (canApproveRequests(user)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return user.isRequireApprovalForCriticalInvoiceActions();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void requireCreate(User user) {
|
public void requireCreate(User user) {
|
||||||
if (!canCreateOrIssue(user)) {
|
if (!canCreateOrIssue(user)) {
|
||||||
throw new InvoiceLifecycleException(
|
throw new InvoiceLifecycleException(
|
||||||
@@ -102,12 +84,6 @@ public class InvoicePermissionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void requireApprove(User user) {
|
|
||||||
if (!canApproveRequests(user)) {
|
|
||||||
throw new InvoiceLifecycleException("Sie haben keine Freigabe-Berechtigung.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convenience: prüft, ob der Nutzer Eigentümer einer Rechnung ist (oder Admin).
|
* Convenience: prüft, ob der Nutzer Eigentümer einer Rechnung ist (oder Admin).
|
||||||
* Wird genutzt, um Cross-Tenant-Zugriffe zu verhindern.
|
* Wird genutzt, um Cross-Tenant-Zugriffe zu verhindern.
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -95,34 +95,4 @@ spring.ai.openai.read-timeout=120s
|
|||||||
spring.ai.mcp.server.enabled=true
|
spring.ai.mcp.server.enabled=true
|
||||||
spring.ai.mcp.server.name=votianlt-mcp-server
|
spring.ai.mcp.server.name=votianlt-mcp-server
|
||||||
spring.ai.mcp.server.version=1.0.0
|
spring.ai.mcp.server.version=1.0.0
|
||||||
spring.ai.mcp.server.sse-message-endpoint=/mcp/message
|
spring.ai.mcp.server.sse-message-endpoint=/mcp/message
|
||||||
|
|
||||||
# ===========================================
|
|
||||||
# E-Rechnung (ZUGFeRD/Factur-X) und PAdES-Signatur
|
|
||||||
# ===========================================
|
|
||||||
# Aktivieren Sie die ZUGFeRD-Anreicherung systemweit. Pro Nutzer entscheidet
|
|
||||||
# zusätzlich das Profilfeld eInvoiceEnabled, ob es tatsächlich angewendet wird.
|
|
||||||
votianlt.einvoice.enabled=false
|
|
||||||
votianlt.einvoice.profile=EN16931
|
|
||||||
|
|
||||||
# PAdES-Signatur: nur aktiv, wenn unten ein gültiger Keystore konfiguriert ist.
|
|
||||||
votianlt.einvoice.signing.enabled=false
|
|
||||||
votianlt.einvoice.signing.keystore-path=
|
|
||||||
votianlt.einvoice.signing.keystore-password=
|
|
||||||
votianlt.einvoice.signing.key-alias=
|
|
||||||
votianlt.einvoice.signing.reason=Rechnung
|
|
||||||
votianlt.einvoice.signing.location=
|
|
||||||
votianlt.einvoice.signing.contact=
|
|
||||||
# Master-Key (>= 16 Zeichen) zum Verschlüsseln nutzerseitig hinterlegter PKCS#12-Keystores.
|
|
||||||
# Verlust dieses Keys macht alle gespeicherten Nutzer-Keystores unbrauchbar.
|
|
||||||
#
|
|
||||||
# SICHERHEITSEMPFEHLUNG (Stufe 2/3):
|
|
||||||
# - Den Key NIEMALS inline hier hinterlegen — nutzen Sie ENV oder eine Secret-Datei.
|
|
||||||
# - ENV-Variante: VOTIANLT_EINVOICE_SIGNING_MASTER_KEY=...
|
|
||||||
# (oder Lower-Case-Equivalent via Spring Relaxed Binding)
|
|
||||||
# - Secret-Datei-Variante: master-key-file zeigt auf eine Datei (Docker-/K8s-Secret),
|
|
||||||
# chmod 600 auf Bare-Metal/VM-Deployments.
|
|
||||||
# - Die Spring-Placeholder-Syntax ${VAR:default} liest die ENV automatisch.
|
|
||||||
# - application.properties selbst sollte nicht weltlesbar sein (chmod 600).
|
|
||||||
votianlt.einvoice.signing.master-key=${VOTIANLT_EINVOICE_SIGNING_MASTER_KEY:}
|
|
||||||
votianlt.einvoice.signing.master-key-file=${VOTIANLT_EINVOICE_SIGNING_MASTER_KEY_FILE:}
|
|
||||||
@@ -9,17 +9,6 @@ nav.customers=Adressbuch
|
|||||||
nav.appusers=App-Nutzer
|
nav.appusers=App-Nutzer
|
||||||
nav.statistics=Statistiken
|
nav.statistics=Statistiken
|
||||||
nav.invoices=Rechnungen
|
nav.invoices=Rechnungen
|
||||||
nav.datev.export=DATEV-Export
|
|
||||||
nav.approvals=Freigaben
|
|
||||||
datev.export.title=DATEV-Export
|
|
||||||
datev.export.description=Lädt einen DATEV-kompatiblen Buchungsstapel mit allen festgeschriebenen Rechnungen des gewählten Zeitraums herunter. Die Datei kann in DATEV Unternehmen Online sowie in DATEV-importfähigen Drittprogrammen eingelesen werden.
|
|
||||||
datev.export.from=Von
|
|
||||||
datev.export.to=Bis
|
|
||||||
datev.export.button=Rechnungen exportieren
|
|
||||||
datev.export.success=Export erstellt: {0}
|
|
||||||
datev.export.error.dates=Bitte Von- und Bis-Datum auswählen.
|
|
||||||
datev.export.error.range=Das Bis-Datum darf nicht vor dem Von-Datum liegen.
|
|
||||||
datev.export.error.user=Aktueller Nutzer konnte nicht ermittelt werden.
|
|
||||||
nav.messages=Nachrichten
|
nav.messages=Nachrichten
|
||||||
nav.profile=Mein Profil
|
nav.profile=Mein Profil
|
||||||
nav.myinvoices=Rechnungen
|
nav.myinvoices=Rechnungen
|
||||||
@@ -58,36 +47,6 @@ profile.settings.digitalprocess.info=Aufträge werden digital über die App abge
|
|||||||
profile.settings.locateappuser=App-Nutzer orten
|
profile.settings.locateappuser=App-Nutzer orten
|
||||||
profile.settings.locateappuser.info=Standort der App-Nutzer wird regelmäßig übertragen
|
profile.settings.locateappuser.info=Standort der App-Nutzer wird regelmäßig übertragen
|
||||||
profile.settings.vatrate=Umsatzsteuer
|
profile.settings.vatrate=Umsatzsteuer
|
||||||
profile.settings.einvoice=ZUGFeRD-E-Rechnung erstellen
|
|
||||||
profile.settings.einvoice.helper=Erzeugt PDF/A-3 mit eingebettetem XRechnung/ZUGFeRD-XML (sofern systemweit aktiviert).
|
|
||||||
profile.settings.signinvoices=Rechnungen digital signieren
|
|
||||||
profile.settings.signinvoices.helper=Erzeugt eine PAdES-Signatur mit dem hinterlegten Zertifikat. Ohne aktives Zertifikat schlägt das Speichern fehl.
|
|
||||||
profile.signing.title=Signatur-Zertifikat
|
|
||||||
profile.signing.hint=Hinterlegen Sie Ihr eigenes PKCS#12-Zertifikat (.p12/.pfx), damit Ihre Rechnungen mit Ihrer Signatur erstellt werden. Der private Schlüssel wird verschlüsselt in der Datenbank gespeichert.
|
|
||||||
profile.signing.masterkey.missing=Hinweis: Der Server-Master-Key ist nicht gesetzt. Bitten Sie Ihren Administrator, votianlt.einvoice.signing.master-key zu konfigurieren, bevor Sie ein Zertifikat hinterlegen.
|
|
||||||
profile.signing.none=Es ist noch kein eigenes Signatur-Zertifikat hinterlegt. Beim Signieren wird der systemweit konfigurierte Schlüssel verwendet.
|
|
||||||
profile.signing.metadata.alias=Alias
|
|
||||||
profile.signing.metadata.subject=Inhaber
|
|
||||||
profile.signing.metadata.issuer=Aussteller
|
|
||||||
profile.signing.metadata.serial=Seriennummer
|
|
||||||
profile.signing.metadata.validity=Gültig
|
|
||||||
profile.signing.expired=Zertifikat abgelaufen
|
|
||||||
profile.signing.expiring=Läuft in den nächsten 30 Tagen ab
|
|
||||||
profile.signing.enabled=Eigenes Zertifikat zum Signieren verwenden
|
|
||||||
profile.signing.toggle.saved=Einstellung gespeichert.
|
|
||||||
profile.signing.delete=Zertifikat entfernen
|
|
||||||
profile.signing.deleted=Signatur-Zertifikat entfernt.
|
|
||||||
profile.signing.upload.title=Zertifikat hochladen
|
|
||||||
profile.signing.upload.drop=PKCS#12-Datei hier ablegen oder klicken
|
|
||||||
profile.signing.upload.received=Datei empfangen — bitte Alias und Passwort angeben.
|
|
||||||
profile.signing.upload.required=Bitte zuerst eine Zertifikatsdatei hochladen.
|
|
||||||
profile.signing.upload.save=Speichern
|
|
||||||
profile.signing.alias=Schlüssel-Alias
|
|
||||||
profile.signing.alias.required=Bitte den Alias des Schlüssels angeben.
|
|
||||||
profile.signing.password=Keystore-Passwort
|
|
||||||
profile.signing.password.required=Bitte das Keystore-Passwort angeben.
|
|
||||||
profile.signing.saved=Signatur-Zertifikat gespeichert.
|
|
||||||
profile.signing.error=Speichern fehlgeschlagen
|
|
||||||
profile.account=Konto
|
profile.account=Konto
|
||||||
profile.security=Sicherheit
|
profile.security=Sicherheit
|
||||||
profile.security.twofactor=Zwei-Faktor-Authentifizierung
|
profile.security.twofactor=Zwei-Faktor-Authentifizierung
|
||||||
@@ -798,8 +757,6 @@ invoices.audit.action.replaced=Ersetzt durch neue Rechnung
|
|||||||
invoices.audit.action.deleted_draft=Entwurf gelöscht
|
invoices.audit.action.deleted_draft=Entwurf gelöscht
|
||||||
invoices.audit.action.payment_recorded=Zahlung erfasst
|
invoices.audit.action.payment_recorded=Zahlung erfasst
|
||||||
invoices.audit.resulting=Erzeugter Folgebeleg: {0}
|
invoices.audit.resulting=Erzeugter Folgebeleg: {0}
|
||||||
invoices.column.payment=Zahlung
|
|
||||||
invoices.column.outstanding=Offen
|
|
||||||
invoices.payment.unpaid=Offen
|
invoices.payment.unpaid=Offen
|
||||||
invoices.payment.partially_paid=Teilzahlung
|
invoices.payment.partially_paid=Teilzahlung
|
||||||
invoices.payment.paid=Bezahlt
|
invoices.payment.paid=Bezahlt
|
||||||
@@ -815,28 +772,6 @@ invoices.payment.reference=Zahlungsreferenz (z.B. Kontoauszug, Buchungs-Nr.)
|
|||||||
invoices.payment.reason=Anmerkung
|
invoices.payment.reason=Anmerkung
|
||||||
invoices.payment.confirm=Zahlung erfassen
|
invoices.payment.confirm=Zahlung erfassen
|
||||||
invoices.notification.payment=Zahlung erfasst.
|
invoices.notification.payment=Zahlung erfasst.
|
||||||
invoices.einvoice.tooltip=PDF/A-3 mit eingebettetem ZUGFeRD/XRechnung-XML
|
|
||||||
invoices.einvoice.signed=Signiert
|
|
||||||
invoices.action.cancel.request=Storno beantragen
|
|
||||||
invoices.action.correct.request=Berichtigung beantragen
|
|
||||||
invoices.notification.requested=Freigabe-Anfrage erstellt. Bitte auf Freigabe warten.
|
|
||||||
approvals.title=Freigaben
|
|
||||||
approvals.no.permission=Sie haben keine Berechtigung, Freigaben zu bearbeiten.
|
|
||||||
approvals.column.requested=Beantragt am
|
|
||||||
approvals.column.requester=Beantragt von
|
|
||||||
approvals.column.invoice=Rechnung
|
|
||||||
approvals.column.action=Aktion
|
|
||||||
approvals.column.reason=Grund
|
|
||||||
approvals.action.approve=Freigeben
|
|
||||||
approvals.action.reject=Ablehnen
|
|
||||||
approvals.confirm.approve.title=Anfrage zu Rechnung {0} freigeben
|
|
||||||
approvals.confirm.reject.title=Anfrage zu Rechnung {0} ablehnen
|
|
||||||
approvals.review.fields=Berichtigte Angaben
|
|
||||||
approvals.review.reason=Grund
|
|
||||||
approvals.review.comment=Kommentar (optional)
|
|
||||||
approvals.notification.approved=Anfrage freigegeben — Folgebeleg wurde erstellt.
|
|
||||||
approvals.notification.rejected=Anfrage abgelehnt.
|
|
||||||
page.title.approvals=Freigaben
|
|
||||||
|
|
||||||
# My Invoices
|
# My Invoices
|
||||||
myinvoices.title=Rechnungen
|
myinvoices.title=Rechnungen
|
||||||
|
|||||||
@@ -9,17 +9,6 @@ nav.customers=Address Book
|
|||||||
nav.appusers=App Users
|
nav.appusers=App Users
|
||||||
nav.statistics=Statistics
|
nav.statistics=Statistics
|
||||||
nav.invoices=Invoices
|
nav.invoices=Invoices
|
||||||
nav.datev.export=DATEV Export
|
|
||||||
nav.approvals=Approvals
|
|
||||||
datev.export.title=DATEV Export
|
|
||||||
datev.export.description=Downloads a DATEV-compatible booking batch containing all finalized invoices for the selected period. The file can be imported into DATEV Unternehmen Online as well as DATEV-compatible third-party tools.
|
|
||||||
datev.export.from=From
|
|
||||||
datev.export.to=To
|
|
||||||
datev.export.button=Export invoices
|
|
||||||
datev.export.success=Export created: {0}
|
|
||||||
datev.export.error.dates=Please pick both From and To dates.
|
|
||||||
datev.export.error.range=To date must not be before From date.
|
|
||||||
datev.export.error.user=Could not determine the current user.
|
|
||||||
nav.messages=Messages
|
nav.messages=Messages
|
||||||
nav.profile=My Profile
|
nav.profile=My Profile
|
||||||
nav.myinvoices=Invoices
|
nav.myinvoices=Invoices
|
||||||
@@ -58,36 +47,6 @@ profile.settings.digitalprocess.info=Jobs are processed digitally via the app
|
|||||||
profile.settings.locateappuser=Locate App Users
|
profile.settings.locateappuser=Locate App Users
|
||||||
profile.settings.locateappuser.info=App user location is transmitted regularly
|
profile.settings.locateappuser.info=App user location is transmitted regularly
|
||||||
profile.settings.vatrate=VAT rate
|
profile.settings.vatrate=VAT rate
|
||||||
profile.settings.einvoice=Create ZUGFeRD e-invoice
|
|
||||||
profile.settings.einvoice.helper=Generates PDF/A-3 with embedded XRechnung/ZUGFeRD XML (when enabled system-wide).
|
|
||||||
profile.settings.signinvoices=Digitally sign invoices
|
|
||||||
profile.settings.signinvoices.helper=Adds a PAdES signature using the configured certificate. Saving fails if no active certificate is available.
|
|
||||||
profile.signing.title=Signing certificate
|
|
||||||
profile.signing.hint=Upload your own PKCS#12 certificate (.p12/.pfx) so invoices are signed with your signature. The private key is stored encrypted in the database.
|
|
||||||
profile.signing.masterkey.missing=Note: The server master key is not configured. Ask your administrator to set votianlt.einvoice.signing.master-key before uploading a certificate.
|
|
||||||
profile.signing.none=No personal signing certificate stored yet. The system-wide key will be used when signing.
|
|
||||||
profile.signing.metadata.alias=Alias
|
|
||||||
profile.signing.metadata.subject=Subject
|
|
||||||
profile.signing.metadata.issuer=Issuer
|
|
||||||
profile.signing.metadata.serial=Serial number
|
|
||||||
profile.signing.metadata.validity=Valid
|
|
||||||
profile.signing.expired=Certificate expired
|
|
||||||
profile.signing.expiring=Expires within the next 30 days
|
|
||||||
profile.signing.enabled=Use my certificate for signing
|
|
||||||
profile.signing.toggle.saved=Setting saved.
|
|
||||||
profile.signing.delete=Remove certificate
|
|
||||||
profile.signing.deleted=Signing certificate removed.
|
|
||||||
profile.signing.upload.title=Upload certificate
|
|
||||||
profile.signing.upload.drop=Drop your PKCS#12 file here or click to upload
|
|
||||||
profile.signing.upload.received=File received — please provide alias and password.
|
|
||||||
profile.signing.upload.required=Please upload a certificate file first.
|
|
||||||
profile.signing.upload.save=Save
|
|
||||||
profile.signing.alias=Key alias
|
|
||||||
profile.signing.alias.required=Please provide the key alias.
|
|
||||||
profile.signing.password=Keystore password
|
|
||||||
profile.signing.password.required=Please provide the keystore password.
|
|
||||||
profile.signing.saved=Signing certificate saved.
|
|
||||||
profile.signing.error=Save failed
|
|
||||||
profile.account=Account
|
profile.account=Account
|
||||||
profile.security=Security
|
profile.security=Security
|
||||||
profile.security.twofactor=Two-Factor Authentication
|
profile.security.twofactor=Two-Factor Authentication
|
||||||
@@ -798,8 +757,6 @@ invoices.audit.action.replaced=Replaced by new invoice
|
|||||||
invoices.audit.action.deleted_draft=Draft deleted
|
invoices.audit.action.deleted_draft=Draft deleted
|
||||||
invoices.audit.action.payment_recorded=Payment recorded
|
invoices.audit.action.payment_recorded=Payment recorded
|
||||||
invoices.audit.resulting=Resulting document: {0}
|
invoices.audit.resulting=Resulting document: {0}
|
||||||
invoices.column.payment=Payment
|
|
||||||
invoices.column.outstanding=Outstanding
|
|
||||||
invoices.payment.unpaid=Open
|
invoices.payment.unpaid=Open
|
||||||
invoices.payment.partially_paid=Partially paid
|
invoices.payment.partially_paid=Partially paid
|
||||||
invoices.payment.paid=Paid
|
invoices.payment.paid=Paid
|
||||||
@@ -815,28 +772,6 @@ invoices.payment.reference=Payment reference (e.g. statement, booking ID)
|
|||||||
invoices.payment.reason=Note
|
invoices.payment.reason=Note
|
||||||
invoices.payment.confirm=Record payment
|
invoices.payment.confirm=Record payment
|
||||||
invoices.notification.payment=Payment recorded.
|
invoices.notification.payment=Payment recorded.
|
||||||
invoices.einvoice.tooltip=PDF/A-3 with embedded ZUGFeRD/XRechnung XML
|
|
||||||
invoices.einvoice.signed=Signed
|
|
||||||
invoices.action.cancel.request=Request cancellation
|
|
||||||
invoices.action.correct.request=Request correction
|
|
||||||
invoices.notification.requested=Approval request created. Please wait for approval.
|
|
||||||
approvals.title=Approvals
|
|
||||||
approvals.no.permission=You do not have permission to handle approvals.
|
|
||||||
approvals.column.requested=Requested at
|
|
||||||
approvals.column.requester=Requested by
|
|
||||||
approvals.column.invoice=Invoice
|
|
||||||
approvals.column.action=Action
|
|
||||||
approvals.column.reason=Reason
|
|
||||||
approvals.action.approve=Approve
|
|
||||||
approvals.action.reject=Reject
|
|
||||||
approvals.confirm.approve.title=Approve request for invoice {0}
|
|
||||||
approvals.confirm.reject.title=Reject request for invoice {0}
|
|
||||||
approvals.review.fields=Corrected information
|
|
||||||
approvals.review.reason=Reason
|
|
||||||
approvals.review.comment=Comment (optional)
|
|
||||||
approvals.notification.approved=Request approved — follow-up document created.
|
|
||||||
approvals.notification.rejected=Request rejected.
|
|
||||||
page.title.approvals=Approvals
|
|
||||||
|
|
||||||
# My Invoices
|
# My Invoices
|
||||||
myinvoices.title=Invoices
|
myinvoices.title=Invoices
|
||||||
|
|||||||
@@ -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