feat: E-Rechnungs-Backend, Pflichtangaben-Validator, Nummern-Audit und DATEV-Export

Größerer Wurf rund um Rechnungen: das vollständige E-Rechnungs- und
Lifecycle-Backend wird eingecheckt, die UI-seitige Rechnungserstellung wird
zugunsten eines DATEV-Exports zurückgebaut, und die Test-Infrastruktur wird
auf JDK 25 angehoben.

E-Rechnung & Signatur
- ZUGFeRD/Factur-X/XRechnung-Anreicherung via Mustang (EInvoiceService).
- PAdES-Detached-Signatur via PDFBox + BouncyCastle, System- und Nutzer-
  Keystore-Pfad, Nutzer-Keystores AES-GCM-verschlüsselt (AesGcmCipher,
  SigningCredentialsService, UserSigningCredentials).
- Konfiguration via EInvoiceProperties + EInvoiceSecurityInitializer.
- Approval-Workflow für kritische Rechnungsvorgänge (Storno, Berichtigung):
  InvoiceApprovalService, InvoiceApprovalRequest, ApprovalsView,
  InvoicePermissionService, InvoiceRoles.

Lifecycle & Audit
- InvoiceStatus DRAFT/ISSUED/SENT/CANCELLED/CORRECTED, InvoiceType
  INVOICE/CORRECTION/CANCELLATION, PaymentStatus, lückenloser Audit-Trail
  via InvoiceAuditEntry/Action.
- InvoiceLifecycleService verwaltet alle Übergänge inklusive Storno- und
  Berichtigungsbelegen mit Querverweis zur Originalrechnung.
- InvoiceLifecycleMigration zieht Bestandsdaten in das neue Lifecycle-Modell.
- InvoiceExportService bündelt Original + Folgebelege als ZIP für die
  Auslieferung an den Steuerberater.

Pflichtangaben-Validator (§ 14 UStG)
- InvoiceComplianceValidator + Exception sammeln alle Verstöße in einem
  Lauf (Pflichtfelder, Adressen, Steuernummer/USt-IdNr, Items, Betrags-
  konsistenz, Hinweispflicht bei 0 % USt).
- Wird vor jedem DRAFT-→-ISSUED-Übergang im Lifecycle aufgerufen, sodass
  festgeschriebene Rechnungen keine Pflichtfeld-Lücken mehr haben können.

Rechnungsnummer-Audit (§ 14 Abs. 4 Nr. 4 UStG / GoBD)
- InvoiceNumberReservation + Status RESERVED/USED/VOIDED protokollieren
  jede aus dem Counter gezogene Nummer.
- UserInvoiceDataService schreibt bei Vergabe ein RESERVED-Audit, der
  Lifecycle setzt nach Festschreiben USED bzw. nach Löschen eines Entwurfs
  VOIDED — Lücken im Nummernkreis sind dadurch erklärbar.
- InvoiceNumberAuditService liefert markUsed/markVoided/findUnused für
  Folge-UI und Betriebsprüfungs-Reports.

UI-Rückbau und DATEV-Export
- Routen für CreateInvoiceView, InvoicesView, MyInvoicesView auskommentiert
  (Code bleibt erhalten, Reaktivierung dokumentiert).
- Rechnungs-Buttons aus ShowJobsView entfernt, Nav-Eintrag „Rechnungen"
  durch „DATEV-Export" ersetzt.
- DatevExportService erzeugt einen DATEV-EXTF-Buchungsstapel (Version 7,
  Windows-1252, CRLF) mit SKR03-Erlöskonten (8400/8300/8125), Sammel-
  debitor 10000 und korrektem S/H-Verhalten für Stornorechnungen.
- DatevExportView mit Zeitraum-Picker und Auto-Download.
- i18n-Keys (de/en) für nav.datev.export und datev.export.*.

Tests & Build
- EInvoiceServiceTest (Signatur-Pfade), EInvoiceServiceDssValidationTest
  (PAdES-Profil via EU DSS 6.2 — dokumentiert PKCS7-B als Ist-Stand),
  InvoiceComplianceValidatorTest (26 Cases als Spezifikation der
  Pflichtangaben), InvoiceNumberAuditServiceTest, DatevExportServiceTest.
- Mockito 5.18 + ByteBuddy 1.17.5 in <dependencyManagement> gepinnt;
  die Spring-Boot-3.4.3-Defaults (Mockito 5.14.2 / ByteBuddy 1.15.11)
  konnten den Inline-Mock-Maker auf JDK 25 nicht laden, weshalb die
  beiden DemoModeServiceTests vorher rot waren.
- DSS-Test-Dependencies (dss-pades-pdfbox, dss-validation,
  dss-utils-apache-commons, dss-crl-parser-x509crl 6.2) im Test-Scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 10:34:04 +02:00
parent 5ac629c23d
commit d699609aa1
54 changed files with 6930 additions and 89 deletions

View File

@@ -44,6 +44,31 @@
<type>pom</type> <type>pom</type>
<scope>import</scope> <scope>import</scope>
</dependency> </dependency>
<!-- Mockito + ByteBuddy hochziehen, weil die Spring-Boot-3.4.3-Defaults
(Mockito 5.14.2 / ByteBuddy 1.15.11) den Inline-Mock-Maker auf
JDK 25 nicht laden können — die JVM lehnt die Class-Modifikation
von java.lang.Object ab. Mockito 5.18 + ByteBuddy 1.17.5 sind die
erste stabile Kombination, die JDK 25 offiziell trägt. -->
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.18.0</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.18.0</version>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.17.5</version>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy-agent</artifactId>
<version>1.17.5</version>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>
@@ -159,6 +184,20 @@
<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>
@@ -183,6 +222,32 @@
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- EU DSS: PAdES-Validierung der signierten Test-PDFs (nur Test-Scope) -->
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-pades-pdfbox</artifactId>
<version>6.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-validation</artifactId>
<version>6.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-utils-apache-commons</artifactId>
<version>6.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-crl-parser-x509crl</artifactId>
<version>6.2</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -0,0 +1,181 @@
package de.assecutor.votianlt.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* Konfiguration für ZUGFeRD-Anreicherung und PAdES-Signatur.
*
* Wird über {@code application.properties} mit dem Präfix
* {@code votianlt.einvoice} gesetzt. Beispiel:
*
* <pre>
* votianlt.einvoice.enabled=true
* votianlt.einvoice.profile=EN16931
* votianlt.einvoice.signing.enabled=true
* votianlt.einvoice.signing.keystore-path=/etc/votianlt/keystore.p12
* votianlt.einvoice.signing.keystore-password=changeit
* votianlt.einvoice.signing.key-alias=invoice-signer
* votianlt.einvoice.signing.reason=Rechnung
* votianlt.einvoice.signing.location=Berlin
* </pre>
*
* Sind die Werte nicht gesetzt, bleibt die Funktion deaktiviert. Die Nutzer-
* Einstellung {@code User.eInvoiceEnabled} entscheidet zusätzlich pro Konto,
* ob die Anreicherung tatsächlich angewendet wird.
*/
@Component
@ConfigurationProperties(prefix = "votianlt.einvoice")
public class EInvoiceProperties {
/** Globale Aktivierung der ZUGFeRD-Anreicherung. */
private boolean enabled = false;
/** ZUGFeRD-Profil (BASIC, COMFORT/EN16931, EXTENDED, XRECHNUNG). Default: EN16931. */
private String profile = "EN16931";
private final Signing signing = new Signing();
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getProfile() {
return profile;
}
public void setProfile(String profile) {
this.profile = profile;
}
public Signing getSigning() {
return signing;
}
public static class Signing {
/** Aktivierung der PAdES-Signatur. */
private boolean enabled = false;
/** Pfad zum PKCS#12-Keystore. */
private String keystorePath;
/** Passwort des Keystores (und des Schlüssels). */
private String keystorePassword;
/** Alias des zu verwendenden Schlüssels im Keystore. */
private String keyAlias;
/** Optionaler Anzeigegrund der Signatur. */
private String reason = "Rechnung";
/** Optionaler Anzeigeort der Signatur. */
private String location = "";
/** Optionaler Kontakt der Signatur. */
private String contact = "";
/**
* Master-Key (mind. 16 Zeichen) zum Verschlüsseln nutzerseitig hinterlegter
* PKCS#12-Keystores in der Datenbank. Wird via SHA-256 zu einem 256-Bit-AES-Schlüssel
* abgeleitet. Verlust dieses Keys macht alle gespeicherten Nutzer-Keystores unbrauchbar.
*
* Empfehlung: Wert per ENV oder via {@link #masterKeyFile} bereitstellen — niemals
* inline in der ausgelieferten {@code application.properties}.
*/
private String masterKey = "";
/**
* Optionaler Pfad zu einer Datei, deren Inhalt als Master-Key verwendet wird
* (Docker-/K8s-Secret-Style). Wenn gesetzt und lesbar, hat diese Datei Vorrang
* vor {@link #masterKey}. Empfohlen für produktive Deployments.
*/
private String masterKeyFile = "";
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getKeystorePath() {
return keystorePath;
}
public void setKeystorePath(String keystorePath) {
this.keystorePath = keystorePath;
}
public String getKeystorePassword() {
return keystorePassword;
}
public void setKeystorePassword(String keystorePassword) {
this.keystorePassword = keystorePassword;
}
public String getKeyAlias() {
return keyAlias;
}
public void setKeyAlias(String keyAlias) {
this.keyAlias = keyAlias;
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
public String getLocation() {
return location;
}
public void setLocation(String location) {
this.location = location;
}
public String getContact() {
return contact;
}
public void setContact(String contact) {
this.contact = contact;
}
public String getMasterKey() {
return masterKey;
}
public void setMasterKey(String masterKey) {
this.masterKey = masterKey;
}
public String getMasterKeyFile() {
return masterKeyFile;
}
public void setMasterKeyFile(String masterKeyFile) {
this.masterKeyFile = masterKeyFile;
}
public boolean isFullyConfigured() {
return enabled && keystorePath != null && !keystorePath.isBlank()
&& keystorePassword != null && keyAlias != null && !keyAlias.isBlank();
}
/** Liefert {@code true}, wenn ein nutzbarer Master-Key gesetzt ist. */
public boolean hasMasterKey() {
return masterKey != null && masterKey.length() >= 16;
}
}
}

View File

@@ -0,0 +1,118 @@
package de.assecutor.votianlt.config;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.Set;
/**
* Stufe 2/3 der E-Rechnungs-Sicherheit:
*
* <ul>
* <li>Lädt den Master-Key beim Startup aus einer optional konfigurierten Secret-Datei
* (Docker-/K8s-Secrets-Style). Inhalt wird einmalig in {@code Signing.masterKey}
* gespiegelt die Datei wird zur Laufzeit nicht erneut gelesen.</li>
* <li>Prüft die Dateiberechtigungen und warnt, wenn die Datei für andere Nutzer lesbar
* oder schreibbar ist (POSIX-Filesystems).</li>
* <li>Loggt einen kompakten Sicherheits-Banner mit Konfigurations-Quelle, ohne den
* Inhalt des Keys zu offenbaren.</li>
* </ul>
*
* Der Master-Key kann auf drei Arten gesetzt werden, in Vorrang-Reihenfolge:
* <ol>
* <li>{@code votianlt.einvoice.signing.master-key-file} — Pfad zu einer Secret-Datei</li>
* <li>{@code votianlt.einvoice.signing.master-key} — direkt gesetzte Property
* (z.B. via Spring-Placeholder {@code ${VOTIANLT_EINVOICE_MASTER_KEY}})</li>
* <li>nichts gesetzt — Funktion deaktiviert, gespeicherte Keystores bleiben unentschlüsselbar</li>
* </ol>
*/
@Component
public class EInvoiceSecurityInitializer {
private static final Logger log = LoggerFactory.getLogger(EInvoiceSecurityInitializer.class);
private final EInvoiceProperties properties;
public EInvoiceSecurityInitializer(EInvoiceProperties properties) {
this.properties = properties;
}
@PostConstruct
void initialize() {
EInvoiceProperties.Signing signing = properties.getSigning();
String source = "(nicht gesetzt)";
String fileFromConfig = signing.getMasterKeyFile();
if (fileFromConfig != null && !fileFromConfig.isBlank()) {
Path path = Path.of(fileFromConfig);
if (!Files.exists(path)) {
log.error("E-Invoice: Master-Key-Datei nicht gefunden: {}", path);
} else if (!Files.isReadable(path)) {
log.error("E-Invoice: Master-Key-Datei nicht lesbar: {}", path);
} else {
try {
String content = Files.readString(path).trim();
if (content.isBlank()) {
log.error("E-Invoice: Master-Key-Datei {} ist leer.", path);
} else {
signing.setMasterKey(content);
source = "Datei " + path;
warnIfPermissionsTooOpen(path);
}
} catch (IOException ex) {
log.error("E-Invoice: Master-Key-Datei {} konnte nicht gelesen werden: {}", path,
ex.getMessage());
}
}
} else if (signing.getMasterKey() != null && !signing.getMasterKey().isBlank()) {
source = "Property/ENV";
}
if (signing.hasMasterKey()) {
log.info("E-Invoice Master-Key aktiv (Quelle: {}, Länge: {} Zeichen).", source,
signing.getMasterKey().length());
} else {
log.warn("E-Invoice Master-Key nicht konfiguriert — nutzerseitig hinterlegte Signatur-Keystores "
+ "können nicht entschlüsselt werden. Setzen Sie votianlt.einvoice.signing.master-key oder "
+ "votianlt.einvoice.signing.master-key-file.");
}
}
/**
* Warnt, wenn die Secret-Datei für Gruppe oder Andere lesbar/schreibbar ist.
* In Container-Deployments (typische Docker/K8s-Secrets, Mode 0444) sind
* group/other-readable Berechtigungen nicht zwingend riskant der Hinweis
* dient als Auf­merksamkeitssignal für Bare-Metal- oder VM-Setups.
*/
private void warnIfPermissionsTooOpen(Path path) {
try {
Set<PosixFilePermission> perms = Files.getPosixFilePermissions(path);
boolean groupReadable = perms.contains(PosixFilePermission.GROUP_READ);
boolean otherReadable = perms.contains(PosixFilePermission.OTHERS_READ);
boolean otherWritable = perms.contains(PosixFilePermission.OTHERS_WRITE);
boolean groupWritable = perms.contains(PosixFilePermission.GROUP_WRITE);
if (otherWritable || groupWritable) {
log.error("E-Invoice: Master-Key-Datei {} ist für andere Nutzer SCHREIBBAR ({}). "
+ "Sofort die Berechtigungen härten (chmod 600).", path,
PosixFilePermissions.toString(perms));
} else if (otherReadable || groupReadable) {
log.warn("E-Invoice: Master-Key-Datei {} ist für andere Nutzer lesbar ({}). "
+ "Empfehlung für Bare-Metal/VM-Deployments: chmod 600. "
+ "Im Container ist dies meist akzeptabel.", path, PosixFilePermissions.toString(perms));
}
} catch (UnsupportedOperationException ex) {
// POSIX-Permissions auf Nicht-Unix (z.B. Windows) nicht verfügbar — Check überspringen.
} catch (IOException ex) {
log.warn("E-Invoice: Berechtigungsprüfung der Master-Key-Datei {} fehlgeschlagen: {}", path,
ex.getMessage());
}
}
}

View File

@@ -0,0 +1,59 @@
package de.assecutor.votianlt.config;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceAuditAction;
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
/**
* Einmalige Migration der vorhandenen Rechnungen auf das neue Lifecycle-Modell.
*
* Bestandsdaten haben weder Status noch Typ. Sie werden konservativ auf
* <code>type=INVOICE, status=ISSUED</code> gesetzt sie sind in der Praxis bereits
* ausgestellt, denn sie tragen eine Rechnungsnummer und enthalten ein PDF.
*
* Ein Audit-Eintrag dokumentiert die Migration (R-37).
*/
@Component
@Order(50)
public class InvoiceLifecycleMigration implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(InvoiceLifecycleMigration.class);
private final CustomerInvoiceRepository invoiceRepository;
public InvoiceLifecycleMigration(CustomerInvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
@Override
public void run(String... args) {
List<CustomerInvoice> legacyInvoices = invoiceRepository.findByStatusIsNull();
if (legacyInvoices.isEmpty()) {
return;
}
LocalDateTime now = LocalDateTime.now();
for (CustomerInvoice invoice : legacyInvoices) {
invoice.setType(InvoiceType.INVOICE);
invoice.setStatus(InvoiceStatus.ISSUED);
if (invoice.getIssuedAt() == null) {
invoice.setIssuedAt(now);
}
InvoiceAuditEntry entry = new InvoiceAuditEntry(InvoiceAuditAction.ISSUED, null, "system",
"Migration: Bestandsrechnung auf Status ISSUED gesetzt.");
invoice.addAuditEntry(entry);
}
invoiceRepository.saveAll(legacyInvoices);
log.info("Lifecycle-Migration: {} Bestandsrechnungen auf Status ISSUED gesetzt.", legacyInvoices.size());
}
}

View File

@@ -72,4 +72,29 @@ public class User {
// Umsatzsteuer-Satz (als Dezimalwert, z.B. 0.19 für 19 %) // Umsatzsteuer-Satz (als Dezimalwert, z.B. 0.19 für 19 %)
private BigDecimal vatRate = new BigDecimal("0.19"); private BigDecimal vatRate = new BigDecimal("0.19");
/**
* Optionaler Freigabe-Workflow für kritische Rechnungsvorgänge (R-42).
*
* Ist das Flag aktiv, müssen Storno- und Berichtigungsbelege durch einen Nutzer mit
* {@link de.assecutor.votianlt.security.InvoiceRoles#APPROVER}-Rolle freigegeben werden,
* bevor sie tatsächlich erzeugt werden.
*/
private boolean requireApprovalForCriticalInvoiceActions = false;
/**
* Aktiviert ZUGFeRD/Factur-X-Anreicherung beim Speichern der Rechnungs-PDFs.
* Greift nur, wenn auch systemweit über
* {@link de.assecutor.votianlt.config.EInvoiceProperties} aktiviert ist.
*/
private boolean einvoiceEnabled = false;
/**
* Aktiviert die digitale PAdES-Signatur der erzeugten Rechnungs-PDFs.
* Funktioniert unabhängig von {@link #einvoiceEnabled}: signierte Standard-PDFs
* sind ebenso möglich wie signierte ZUGFeRD-PDFs.
* Voraussetzung: ein hinterlegtes Nutzer-Zertifikat oder ein systemweit
* konfigurierter Keystore.
*/
private boolean signInvoicesEnabled = false;
} }

View File

@@ -3,7 +3,9 @@ package de.assecutor.votianlt.model.invoices;
import org.springframework.data.annotation.Id; import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List; import java.util.List;
@Document(collection = "customerInvoices") @Document(collection = "customerInvoices")
@@ -12,6 +14,39 @@ public class CustomerInvoice {
@Id @Id
private String id; private String id;
// Lebenszyklus und Belegtyp gemäß invoices_rules R-01 bis R-22
private InvoiceStatus status = InvoiceStatus.DRAFT;
private InvoiceType type = InvoiceType.INVOICE;
// Verknüpfung auf die Originalrechnung bei Korrektur- oder Stornobelegen (R-10, R-13, R-19, R-22, R-30)
private String originalInvoiceId;
private String originalInvoiceNumber;
private LocalDate originalInvoiceDate;
// Verkettung: bei stornierten/korrigierten Originalen Verweise auf die erzeugten Folgebelege.
private String cancellationInvoiceId;
private String correctionInvoiceId;
private String replacementInvoiceId;
// Zeitstempel für Statusübergänge
private LocalDateTime issuedAt;
private LocalDateTime sentAt;
private LocalDateTime cancelledAt;
// Änderungsprotokoll (R-36 bis R-39)
private List<InvoiceAuditEntry> auditLog = new ArrayList<>();
// Zahlungsstatus gemäß R-23 bis R-26
private PaymentStatus paymentStatus = PaymentStatus.UNPAID;
private BigDecimal paidAmount;
private LocalDateTime lastPaymentAt;
// E-Rechnung / Signatur-Marker (Mustangproject + iText sign)
private EInvoiceFormat eInvoiceFormat = EInvoiceFormat.NONE;
private boolean signed = false;
private LocalDateTime signedAt;
private String signedBy;
// Pflichtangaben nach §14 UStG (German VAT law) // 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
@@ -372,4 +407,166 @@ public class CustomerInvoice {
public void setPdfData(byte[] pdfData) { public void setPdfData(byte[] pdfData) {
this.pdfData = pdfData; this.pdfData = pdfData;
} }
public InvoiceStatus getStatus() {
return status;
}
public void setStatus(InvoiceStatus status) {
this.status = status;
}
public InvoiceType getType() {
return type;
}
public void setType(InvoiceType type) {
this.type = type;
}
public String getOriginalInvoiceId() {
return originalInvoiceId;
}
public void setOriginalInvoiceId(String originalInvoiceId) {
this.originalInvoiceId = originalInvoiceId;
}
public String getOriginalInvoiceNumber() {
return originalInvoiceNumber;
}
public void setOriginalInvoiceNumber(String originalInvoiceNumber) {
this.originalInvoiceNumber = originalInvoiceNumber;
}
public LocalDate getOriginalInvoiceDate() {
return originalInvoiceDate;
}
public void setOriginalInvoiceDate(LocalDate originalInvoiceDate) {
this.originalInvoiceDate = originalInvoiceDate;
}
public String getCancellationInvoiceId() {
return cancellationInvoiceId;
}
public void setCancellationInvoiceId(String cancellationInvoiceId) {
this.cancellationInvoiceId = cancellationInvoiceId;
}
public String getCorrectionInvoiceId() {
return correctionInvoiceId;
}
public void setCorrectionInvoiceId(String correctionInvoiceId) {
this.correctionInvoiceId = correctionInvoiceId;
}
public String getReplacementInvoiceId() {
return replacementInvoiceId;
}
public void setReplacementInvoiceId(String replacementInvoiceId) {
this.replacementInvoiceId = replacementInvoiceId;
}
public LocalDateTime getIssuedAt() {
return issuedAt;
}
public void setIssuedAt(LocalDateTime issuedAt) {
this.issuedAt = issuedAt;
}
public LocalDateTime getSentAt() {
return sentAt;
}
public void setSentAt(LocalDateTime sentAt) {
this.sentAt = sentAt;
}
public LocalDateTime getCancelledAt() {
return cancelledAt;
}
public void setCancelledAt(LocalDateTime cancelledAt) {
this.cancelledAt = cancelledAt;
}
public List<InvoiceAuditEntry> getAuditLog() {
if (auditLog == null) {
auditLog = new ArrayList<>();
}
return auditLog;
}
public void setAuditLog(List<InvoiceAuditEntry> auditLog) {
this.auditLog = auditLog != null ? auditLog : new ArrayList<>();
}
public void addAuditEntry(InvoiceAuditEntry entry) {
if (entry == null) {
return;
}
getAuditLog().add(entry);
}
public PaymentStatus getPaymentStatus() {
return paymentStatus;
}
public void setPaymentStatus(PaymentStatus paymentStatus) {
this.paymentStatus = paymentStatus;
}
public BigDecimal getPaidAmount() {
return paidAmount;
}
public void setPaidAmount(BigDecimal paidAmount) {
this.paidAmount = paidAmount;
}
public LocalDateTime getLastPaymentAt() {
return lastPaymentAt;
}
public void setLastPaymentAt(LocalDateTime lastPaymentAt) {
this.lastPaymentAt = lastPaymentAt;
}
public EInvoiceFormat getEInvoiceFormat() {
return eInvoiceFormat;
}
public void setEInvoiceFormat(EInvoiceFormat eInvoiceFormat) {
this.eInvoiceFormat = eInvoiceFormat;
}
public boolean isSigned() {
return signed;
}
public void setSigned(boolean signed) {
this.signed = signed;
}
public LocalDateTime getSignedAt() {
return signedAt;
}
public void setSignedAt(LocalDateTime signedAt) {
this.signedAt = signedAt;
}
public String getSignedBy() {
return signedBy;
}
public void setSignedBy(String signedBy) {
this.signedBy = signedBy;
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
package de.assecutor.votianlt.model.invoices;
/**
* Aktionen, die im Rechnungs-Audit-Log gemäß R-36 protokolliert werden.
*/
public enum InvoiceAuditAction {
CREATED_DRAFT, UPDATED_DRAFT, ISSUED, SENT, CANCELLED, CORRECTED, REPLACED, DELETED_DRAFT, PAYMENT_RECORDED
}

View File

@@ -0,0 +1,89 @@
package de.assecutor.votianlt.model.invoices;
import java.time.LocalDateTime;
/**
* Einzelner Eintrag im Änderungsprotokoll einer Rechnung gemäß R-36 bis R-39.
*
* Eingebettet in {@link CustomerInvoice}; wird ausschließlich angehängt, niemals
* geändert. Hält Wer/Wann/Was/Warum sowie ggf. den erzeugten Folgebeleg fest.
*/
public class InvoiceAuditEntry {
private LocalDateTime timestamp;
private String userId;
private String userDisplayName;
private InvoiceAuditAction action;
private String reason;
/** Optionale Referenz auf einen erzeugten Folgebeleg (Korrektur, Storno, Ersatzrechnung). */
private String resultingInvoiceId;
private String resultingInvoiceNumber;
public InvoiceAuditEntry() {
}
public InvoiceAuditEntry(InvoiceAuditAction action, String userId, String userDisplayName, String reason) {
this.timestamp = LocalDateTime.now();
this.action = action;
this.userId = userId;
this.userDisplayName = userDisplayName;
this.reason = reason;
}
public LocalDateTime getTimestamp() {
return timestamp;
}
public void setTimestamp(LocalDateTime timestamp) {
this.timestamp = timestamp;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getUserDisplayName() {
return userDisplayName;
}
public void setUserDisplayName(String userDisplayName) {
this.userDisplayName = userDisplayName;
}
public InvoiceAuditAction getAction() {
return action;
}
public void setAction(InvoiceAuditAction action) {
this.action = action;
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
public String getResultingInvoiceId() {
return resultingInvoiceId;
}
public void setResultingInvoiceId(String resultingInvoiceId) {
this.resultingInvoiceId = resultingInvoiceId;
}
public String getResultingInvoiceNumber() {
return resultingInvoiceNumber;
}
public void setResultingInvoiceNumber(String resultingInvoiceNumber) {
this.resultingInvoiceNumber = resultingInvoiceNumber;
}
}

View File

@@ -0,0 +1,61 @@
package de.assecutor.votianlt.model.invoices;
import lombok.Data;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.index.CompoundIndexes;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.Instant;
/**
* Audit-Eintrag für jede aus dem Rechnungsnummern-Counter gezogene Nummer.
* Erlaubt nachzuweisen, dass jede Lücke im fortlaufenden Nummernkreis erklärt
* werden kann (vergeben aber nicht ausgestellt → entweder offen oder begründet
* verworfen). Pflichtgrundlage: § 14 Abs. 4 Nr. 4 UStG i.V.m. GoBD.
*
* Pro (userId, sequence) existiert genau eine Reservierung — ein Eindeutigkeits-
* Index erzwingt das auch bei nebenläufigen Aufrufen.
*/
@Data
@Document(collection = "invoice_number_reservations")
@CompoundIndexes({
@CompoundIndex(name = "user_sequence_unique", def = "{'userId': 1, 'sequence': 1}", unique = true),
@CompoundIndex(name = "user_status", def = "{'userId': 1, 'status': 1}")
})
public class InvoiceNumberReservation {
@Id
private ObjectId id;
@Indexed
private ObjectId userId;
/** Vollständige formatierte Rechnungsnummer wie sie auf dem Beleg erscheint (Präfix + Sequenz). */
private String number;
/** Roh-Sequenznummer aus dem Counter — Basis für Lücken-Analyse. */
private long sequence;
/** Präfix, mit dem die Nummer formatiert wurde — relevant, falls der Anwender den Präfix später ändert. */
private String prefix;
private Instant reservedAt;
/** Anzeigename des reservierenden Nutzers (z.B. „Anna Müller") oder „system" für Hintergrundprozesse. */
private String reservedBy;
private InvoiceNumberReservationStatus status;
/** Bei status=USED: ID der ausgestellten Rechnung. */
private String invoiceId;
private Instant usedAt;
/** Bei status=VOIDED: vom Anwender erfasster Grund — Pflichtfeld für betriebsprüfungstaugliche Erklärung. */
private String voidReason;
private Instant voidedAt;
}

View File

@@ -0,0 +1,17 @@
package de.assecutor.votianlt.model.invoices;
/**
* Status einer aus dem Nummernkreis gezogenen Rechnungsnummer.
*
* RESERVED Nummer wurde aus dem Counter gezogen, aber noch keine Rechnung dazu festgeschrieben.
* Bleibt eine Reservierung lange in diesem Zustand, deutet das auf einen
* abgebrochenen Erstell-Prozess hin und produziert eine erklärungsbedürftige
* Lücke im Nummernkreis (§ 14 Abs. 4 Nr. 4 UStG).
* USED Eine festgeschriebene Rechnung trägt diese Nummer; lücken-unkritisch.
* VOIDED Reservierung wurde bewusst verworfen (Erstellprozess abgebrochen, Anwender
* hat erklärt, warum die Nummer nicht ausgestellt wurde) — Lücke ist
* dokumentiert und betriebsprüfungstauglich.
*/
public enum InvoiceNumberReservationStatus {
RESERVED, USED, VOIDED
}

View File

@@ -0,0 +1,22 @@
package de.assecutor.votianlt.model.invoices;
/**
* Lebenszyklus einer Rechnung gemäß R-01 bis R-04.
*
* DRAFT noch in Bearbeitung, darf editiert oder gelöscht werden.
* ISSUED formal ausgestellt/gebucht, darf nicht mehr direkt überschrieben werden.
* SENT an den Empfänger versendet.
* CANCELLED durch eine Stornorechnung aufgehoben.
* CORRECTED durch eine Berichtigung formal korrigiert (Original bleibt sichtbar).
*/
public enum InvoiceStatus {
DRAFT, ISSUED, SENT, CANCELLED, CORRECTED;
public boolean isFinalized() {
return this != DRAFT;
}
public boolean isMutable() {
return this == DRAFT;
}
}

View File

@@ -0,0 +1,14 @@
package de.assecutor.votianlt.model.invoices;
/**
* Belegtyp einer Rechnung gemäß R-09, R-12 ff. und R-17 ff.
*
* INVOICE reguläre Ausgangsrechnung.
* CORRECTION Rechnungsberichtigung für formale Fehler (R-12 bis R-16).
* Verweist eindeutig auf die zu korrigierende Originalrechnung.
* CANCELLATION Stornorechnung für wirtschaftliche Fehler (R-17 bis R-22).
* Verweist eindeutig auf die zu stornierende Originalrechnung.
*/
public enum InvoiceType {
INVOICE, CORRECTION, CANCELLATION
}

View File

@@ -0,0 +1,14 @@
package de.assecutor.votianlt.model.invoices;
/**
* Zahlungsstatus einer Rechnung gemäß R-23 bis R-26.
*
* UNPAID noch nicht bezahlt.
* PARTIALLY_PAID Teilzahlung erhalten, Restbetrag offen.
* PAID vollständig bezahlt.
* OVERPAID Zahlbetrag übersteigt den Rechnungsbetrag.
* REFUND_DUE Erstattungsbetrag offen (z.B. nach Storno einer bezahlten Rechnung).
*/
public enum PaymentStatus {
UNPAID, PARTIALLY_PAID, PAID, OVERPAID, REFUND_DUE
}

View File

@@ -0,0 +1,163 @@
package de.assecutor.votianlt.model.invoices;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
/**
* Pro Nutzer hinterlegte Signatur-Credentials für die Erzeugung von signierten
* (PAdES) E-Rechnungen.
*
* Sicherheitshinweise:
* <ul>
* <li>Der private Schlüssel (komplette PKCS#12-Datei) wird vor dem Speichern
* mit AES-256-GCM unter dem systemweiten Master-Key
* ({@code votianlt.einvoice.signing.master-key}) verschlüsselt.</li>
* <li>Das Keystore-Passwort wird ebenfalls AES-GCM-verschlüsselt persistiert.</li>
* <li>Beide Felder enthalten die Initialisierungs-IV als Präfix
* (12 Byte IV + Ciphertext+Tag).</li>
* <li>Der Master-Key darf nicht verloren gehen — andernfalls sind alle
* hinterlegten Keystores nicht mehr verwendbar und müssen vom Nutzer
* neu hochgeladen werden.</li>
* </ul>
*
* Die Klar-Metadaten (Subject/Issuer/Gültigkeit/Alias) werden unverschlüsselt
* abgelegt, damit das Profil sie ohne Master-Key anzeigen kann (Statussicht
* funktioniert auch nach Schlüsselverlust).
*/
@Document(collection = "userSigningCredentials")
public class UserSigningCredentials {
@Id
private String id;
@Indexed(unique = true)
private String userId;
/** Verschlüsselter PKCS#12-Keystore (AES-GCM, IV-präfiziert). */
private byte[] encryptedKeystore;
/** Verschlüsseltes Keystore-Passwort (AES-GCM, IV-präfiziert, Base64). */
private String encryptedPassword;
private String keyAlias;
// Klartext-Metadaten zum Zertifikat (Anzeige im Profil ohne Entschlüsselung)
private String subjectDn;
private String issuerDn;
private String serialNumber;
private LocalDateTime validFrom;
private LocalDateTime validUntil;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/** Nutzerseitiger Schalter, der die Verwendung der eigenen Credentials aktiviert. */
private boolean enabled = true;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public byte[] getEncryptedKeystore() {
return encryptedKeystore;
}
public void setEncryptedKeystore(byte[] encryptedKeystore) {
this.encryptedKeystore = encryptedKeystore;
}
public String getEncryptedPassword() {
return encryptedPassword;
}
public void setEncryptedPassword(String encryptedPassword) {
this.encryptedPassword = encryptedPassword;
}
public String getKeyAlias() {
return keyAlias;
}
public void setKeyAlias(String keyAlias) {
this.keyAlias = keyAlias;
}
public String getSubjectDn() {
return subjectDn;
}
public void setSubjectDn(String subjectDn) {
this.subjectDn = subjectDn;
}
public String getIssuerDn() {
return issuerDn;
}
public void setIssuerDn(String issuerDn) {
this.issuerDn = issuerDn;
}
public String getSerialNumber() {
return serialNumber;
}
public void setSerialNumber(String serialNumber) {
this.serialNumber = serialNumber;
}
public LocalDateTime getValidFrom() {
return validFrom;
}
public void setValidFrom(LocalDateTime validFrom) {
this.validFrom = validFrom;
}
public LocalDateTime getValidUntil() {
return validUntil;
}
public void setValidUntil(LocalDateTime validUntil) {
this.validUntil = validUntil;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}

View File

@@ -0,0 +1,255 @@
package de.assecutor.votianlt.pages.base.ui.component;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.checkbox.Checkbox;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.H3;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.PasswordField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.component.upload.Upload;
import com.vaadin.flow.component.upload.receivers.MemoryBuffer;
import com.vaadin.flow.i18n.I18NProvider;
import de.assecutor.votianlt.config.EInvoiceProperties;
import de.assecutor.votianlt.model.invoices.UserSigningCredentials;
import de.assecutor.votianlt.service.SigningCredentialsService;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Optional;
import java.util.function.Function;
/**
* UI-Panel zur Verwaltung der nutzerseitigen PKCS#12-Signatur-Credentials
* (Phase 5.5 / 5.6 der Rechnungsregeln).
*
* Wird in {@link de.assecutor.votianlt.pages.view.EditProfileView} eingebunden.
* Die Klasse ist absichtlich keine Vaadin-{@code @Route} und keine Spring-Bean —
* sie ist eine gewöhnliche Vaadin-Komponente, die mit den nötigen Services
* konstruiert und vom Profil-View positioniert wird.
*/
public class SigningCredentialsPanel extends VerticalLayout {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY);
private final SigningCredentialsService credentialsService;
private final EInvoiceProperties properties;
private final String userId;
private final Function<String, String> tr;
private final Div statusContainer = new Div();
public SigningCredentialsPanel(SigningCredentialsService credentialsService, EInvoiceProperties properties,
String userId, Function<String, String> translate) {
this.credentialsService = credentialsService;
this.properties = properties;
this.userId = userId;
this.tr = translate;
setPadding(true);
setSpacing(true);
setWidthFull();
addClassName("surface-panel");
getStyle().set("border", "1px solid var(--lumo-contrast-10pct)").set("border-radius",
"var(--lumo-border-radius-l)");
H3 heading = new H3(tr.apply("profile.signing.title"));
add(heading);
Span hint = new Span(tr.apply("profile.signing.hint"));
hint.getStyle().set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-s)");
add(hint);
if (!properties.getSigning().hasMasterKey()) {
Span masterKeyWarning = new Span(tr.apply("profile.signing.masterkey.missing"));
masterKeyWarning.getStyle().set("color", "var(--lumo-error-text-color)")
.set("font-size", "var(--lumo-font-size-s)");
add(masterKeyWarning);
}
add(statusContainer);
renderStatus();
add(buildUploadSection());
}
private void renderStatus() {
statusContainer.removeAll();
Optional<UserSigningCredentials> credentialsOpt = credentialsService.findCredentials(userId);
if (credentialsOpt.isEmpty()) {
Span empty = new Span(tr.apply("profile.signing.none"));
empty.getStyle().set("color", "var(--lumo-secondary-text-color)");
statusContainer.add(empty);
return;
}
UserSigningCredentials credentials = credentialsOpt.get();
Div card = new Div();
card.getStyle().set("padding", "12px 16px").set("background", "var(--lumo-contrast-5pct)")
.set("border-radius", "var(--lumo-border-radius-m)");
addRow(card, tr.apply("profile.signing.metadata.alias"), credentials.getKeyAlias());
addRow(card, tr.apply("profile.signing.metadata.subject"), credentials.getSubjectDn());
addRow(card, tr.apply("profile.signing.metadata.issuer"), credentials.getIssuerDn());
addRow(card, tr.apply("profile.signing.metadata.serial"), credentials.getSerialNumber());
String validity = formatDate(credentials.getValidFrom()) + "" + formatDate(credentials.getValidUntil());
addRow(card, tr.apply("profile.signing.metadata.validity"), validity);
LocalDateTime now = LocalDateTime.now();
if (credentials.getValidUntil() != null) {
if (credentials.getValidUntil().isBefore(now)) {
Span warn = new Span(tr.apply("profile.signing.expired"));
warn.getElement().getThemeList().add("badge");
warn.getElement().getThemeList().add("error");
card.add(new Div(warn));
} else if (credentials.getValidUntil().isBefore(now.plusDays(30))) {
Span warn = new Span(tr.apply("profile.signing.expiring"));
warn.getElement().getThemeList().add("badge");
warn.getElement().getThemeList().add("warning");
card.add(new Div(warn));
}
}
Checkbox enabledCheckbox = new Checkbox(tr.apply("profile.signing.enabled"));
enabledCheckbox.setValue(credentials.isEnabled());
enabledCheckbox.addValueChangeListener(e -> {
credentialsService.setEnabled(userId, Boolean.TRUE.equals(e.getValue()));
Notification.show(tr.apply("profile.signing.toggle.saved"), 2000, Notification.Position.BOTTOM_END);
});
Button deleteBtn = new Button(tr.apply("profile.signing.delete"), ev -> {
credentialsService.deleteForUser(userId);
Notification.show(tr.apply("profile.signing.deleted"), 2500, Notification.Position.BOTTOM_END);
renderStatus();
});
deleteBtn.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY);
HorizontalLayout actions = new HorizontalLayout(enabledCheckbox, deleteBtn);
actions.setSpacing(true);
actions.setAlignItems(Alignment.CENTER);
actions.getStyle().set("margin-top", "12px");
card.add(actions);
statusContainer.add(card);
}
private Div buildUploadSection() {
Div section = new Div();
section.getStyle().set("margin-top", "16px").set("padding", "12px 16px")
.set("border", "1px dashed var(--lumo-contrast-20pct)")
.set("border-radius", "var(--lumo-border-radius-m)");
Span label = new Span(tr.apply("profile.signing.upload.title"));
label.getStyle().set("font-weight", "600");
section.add(label);
MemoryBuffer buffer = new MemoryBuffer();
Upload upload = new Upload(buffer);
upload.setAcceptedFileTypes("application/x-pkcs12", ".p12", ".pfx");
upload.setMaxFiles(1);
upload.setMaxFileSize(2 * 1024 * 1024); // 2 MB sind für PKCS#12 reichlich.
upload.setDropLabel(new Span(tr.apply("profile.signing.upload.drop")));
upload.setWidthFull();
upload.getStyle().set("margin-top", "8px");
TextField aliasField = new TextField(tr.apply("profile.signing.alias"));
aliasField.setWidthFull();
aliasField.setRequiredIndicatorVisible(true);
PasswordField passwordField = new PasswordField(tr.apply("profile.signing.password"));
passwordField.setWidthFull();
passwordField.setRequiredIndicatorVisible(true);
Span uploadStatus = new Span();
uploadStatus.getStyle().set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-s)");
upload.addSucceededListener(event -> uploadStatus.setText(tr.apply("profile.signing.upload.received")));
upload.addFileRejectedListener(event -> uploadStatus.setText(event.getErrorMessage()));
Button saveBtn = new Button(tr.apply("profile.signing.upload.save"), ev -> {
if (buffer.getFileData() == null || buffer.getFileData().getFileName() == null
|| buffer.getFileData().getFileName().isBlank()) {
Notification.show(tr.apply("profile.signing.upload.required"), 4000, Notification.Position.MIDDLE);
return;
}
if (aliasField.isEmpty()) {
aliasField.setInvalid(true);
aliasField.setErrorMessage(tr.apply("profile.signing.alias.required"));
return;
}
if (passwordField.isEmpty()) {
passwordField.setInvalid(true);
passwordField.setErrorMessage(tr.apply("profile.signing.password.required"));
return;
}
try {
byte[] bytes = buffer.getInputStream().readAllBytes();
credentialsService.store(userId, bytes, passwordField.getValue(), aliasField.getValue().trim());
Notification.show(tr.apply("profile.signing.saved"), 3000, Notification.Position.BOTTOM_END);
aliasField.clear();
passwordField.clear();
uploadStatus.setText("");
renderStatus();
} catch (IllegalArgumentException ex) {
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
} catch (IllegalStateException ex) {
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
} catch (Exception ex) {
Notification.show(tr.apply("profile.signing.error") + ": " + ex.getMessage(), 6000,
Notification.Position.MIDDLE);
}
});
saveBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
VerticalLayout fields = new VerticalLayout(upload, uploadStatus, aliasField, passwordField, saveBtn);
fields.setPadding(false);
fields.setSpacing(true);
section.add(fields);
if (!properties.getSigning().hasMasterKey()) {
saveBtn.setEnabled(false);
upload.setVisible(true);
saveBtn.setTooltipText(tr.apply("profile.signing.masterkey.missing"));
}
return section;
}
private void addRow(Div container, String label, String value) {
if (value == null || value.isBlank()) {
return;
}
HorizontalLayout row = new HorizontalLayout();
row.setSpacing(true);
row.setPadding(false);
Span lbl = new Span(label);
lbl.getStyle().set("min-width", "140px").set("color", "var(--lumo-secondary-text-color)");
Span val = new Span(value);
val.getStyle().set("font-family", "var(--lumo-font-family)");
row.add(lbl, val);
container.add(row);
}
private String formatDate(LocalDateTime ts) {
return ts == null ? "" : ts.format(DATE_FORMAT);
}
/**
* Convenience-Konstruktor, der einen {@link I18NProvider} statt einer
* Translate-Funktion akzeptiert. So können Aufrufer ohne View-Kontext
* leichter eigene Übersetzungen einspeisen.
*/
public SigningCredentialsPanel(SigningCredentialsService credentialsService, EInvoiceProperties properties,
String userId, I18NProvider i18nProvider, Locale locale) {
this(credentialsService, properties, userId,
key -> i18nProvider.getTranslation(key, locale != null ? locale : Locale.getDefault()));
}
}

View File

@@ -145,8 +145,12 @@ 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.invoices"), "invoices", VaadinIcon.FILE_TEXT)); new MenuTreeItem(getTranslation("nav.datev.export"), "datev-export", VaadinIcon.DOWNLOAD));
treeData.addItem(verwaltungItem,
new MenuTreeItem(getTranslation("nav.approvals"), "approvals", VaadinIcon.CHECK_CIRCLE));
treeData.addItem(verwaltungItem, treeData.addItem(verwaltungItem,
new MenuTreeItem(getTranslation("nav.customers"), "customers", VaadinIcon.USERS)); new MenuTreeItem(getTranslation("nav.customers"), "customers", VaadinIcon.USERS));
treeData.addItem(verwaltungItem, treeData.addItem(verwaltungItem,

View File

@@ -1,8 +1,14 @@
package de.assecutor.votianlt.pages.service; package de.assecutor.votianlt.pages.service;
import de.assecutor.votianlt.model.UserInvoiceData; import de.assecutor.votianlt.model.UserInvoiceData;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus;
import de.assecutor.votianlt.repository.InvoiceNumberReservationRepository;
import de.assecutor.votianlt.repository.UserInvoiceDataRepository; import de.assecutor.votianlt.repository.UserInvoiceDataRepository;
import de.assecutor.votianlt.security.SecurityService;
import org.bson.types.ObjectId; import org.bson.types.ObjectId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.mongodb.core.FindAndModifyOptions; import org.springframework.data.mongodb.core.FindAndModifyOptions;
import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Criteria;
@@ -10,17 +16,25 @@ import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update; import org.springframework.data.mongodb.core.query.Update;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class UserInvoiceDataService { public class UserInvoiceDataService {
private static final Logger log = LoggerFactory.getLogger(UserInvoiceDataService.class);
private final UserInvoiceDataRepository userInvoiceDataRepository; private final UserInvoiceDataRepository userInvoiceDataRepository;
private final MongoTemplate mongoTemplate; private final MongoTemplate mongoTemplate;
private final InvoiceNumberReservationRepository reservationRepository;
private final SecurityService securityService;
public UserInvoiceDataService(UserInvoiceDataRepository userInvoiceDataRepository, MongoTemplate mongoTemplate) { public UserInvoiceDataService(UserInvoiceDataRepository userInvoiceDataRepository, MongoTemplate mongoTemplate,
InvoiceNumberReservationRepository reservationRepository, SecurityService securityService) {
this.userInvoiceDataRepository = userInvoiceDataRepository; this.userInvoiceDataRepository = userInvoiceDataRepository;
this.mongoTemplate = mongoTemplate; this.mongoTemplate = mongoTemplate;
this.reservationRepository = reservationRepository;
this.securityService = securityService;
} }
public Optional<UserInvoiceData> findByUserId(ObjectId userId) { public Optional<UserInvoiceData> findByUserId(ObjectId userId) {
@@ -64,6 +78,12 @@ public class UserInvoiceDataService {
/** /**
* Generiert atomar die nächste Rechnungsnummer für den Benutzer und erhöht den * Generiert atomar die nächste Rechnungsnummer für den Benutzer und erhöht den
* Zähler um 1. Gibt die vollständige Rechnungsnummer zurück (Präfix + Nummer). * Zähler um 1. Gibt die vollständige Rechnungsnummer zurück (Präfix + Nummer).
*
* Jede Vergabe wird als {@link InvoiceNumberReservation} mit Status RESERVED
* persistiert. Damit ist auch nachvollziehbar, wenn eine Nummer aus dem
* Counter gezogen, aber nie zu einer ausgestellten Rechnung wird (abgebrochener
* Erstell-Prozess, fehlgeschlagene Validierung). Die Reservierung wird später
* vom Lifecycle-Service auf USED bzw. VOIDED gesetzt.
*/ */
public String generateNextInvoiceNumber(ObjectId userId) { public String generateNextInvoiceNumber(ObjectId userId) {
Query query = Query.query(Criteria.where("userId").is(userId)); Query query = Query.query(Criteria.where("userId").is(userId));
@@ -75,11 +95,56 @@ public class UserInvoiceDataService {
// Kein Eintrag vorhanden - Fallback auf aktuelle Daten // Kein Eintrag vorhanden - Fallback auf aktuelle Daten
return findByUserId(userId).map(d -> { return findByUserId(userId).map(d -> {
String prefix = d.getPrefix() != null ? d.getPrefix() : ""; String prefix = d.getPrefix() != null ? d.getPrefix() : "";
return prefix + String.format("%06d", d.getNextInvoiceNumber()); long sequence = d.getNextInvoiceNumber();
String number = prefix + String.format("%06d", sequence);
recordReservation(userId, number, sequence, prefix);
return number;
}).orElse("000000"); }).orElse("000000");
} }
String prefix = before.getPrefix() != null ? before.getPrefix() : ""; String prefix = before.getPrefix() != null ? before.getPrefix() : "";
return prefix + String.format("%06d", before.getNextInvoiceNumber()); long sequence = before.getNextInvoiceNumber();
String number = prefix + String.format("%06d", sequence);
recordReservation(userId, number, sequence, prefix);
return number;
}
/**
* Persistiert die Reservierung einer Nummer. Das Schreiben des Audit-Eintrags
* ist von der Counter-Vergabe entkoppelt: Sollte das Audit-Repository
* vorübergehend ausfallen, geht die Nummer-Vergabe nicht verloren — wir
* loggen den Fehler und vertrauen darauf, dass die anschließende Lücken-
* Analyse auf Basis der ausgestellten Rechnungen die fehlende Reservierung
* sichtbar macht.
*/
private void recordReservation(ObjectId userId, String number, long sequence, String prefix) {
try {
InvoiceNumberReservation reservation = new InvoiceNumberReservation();
reservation.setUserId(userId);
reservation.setNumber(number);
reservation.setSequence(sequence);
reservation.setPrefix(prefix);
reservation.setReservedAt(Instant.now());
reservation.setReservedBy(currentUserDisplayName());
reservation.setStatus(InvoiceNumberReservationStatus.RESERVED);
reservationRepository.save(reservation);
} catch (Exception ex) {
log.warn("Reservierung der Rechnungsnummer {} (User {}) konnte nicht persistiert werden: {}",
number, userId, ex.getMessage(), ex);
}
}
private String currentUserDisplayName() {
try {
var user = securityService.getCurrentDatabaseUser();
String composed = (safe(user.getFirstname()) + " " + safe(user.getName())).trim();
return composed.isBlank() ? safe(user.getEmail()) : composed;
} catch (Exception ignored) {
return "system";
}
}
private String safe(String value) {
return value != null ? value : "";
} }
} }

View File

@@ -0,0 +1,205 @@
package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route;
import de.assecutor.votianlt.model.invoices.InvoiceApprovalAction;
import de.assecutor.votianlt.model.invoices.InvoiceApprovalRequest;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import de.assecutor.votianlt.service.InvoiceApprovalService;
import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoicePermissionService;
import jakarta.annotation.security.RolesAllowed;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
/**
* Freigabe-Posteingang gemäß R-42. Listet offene Storno-/Berichtigungs-Anfragen
* und erlaubt Approver-berechtigten Nutzern die Freigabe oder Ablehnung.
*
* Wird über die Route {@code /approvals} erreicht. Der Zugriff ist nur für Nutzer
* mit Rolle {@code USER}/{@code ADMIN} formal offen — die feingranulare Prüfung
* erfolgt durch den {@link InvoicePermissionService}, der im Render-Pfad
* unberechtigte Nutzer mit einem entsprechenden Hinweis abblockt.
*/
@Route(value = "approvals", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER", "ADMIN" })
public class ApprovalsView extends VerticalLayout implements HasDynamicTitle {
private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm",
Locale.GERMANY);
private final InvoiceApprovalService invoiceApprovalService;
private final Grid<InvoiceApprovalRequest> grid;
public ApprovalsView(InvoiceApprovalService invoiceApprovalService,
InvoicePermissionService invoicePermissionService) {
this.invoiceApprovalService = invoiceApprovalService;
setSizeFull();
setPadding(true);
setSpacing(true);
setJustifyContentMode(FlexComponent.JustifyContentMode.CENTER);
setDefaultHorizontalComponentAlignment(FlexComponent.Alignment.CENTER);
addClassName("data-view");
add(new ViewToolbar(getTranslation("approvals.title")));
if (!invoicePermissionService.canApproveRequests(invoicePermissionService.currentUser())) {
Span hint = new Span(getTranslation("approvals.no.permission"));
hint.getStyle().set("color", "var(--lumo-secondary-text-color)");
add(hint);
grid = null;
return;
}
grid = new Grid<>(InvoiceApprovalRequest.class, false);
grid.setWidthFull();
grid.addClassName("data-grid");
grid.addColumn(this::formatTimestamp).setHeader(getTranslation("approvals.column.requested"))
.setAutoWidth(true);
grid.addColumn(InvoiceApprovalRequest::getRequestedByDisplayName)
.setHeader(getTranslation("approvals.column.requester")).setAutoWidth(true);
grid.addColumn(InvoiceApprovalRequest::getTargetInvoiceNumber)
.setHeader(getTranslation("approvals.column.invoice")).setAutoWidth(true);
grid.addComponentColumn(this::renderActionBadge).setHeader(getTranslation("approvals.column.action"))
.setAutoWidth(true);
grid.addColumn(InvoiceApprovalRequest::getReason).setHeader(getTranslation("approvals.column.reason"))
.setFlexGrow(1);
grid.addComponentColumn(this::renderActions).setHeader(getTranslation("invoices.column.actions"))
.setAutoWidth(true).setFlexGrow(0);
Div panel = new Div(grid);
panel.addClassNames("surface-panel", "data-grid-panel");
panel.setWidthFull();
add(panel);
reload();
}
private void reload() {
if (grid != null) {
grid.setItems(invoiceApprovalService.findPending());
}
}
private String formatTimestamp(InvoiceApprovalRequest req) {
return req.getRequestedAt() != null ? req.getRequestedAt().format(DATE_TIME_FMT) : "";
}
private Component renderActionBadge(InvoiceApprovalRequest req) {
InvoiceApprovalAction action = req.getAction();
Span badge = new Span(action == InvoiceApprovalAction.CANCEL_INVOICE
? getTranslation("invoices.type.cancellation")
: getTranslation("invoices.type.correction"));
badge.getElement().getThemeList().add("badge");
badge.getElement().getThemeList().add(action == InvoiceApprovalAction.CANCEL_INVOICE ? "error" : "warning");
return badge;
}
private Component renderActions(InvoiceApprovalRequest req) {
HorizontalLayout layout = new HorizontalLayout();
layout.setSpacing(true);
layout.setPadding(false);
Button approveBtn = new Button(getTranslation("approvals.action.approve"), e -> openReviewDialog(req, true));
approveBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS,
ButtonVariant.LUMO_SMALL);
layout.add(approveBtn);
Button rejectBtn = new Button(getTranslation("approvals.action.reject"), e -> openReviewDialog(req, false));
rejectBtn.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_SMALL);
layout.add(rejectBtn);
return layout;
}
private void openReviewDialog(InvoiceApprovalRequest req, boolean approve) {
String titleKey = approve ? "approvals.confirm.approve.title" : "approvals.confirm.reject.title";
Dialog dialog = DialogStylingHelper.createStyledDialog(getTranslation(titleKey, req.getTargetInvoiceNumber()),
"520px");
VerticalLayout content = new VerticalLayout();
content.setSpacing(true);
content.setPadding(false);
if (req.getCorrectedFields() != null && !req.getCorrectedFields().isBlank()) {
Span label = new Span(getTranslation("approvals.review.fields"));
label.getStyle().set("font-weight", "600");
content.add(label);
Div fields = new Div();
fields.setText(req.getCorrectedFields());
fields.getStyle().set("white-space", "pre-wrap");
content.add(fields);
}
if (req.getReason() != null && !req.getReason().isBlank()) {
Span label = new Span(getTranslation("approvals.review.reason"));
label.getStyle().set("font-weight", "600").set("margin-top", "8px");
content.add(label);
Div reason = new Div();
reason.setText(req.getReason());
reason.getStyle().set("white-space", "pre-wrap");
content.add(reason);
}
TextArea commentField = new TextArea(getTranslation("approvals.review.comment"));
commentField.setWidthFull();
commentField.setMinHeight("80px");
content.add(commentField);
dialog.add(DialogStylingHelper.wrapContent(content));
Button cancelBtn = new Button(getTranslation("button.cancel"), e -> dialog.close());
cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button confirmBtn = new Button(
getTranslation(approve ? "approvals.action.approve" : "approvals.action.reject"), e -> {
try {
if (approve) {
invoiceApprovalService.approve(req.getId(), commentField.getValue());
Notification.show(getTranslation("approvals.notification.approved"), 4000,
Notification.Position.BOTTOM_END);
} else {
invoiceApprovalService.reject(req.getId(), commentField.getValue());
Notification.show(getTranslation("approvals.notification.rejected"), 4000,
Notification.Position.BOTTOM_END);
}
dialog.close();
reload();
} catch (InvoiceLifecycleException ex) {
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
} catch (Exception ex) {
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
Notification.Position.MIDDLE);
}
});
if (approve) {
confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS);
} else {
confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_ERROR);
}
dialog.getFooter().add(cancelBtn, confirmBtn);
dialog.open();
}
@Override
public String getPageTitle() {
return getTranslation("page.title.approvals");
}
}

View File

@@ -14,7 +14,8 @@ 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 com.vaadin.flow.router.Route; // Import bleibt auskommentiert, solange @Route deaktiviert ist (siehe unten).
// import com.vaadin.flow.router.Route;
import com.vaadin.flow.router.BeforeEvent; import com.vaadin.flow.router.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;
@@ -27,12 +28,14 @@ import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper; import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.service.CustomerService; import de.assecutor.votianlt.pages.service.CustomerService;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService; import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import de.assecutor.votianlt.repository.JobRepository; import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.ServiceRepository; 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.InvoiceLifecycleService;
import de.assecutor.votianlt.service.InvoiceTemplateService; import de.assecutor.votianlt.service.InvoiceTemplateService;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -50,7 +53,11 @@ import java.util.Optional;
import com.vaadin.flow.component.dialog.Dialog; import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.html.IFrame; import com.vaadin.flow.component.html.IFrame;
@Route(value = "create_invoice", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) // Route deaktiviert: das System erstellt keine eigenen Rechnungen mehr.
// Code bleibt erhalten — die statische Methode showSavedInvoiceDialog(...) wird weiterhin
// genutzt, um vorhandene Rechnungs-PDFs anzuzeigen, und der DATEV-Export greift auf
// dieselben Backend-Services zu. Reaktivierung: nächste Zeile @Route entkommentieren.
// @Route(value = "create_invoice", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER" }) @RolesAllowed({ "USER" })
@Slf4j @Slf4j
public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter<String>, HasDynamicTitle { public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter<String>, HasDynamicTitle {
@@ -62,8 +69,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
private final InvoiceTemplateService invoiceTemplateService; private final InvoiceTemplateService invoiceTemplateService;
private final SecurityService securityService; private final SecurityService securityService;
private final UserInvoiceDataService userInvoiceDataService; private final UserInvoiceDataService userInvoiceDataService;
private final CustomerInvoiceRepository customerInvoiceRepository;
private final CustomerService customerService; private final CustomerService customerService;
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<>();
@@ -117,8 +125,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
public CreateInvoiceView(JobRepository jobRepository, ServiceRepository serviceRepository, public CreateInvoiceView(JobRepository jobRepository, ServiceRepository serviceRepository,
UserRepository userRepository, CustomerInvoiceService customerInvoiceService, UserRepository userRepository, CustomerInvoiceService customerInvoiceService,
InvoiceTemplateService invoiceTemplateService, SecurityService securityService, InvoiceTemplateService invoiceTemplateService, SecurityService securityService,
UserInvoiceDataService userInvoiceDataService, CustomerInvoiceRepository customerInvoiceRepository, UserInvoiceDataService userInvoiceDataService, CustomerService customerService,
CustomerService customerService) { InvoiceLifecycleService invoiceLifecycleService, EInvoiceService eInvoiceService) {
this.jobRepository = jobRepository; this.jobRepository = jobRepository;
this.serviceRepository = serviceRepository; this.serviceRepository = serviceRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
@@ -126,8 +134,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
this.invoiceTemplateService = invoiceTemplateService; this.invoiceTemplateService = invoiceTemplateService;
this.securityService = securityService; this.securityService = securityService;
this.userInvoiceDataService = userInvoiceDataService; this.userInvoiceDataService = userInvoiceDataService;
this.customerInvoiceRepository = customerInvoiceRepository;
this.customerService = customerService; this.customerService = customerService;
this.invoiceLifecycleService = invoiceLifecycleService;
this.eInvoiceService = eInvoiceService;
setSizeFull(); setSizeFull();
setPadding(true); setPadding(true);
setSpacing(true); setSpacing(true);
@@ -584,8 +593,22 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
invoice.setVatRate(vatRate); invoice.setVatRate(vatRate);
invoice.setVatAmount(vatAmount); invoice.setVatAmount(vatAmount);
invoice.setTotalAmount(totalAmount); invoice.setTotalAmount(totalAmount);
invoice.setPdfData(pdfBytes);
CustomerInvoice savedInvoice = customerInvoiceRepository.save(invoice); // ZUGFeRD-Anreicherung und PAdES-Signatur sind unabhängig: der Nutzer kann
// beides einzeln im Profil aktivieren. Signatur ist strikt fehlt das
// Zertifikat, schlägt das Speichern hier mit einer InvoiceLifecycleException
// fehl und wird unten als Notification angezeigt.
boolean withZugferd = eInvoiceService.isEInvoiceEnabledGlobally() && user.isEinvoiceEnabled();
boolean withSignature = user.isSignInvoicesEnabled();
byte[] finalPdf = pdfBytes;
if (withZugferd || withSignature) {
finalPdf = eInvoiceService.enhanceAndSign(pdfBytes, invoice, withZugferd, withSignature);
}
invoice.setPdfData(finalPdf);
// Finalisierung mit Audit-Eintrag und Eindeutigkeitsprüfung der Rechnungsnummer (R-07/R-11/R-36).
CustomerInvoice savedInvoice = invoiceLifecycleService.createAndIssue(invoice,
"Rechnung erstellt aus Auftrag " + currentJob.getJobNumber());
currentJob.setInvoiceId(savedInvoice.getId()); currentJob.setInvoiceId(savedInvoice.getId());
jobRepository.save(currentJob); jobRepository.save(currentJob);
@@ -594,6 +617,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
Notification.show(getTranslation("createinvoice.notification.saved", invoiceNumber), 4000, Notification.show(getTranslation("createinvoice.notification.saved", invoiceNumber), 4000,
Notification.Position.BOTTOM_END); Notification.Position.BOTTOM_END);
} catch (InvoiceLifecycleException lifecycleEx) {
log.warn("Lifecycle-Verstoß beim Speichern der Rechnung: {}", lifecycleEx.getMessage());
Notification.show(lifecycleEx.getMessage(), 5000, Notification.Position.MIDDLE);
} catch (Exception ex) { } catch (Exception ex) {
log.error("Fehler beim Speichern der Rechnung", ex); log.error("Fehler beim Speichern der Rechnung", ex);
Notification.show(getTranslation("createinvoice.notification.error", ex.getMessage()), 5000, Notification.show(getTranslation("createinvoice.notification.error", ex.getMessage()), 5000,

View File

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

View File

@@ -43,9 +43,12 @@ 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;
@@ -74,6 +77,8 @@ 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;
@@ -87,12 +92,15 @@ 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;
@@ -367,11 +375,32 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
} }
}); });
HorizontalLayout billingHeaderLayout = new HorizontalLayout(billingEnabled, prefixField, vatRateField); Checkbox eInvoiceCheckbox = new Checkbox(getTranslation("profile.settings.einvoice"));
eInvoiceCheckbox.setHelperText(getTranslation("profile.settings.einvoice.helper"));
eInvoiceCheckbox.setValue(currentUser.isEinvoiceEnabled());
eInvoiceCheckbox.addValueChangeListener(
e -> currentUser.setEinvoiceEnabled(Boolean.TRUE.equals(e.getValue())));
Checkbox signInvoicesCheckbox = new Checkbox(getTranslation("profile.settings.signinvoices"));
signInvoicesCheckbox.setHelperText(getTranslation("profile.settings.signinvoices.helper"));
signInvoicesCheckbox.setValue(currentUser.isSignInvoicesEnabled());
signInvoicesCheckbox.addValueChangeListener(
e -> currentUser.setSignInvoicesEnabled(Boolean.TRUE.equals(e.getValue())));
HorizontalLayout billingHeaderLayout = new HorizontalLayout(billingEnabled, prefixField, vatRateField,
eInvoiceCheckbox, signInvoicesCheckbox);
billingHeaderLayout.setSpacing(true); billingHeaderLayout.setSpacing(true);
billingHeaderLayout.setAlignItems(FlexComponent.Alignment.BASELINE); billingHeaderLayout.setAlignItems(FlexComponent.Alignment.BASELINE);
billingTab.add(billingHeaderLayout); billingTab.add(billingHeaderLayout);
// Signatur-Credentials (Phase 5.5/5.6)
if (currentUser != null && currentUser.getId() != null) {
SigningCredentialsPanel signingPanel = new SigningCredentialsPanel(signingCredentialsService,
eInvoiceProperties, currentUser.getId().toHexString(), this::getTranslation);
signingPanel.setMaxWidth("760px");
billingTab.add(signingPanel);
}
// Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften) // Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften)
final HorizontalLayout mainLayout = new HorizontalLayout(); final HorizontalLayout mainLayout = new HorizontalLayout();
mainLayout.setWidthFull(); mainLayout.setWidthFull();

View File

@@ -1,39 +1,89 @@
package de.assecutor.votianlt.pages.view; package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.grid.Grid; import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Anchor;
import com.vaadin.flow.component.html.Div; 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.html.Span;
import com.vaadin.flow.component.notification.Notification; import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.FlexComponent; 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.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.NumberField;
import com.vaadin.flow.component.textfield.TextArea;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.HasDynamicTitle; import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route; // Route deaktiviert (siehe Klassen-Header) — die Anwendung erstellt/bearbeitet keine
import com.vaadin.flow.component.UI; // Rechnungen mehr selbst, der Bestand wird per DATEV-Export weiterverarbeitet.
// import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.StreamRegistration;
import com.vaadin.flow.server.StreamResource;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.invoices.CustomerInvoice; import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import de.assecutor.votianlt.model.invoices.PaymentStatus;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar; import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository; import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
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.InvoiceApprovalService;
import de.assecutor.votianlt.service.InvoiceExportService;
import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoiceLifecycleService;
import de.assecutor.votianlt.service.InvoicePermissionService;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Optional; import java.util.Optional;
import com.vaadin.flow.server.StreamResource; // @Route deaktiviert — Rechnungs-UI ist durch DATEV-Export ersetzt. Reaktivierung:
import com.vaadin.flow.server.StreamRegistration; // @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 {
private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm",
Locale.GERMANY);
private final Grid<CustomerInvoice> invoiceGrid; private final Grid<CustomerInvoice> invoiceGrid;
private final CustomerInvoiceRepository customerInvoiceRepository; private final CustomerInvoiceRepository customerInvoiceRepository;
private final SecurityService securityService; private final SecurityService securityService;
private final InvoiceLifecycleService invoiceLifecycleService;
private final CustomerInvoiceService customerInvoiceService;
private final InvoiceExportService invoiceExportService;
private final InvoicePermissionService invoicePermissionService;
private final InvoiceApprovalService invoiceApprovalService;
private final UserInvoiceDataService userInvoiceDataService;
private final UserRepository userRepository;
public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService) { public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService,
InvoiceLifecycleService invoiceLifecycleService, CustomerInvoiceService customerInvoiceService,
InvoiceExportService invoiceExportService, InvoicePermissionService invoicePermissionService,
InvoiceApprovalService invoiceApprovalService, UserInvoiceDataService userInvoiceDataService,
UserRepository userRepository) {
this.customerInvoiceRepository = customerInvoiceRepository; this.customerInvoiceRepository = customerInvoiceRepository;
this.securityService = securityService; this.securityService = securityService;
this.invoiceLifecycleService = invoiceLifecycleService;
this.customerInvoiceService = customerInvoiceService;
this.invoiceExportService = invoiceExportService;
this.invoicePermissionService = invoicePermissionService;
this.invoiceApprovalService = invoiceApprovalService;
this.userInvoiceDataService = userInvoiceDataService;
this.userRepository = userRepository;
setSizeFull(); setSizeFull();
setPadding(true); setPadding(true);
@@ -43,60 +93,572 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
addClassName("data-view"); addClassName("data-view");
add(new ViewToolbar(getTranslation("invoices.title"))); add(new ViewToolbar(getTranslation("invoices.title")));
add(buildLegalDisclaimer());
invoiceGrid = new Grid<>(CustomerInvoice.class, false); invoiceGrid = new Grid<>(CustomerInvoice.class, false);
invoiceGrid.setWidthFull(); invoiceGrid.setWidthFull();
invoiceGrid.addClassName("data-grid"); invoiceGrid.addClassName("data-grid");
invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getInvoiceNumber(), invoice.getId())) invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getInvoiceNumber(), invoice.getId()))
.setHeader(getTranslation("invoices.column.number")).setAutoWidth(true); .setHeader(getTranslation("invoices.column.number")).setAutoWidth(true);
invoiceGrid.addComponentColumn(this::renderTypeBadge)
.setHeader(getTranslation("invoices.column.type")).setAutoWidth(true);
invoiceGrid.addComponentColumn(this::renderStatusBadge)
.setHeader(getTranslation("invoices.column.status")).setAutoWidth(true);
invoiceGrid.addColumn(this::getRecipientLabel).setHeader(getTranslation("invoices.column.customer")) invoiceGrid.addColumn(this::getRecipientLabel).setHeader(getTranslation("invoices.column.customer"))
.setAutoWidth(true); .setAutoWidth(true);
invoiceGrid.addColumn(invoice -> Optional.ofNullable(invoice.getInvoiceDate()).map(Object::toString).orElse("")) invoiceGrid.addColumn(invoice -> Optional.ofNullable(invoice.getInvoiceDate()).map(Object::toString).orElse(""))
.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.addColumn(invoice -> firstNonBlank(invoice.getDescription(), "")) invoiceGrid.addComponentColumn(this::renderPaymentBadge)
.setHeader(getTranslation("invoices.column.description")).setAutoWidth(true); .setHeader(getTranslation("invoices.column.payment")).setAutoWidth(true);
invoiceGrid.setSelectionMode(Grid.SelectionMode.SINGLE); invoiceGrid.addColumn(this::formatOutstanding).setHeader(getTranslation("invoices.column.outstanding"))
invoiceGrid.getStyle().set("cursor", "pointer"); .setAutoWidth(true);
invoiceGrid.addComponentColumn(this::renderActions)
.setHeader(getTranslation("invoices.column.actions")).setAutoWidth(true).setFlexGrow(0);
invoiceGrid.addItemClickListener(event -> { invoiceGrid.setSelectionMode(Grid.SelectionMode.NONE);
CustomerInvoice invoice = event.getItem();
if (invoice != null) {
downloadInvoicePdf(invoice);
}
});
loadInvoices();
Div gridPanel = new Div(invoiceGrid); Div gridPanel = new Div(invoiceGrid);
gridPanel.addClassNames("surface-panel", "data-grid-panel"); gridPanel.addClassNames("surface-panel", "data-grid-panel");
gridPanel.setWidthFull(); gridPanel.setWidthFull();
add(gridPanel); add(gridPanel);
loadInvoices();
}
private Component buildLegalDisclaimer() {
Div banner = new Div();
banner.addClassName("surface-panel");
banner.getStyle().set("padding", "12px 16px").set("border-left", "4px solid var(--lumo-primary-color)")
.set("background", "var(--lumo-contrast-5pct)");
Span text = new Span(getTranslation("invoices.disclaimer"));
text.getStyle().set("font-size", "var(--lumo-font-size-s)");
banner.add(text);
return banner;
} }
private void loadInvoices() { private void loadInvoices() {
String currentUserId = securityService.getCurrentUserId().toHexString(); String currentUserId = securityService.getCurrentUserId().toHexString();
List<CustomerInvoice> invoices = customerInvoiceRepository.findByUserId(currentUserId).stream() List<CustomerInvoice> invoices = customerInvoiceRepository.findByUserId(currentUserId).stream()
.filter(this::hasPdfData).sorted((left, right) -> { .sorted(Comparator
if (left.getInvoiceDate() == null && right.getInvoiceDate() == null) { .comparing((CustomerInvoice i) -> i.getInvoiceDate() == null ? LocalDate.MIN
return 0; : i.getInvoiceDate())
} .reversed())
if (left.getInvoiceDate() == null) { .toList();
return 1;
}
if (right.getInvoiceDate() == null) {
return -1;
}
return right.getInvoiceDate().compareTo(left.getInvoiceDate());
}).toList();
invoiceGrid.setItems(invoices); invoiceGrid.setItems(invoices);
}
if (invoices.isEmpty()) { private Component renderStatusBadge(CustomerInvoice invoice) {
Span emptyState = new Span(getTranslation("invoices.empty")); InvoiceStatus status = invoice.getStatus() != null ? invoice.getStatus() : InvoiceStatus.ISSUED;
emptyState.getStyle().set("color", "var(--lumo-secondary-text-color)"); Span badge = new Span(getTranslation("invoices.status." + status.name().toLowerCase(Locale.ROOT)));
add(emptyState); badge.getElement().getThemeList().add("badge");
switch (status) {
case DRAFT -> badge.getElement().getThemeList().add("contrast");
case SENT -> badge.getElement().getThemeList().add("success");
case CANCELLED -> badge.getElement().getThemeList().add("error");
case CORRECTED -> badge.getElement().getThemeList().add("warning");
default -> {
} }
}
return badge;
}
private Component renderPaymentBadge(CustomerInvoice invoice) {
PaymentStatus status = invoice.getPaymentStatus() != null ? invoice.getPaymentStatus() : PaymentStatus.UNPAID;
Span badge = new Span(getTranslation("invoices.payment." + status.name().toLowerCase(Locale.ROOT)));
badge.getElement().getThemeList().add("badge");
switch (status) {
case PAID -> badge.getElement().getThemeList().add("success");
case PARTIALLY_PAID -> badge.getElement().getThemeList().add("contrast");
case OVERPAID -> badge.getElement().getThemeList().add("warning");
case REFUND_DUE -> badge.getElement().getThemeList().add("error");
default -> {
}
}
return badge;
}
private String formatOutstanding(CustomerInvoice invoice) {
if (invoice.getTotalAmount() == null) {
return "";
}
java.math.BigDecimal outstanding = invoiceLifecycleService.computeOutstandingAmount(invoice);
return java.text.NumberFormat.getCurrencyInstance(Locale.GERMANY).format(outstanding);
}
private Component renderTypeBadge(CustomerInvoice invoice) {
InvoiceType type = invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE;
HorizontalLayout layout = new HorizontalLayout();
layout.setSpacing(true);
layout.setPadding(false);
Span badge = new Span(getTranslation("invoices.type." + type.name().toLowerCase(Locale.ROOT)));
badge.getElement().getThemeList().add("badge");
if (type == InvoiceType.CANCELLATION) {
badge.getElement().getThemeList().add("error");
} else if (type == InvoiceType.CORRECTION) {
badge.getElement().getThemeList().add("warning");
}
layout.add(badge);
if (invoice.getEInvoiceFormat() != null
&& invoice.getEInvoiceFormat() != de.assecutor.votianlt.model.invoices.EInvoiceFormat.NONE) {
Span eInvoiceBadge = new Span("ZUGFeRD");
eInvoiceBadge.getElement().getThemeList().add("badge");
eInvoiceBadge.getElement().getThemeList().add("primary");
eInvoiceBadge.setTitle(getTranslation("invoices.einvoice.tooltip"));
layout.add(eInvoiceBadge);
}
if (invoice.isSigned()) {
Span signedBadge = new Span("" + getTranslation("invoices.einvoice.signed"));
signedBadge.getElement().getThemeList().add("badge");
signedBadge.getElement().getThemeList().add("success");
layout.add(signedBadge);
}
return layout;
}
private Component renderActions(CustomerInvoice invoice) {
HorizontalLayout actions = new HorizontalLayout();
actions.setSpacing(true);
actions.setPadding(false);
Button viewBtn = new Button(getTranslation("invoices.action.view"), e -> downloadInvoicePdf(invoice));
viewBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
viewBtn.setEnabled(invoice.getPdfData() != null && invoice.getPdfData().length > 0);
actions.add(viewBtn);
Button historyBtn = new Button(getTranslation("invoices.action.history"), e -> openHistoryDialog(invoice));
historyBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
actions.add(historyBtn);
InvoiceStatus status = invoice.getStatus() != null ? invoice.getStatus() : InvoiceStatus.ISSUED;
InvoiceType type = invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE;
User currentUser = invoicePermissionService.currentUser();
// Aktionen nur für reguläre, noch aktive Rechnungen anbieten
boolean isLiveInvoice = type == InvoiceType.INVOICE
&& (status == InvoiceStatus.ISSUED || status == InvoiceStatus.SENT);
if (type == InvoiceType.INVOICE && status == InvoiceStatus.ISSUED
&& invoicePermissionService.canMarkAsSent(currentUser)) {
Button sentBtn = new Button(getTranslation("invoices.action.marksent"),
e -> markAsSent(invoice));
sentBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
actions.add(sentBtn);
}
if (isLiveInvoice) {
boolean hasPendingRequest = !invoiceApprovalService
.findOpenForCurrentRequester().stream()
.filter(r -> invoice.getId().equals(r.getTargetInvoiceId()))
.toList().isEmpty();
if (invoicePermissionService.canCorrect(currentUser)) {
String label = invoicePermissionService.requiresApproval(currentUser)
? getTranslation("invoices.action.correct.request")
: getTranslation("invoices.action.correct");
Button correctBtn = new Button(label, e -> openCorrectionDialog(invoice));
correctBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
correctBtn.setEnabled(!hasPendingRequest);
actions.add(correctBtn);
}
if (invoicePermissionService.canCancel(currentUser)) {
String label = invoicePermissionService.requiresApproval(currentUser)
? getTranslation("invoices.action.cancel.request")
: getTranslation("invoices.action.cancel");
Button cancelBtn = new Button(label, e -> openCancellationDialog(invoice));
cancelBtn.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ERROR);
cancelBtn.setEnabled(!hasPendingRequest);
actions.add(cancelBtn);
}
}
// Zahlung erfassen: nur für reguläre Rechnungen (R-25)
if (type == InvoiceType.INVOICE && status != InvoiceStatus.DRAFT
&& invoicePermissionService.canRecordPayment(currentUser)) {
Button payBtn = new Button(getTranslation("invoices.action.payment"),
e -> openPaymentDialog(invoice));
payBtn.addThemeVariants(ButtonVariant.LUMO_SMALL);
actions.add(payBtn);
}
// Belegpaket exportieren (R-33/R-34)
Button exportBtn = new Button(getTranslation("invoices.action.export"), e -> exportPackage(invoice));
exportBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
actions.add(exportBtn);
return actions;
}
private void openPaymentDialog(CustomerInvoice invoice) {
Dialog dialog = DialogStylingHelper.createStyledDialog(
getTranslation("invoices.payment.title", invoice.getInvoiceNumber()), "480px");
VerticalLayout content = new VerticalLayout();
content.setSpacing(true);
content.setPadding(false);
java.math.BigDecimal outstanding = invoiceLifecycleService.computeOutstandingAmount(invoice);
Span hint = new Span(getTranslation("invoices.payment.hint",
java.text.NumberFormat.getCurrencyInstance(Locale.GERMANY).format(outstanding)));
hint.getStyle().set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-s)");
content.add(hint);
NumberField amountField = new NumberField(getTranslation("invoices.payment.amount"));
amountField.setStep(0.01);
amountField.setValue(outstanding.doubleValue());
amountField.setRequiredIndicatorVisible(true);
amountField.setWidthFull();
content.add(amountField);
TextField referenceField = new TextField(getTranslation("invoices.payment.reference"));
referenceField.setWidthFull();
content.add(referenceField);
TextArea reasonField = new TextArea(getTranslation("invoices.payment.reason"));
reasonField.setWidthFull();
reasonField.setMinHeight("80px");
content.add(reasonField);
dialog.add(DialogStylingHelper.wrapContent(content));
Button cancelBtn = new Button(getTranslation("button.cancel"), e -> dialog.close());
cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button confirmBtn = new Button(getTranslation("invoices.payment.confirm"), e -> {
Double amount = amountField.getValue();
if (amount == null || amount == 0d) {
amountField.setInvalid(true);
amountField.setErrorMessage(getTranslation("invoices.payment.amount.required"));
return;
}
performPayment(invoice, java.math.BigDecimal.valueOf(amount), referenceField.getValue(),
reasonField.getValue(), dialog);
});
confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
dialog.getFooter().add(cancelBtn, confirmBtn);
dialog.open();
}
private void performPayment(CustomerInvoice invoice, java.math.BigDecimal amount, String reference, String reason,
Dialog dialog) {
try {
invoicePermissionService.requirePayment(invoicePermissionService.currentUser());
invoiceLifecycleService.registerPayment(invoice.getId(), amount, reference, reason);
dialog.close();
Notification.show(getTranslation("invoices.notification.payment"), 3000, Notification.Position.BOTTOM_END);
loadInvoices();
} catch (InvoiceLifecycleException ex) {
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
} catch (Exception ex) {
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
Notification.Position.MIDDLE);
}
}
private void exportPackage(CustomerInvoice invoice) {
try {
byte[] zipBytes = invoiceExportService.exportInvoicePackage(invoice);
String fileName = invoiceExportService.suggestFilename(invoice);
StreamResource resource = new StreamResource(fileName, () -> new ByteArrayInputStream(zipBytes));
resource.setContentType("application/zip");
resource.setCacheTime(0);
StreamRegistration registration = UI.getCurrent().getSession().getResourceRegistry()
.registerResource(resource);
UI.getCurrent().getPage().open(registration.getResourceUri().toString());
} catch (Exception ex) {
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
Notification.Position.MIDDLE);
}
}
private void markAsSent(CustomerInvoice invoice) {
try {
invoicePermissionService.requireSend(invoicePermissionService.currentUser());
invoiceLifecycleService.markAsSent(invoice.getId(), "Manuell als versendet markiert");
Notification.show(getTranslation("invoices.notification.sent"), 3000, Notification.Position.BOTTOM_END);
loadInvoices();
} catch (InvoiceLifecycleException ex) {
Notification.show(ex.getMessage(), 5000, Notification.Position.MIDDLE);
}
}
private void openCancellationDialog(CustomerInvoice invoice) {
Dialog dialog = DialogStylingHelper.createStyledDialog(
getTranslation("invoices.cancel.title", invoice.getInvoiceNumber()), "560px");
VerticalLayout content = new VerticalLayout();
content.setSpacing(true);
content.setPadding(false);
Span hint = new Span(getTranslation("invoices.cancel.hint"));
hint.getStyle().set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-s)");
content.add(hint);
TextArea reasonField = new TextArea(getTranslation("invoices.cancel.reason"));
reasonField.setWidthFull();
reasonField.setMinHeight("100px");
reasonField.setRequired(true);
content.add(reasonField);
dialog.add(DialogStylingHelper.wrapContent(content));
Button cancelBtn = new Button(getTranslation("button.cancel"), e -> dialog.close());
cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button confirmBtn = new Button(getTranslation("invoices.cancel.confirm"), e -> {
String reason = reasonField.getValue();
if (reason == null || reason.isBlank()) {
reasonField.setInvalid(true);
reasonField.setErrorMessage(getTranslation("invoices.cancel.reason.required"));
return;
}
performCancellation(invoice, reason, dialog);
});
confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_ERROR);
dialog.getFooter().add(cancelBtn, confirmBtn);
dialog.open();
}
private void performCancellation(CustomerInvoice invoice, String reason, Dialog dialog) {
User currentUser = invoicePermissionService.currentUser();
try {
invoicePermissionService.requireCancel(currentUser);
if (invoicePermissionService.requiresApproval(currentUser)) {
invoiceApprovalService.requestCancellation(invoice.getId(), reason);
dialog.close();
Notification.show(getTranslation("invoices.notification.requested"), 4000,
Notification.Position.BOTTOM_END);
loadInvoices();
return;
}
User issuer = resolveIssuer(invoice);
String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId());
LocalDate today = LocalDate.now();
byte[] pdf = customerInvoiceService.generateCancellationPdf(invoice, number, today, reason);
invoiceLifecycleService.cancel(invoice.getId(), number, today, pdf, reason);
dialog.close();
Notification.show(getTranslation("invoices.notification.cancelled", number), 4000,
Notification.Position.BOTTOM_END);
loadInvoices();
} catch (InvoiceLifecycleException ex) {
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
} catch (Exception ex) {
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
Notification.Position.MIDDLE);
}
}
private void openCorrectionDialog(CustomerInvoice invoice) {
Dialog dialog = DialogStylingHelper.createStyledDialog(
getTranslation("invoices.correct.title", invoice.getInvoiceNumber()), "560px");
VerticalLayout content = new VerticalLayout();
content.setSpacing(true);
content.setPadding(false);
Span hint = new Span(getTranslation("invoices.correct.hint"));
hint.getStyle().set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-s)");
content.add(hint);
TextArea fieldsField = new TextArea(getTranslation("invoices.correct.fields"));
fieldsField.setWidthFull();
fieldsField.setMinHeight("100px");
fieldsField.setHelperText(getTranslation("invoices.correct.fields.helper"));
fieldsField.setRequired(true);
content.add(fieldsField);
TextArea reasonField = new TextArea(getTranslation("invoices.correct.reason"));
reasonField.setWidthFull();
reasonField.setMinHeight("80px");
content.add(reasonField);
dialog.add(DialogStylingHelper.wrapContent(content));
Button cancelBtn = new Button(getTranslation("button.cancel"), e -> dialog.close());
cancelBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button confirmBtn = new Button(getTranslation("invoices.correct.confirm"), e -> {
String fields = fieldsField.getValue();
if (fields == null || fields.isBlank()) {
fieldsField.setInvalid(true);
fieldsField.setErrorMessage(getTranslation("invoices.correct.fields.required"));
return;
}
performCorrection(invoice, fields, reasonField.getValue(), dialog);
});
confirmBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
dialog.getFooter().add(cancelBtn, confirmBtn);
dialog.open();
}
private void performCorrection(CustomerInvoice invoice, String correctedFields, String reason, Dialog dialog) {
User currentUser = invoicePermissionService.currentUser();
try {
invoicePermissionService.requireCorrect(currentUser);
if (invoicePermissionService.requiresApproval(currentUser)) {
String requestReason = reason != null && !reason.isBlank() ? reason : correctedFields;
invoiceApprovalService.requestCorrection(invoice.getId(), correctedFields, requestReason);
dialog.close();
Notification.show(getTranslation("invoices.notification.requested"), 4000,
Notification.Position.BOTTOM_END);
loadInvoices();
return;
}
User issuer = resolveIssuer(invoice);
String number = userInvoiceDataService.generateNextInvoiceNumber(issuer.getId());
LocalDate today = LocalDate.now();
byte[] pdf = customerInvoiceService.generateCorrectionPdf(invoice, number, today, reason, correctedFields);
invoiceLifecycleService.correct(invoice.getId(), number, today, pdf, correctedFields, reason);
dialog.close();
Notification.show(getTranslation("invoices.notification.corrected", number), 4000,
Notification.Position.BOTTOM_END);
loadInvoices();
} catch (InvoiceLifecycleException ex) {
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
} catch (Exception ex) {
Notification.show(getTranslation("invoices.notification.error", ex.getMessage()), 6000,
Notification.Position.MIDDLE);
}
}
private User resolveIssuer(CustomerInvoice invoice) {
if (invoice.getUserId() != null && !invoice.getUserId().isBlank()) {
try {
return userRepository.findById(new org.bson.types.ObjectId(invoice.getUserId()))
.orElseGet(securityService::getCurrentDatabaseUser);
} catch (IllegalArgumentException ex) {
// userId ist kein gültiger ObjectId Fallback auf eingeloggten Nutzer
}
}
return securityService.getCurrentDatabaseUser();
}
private void openHistoryDialog(CustomerInvoice invoice) {
Dialog dialog = DialogStylingHelper.createStyledDialog(
getTranslation("invoices.history.title", invoice.getInvoiceNumber()), "640px");
VerticalLayout content = new VerticalLayout();
content.setSpacing(true);
content.setPadding(false);
// Verkettung anzeigen, falls vorhanden
Div linksBlock = renderRelatedInvoiceLinks(invoice);
if (linksBlock != null) {
content.add(linksBlock);
}
H3 logTitle = new H3(getTranslation("invoices.history.log"));
content.add(logTitle);
List<InvoiceAuditEntry> log = invoice.getAuditLog();
if (log == null || log.isEmpty()) {
content.add(new Span(getTranslation("invoices.history.empty")));
} else {
log.stream()
.sorted(Comparator
.comparing((InvoiceAuditEntry e) -> e.getTimestamp() == null
? java.time.LocalDateTime.MIN
: e.getTimestamp())
.reversed())
.forEach(entry -> content.add(renderAuditEntry(entry)));
}
dialog.add(DialogStylingHelper.wrapContent(content, true));
Button closeBtn = new Button(getTranslation("button.close"), e -> dialog.close());
closeBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
dialog.getFooter().add(closeBtn);
dialog.open();
}
private Div renderRelatedInvoiceLinks(CustomerInvoice invoice) {
Div block = new Div();
boolean hasContent = false;
if (invoice.getOriginalInvoiceId() != null) {
block.add(buildLinkRow(getTranslation("invoices.history.original"),
invoice.getOriginalInvoiceNumber(), invoice.getOriginalInvoiceId()));
hasContent = true;
}
if (invoice.getCancellationInvoiceId() != null) {
block.add(buildLinkRow(getTranslation("invoices.history.cancellation"),
null, invoice.getCancellationInvoiceId()));
hasContent = true;
}
if (invoice.getCorrectionInvoiceId() != null) {
block.add(buildLinkRow(getTranslation("invoices.history.correction"),
null, invoice.getCorrectionInvoiceId()));
hasContent = true;
}
if (invoice.getReplacementInvoiceId() != null) {
block.add(buildLinkRow(getTranslation("invoices.history.replacement"),
null, invoice.getReplacementInvoiceId()));
hasContent = true;
}
return hasContent ? block : null;
}
private HorizontalLayout buildLinkRow(String label, String fallbackNumber, String invoiceId) {
HorizontalLayout row = new HorizontalLayout();
row.setSpacing(true);
row.setPadding(false);
Span lbl = new Span(label);
lbl.getStyle().set("min-width", "180px").set("color", "var(--lumo-secondary-text-color)");
row.add(lbl);
CustomerInvoice related = invoiceLifecycleService.findById(invoiceId).orElse(null);
String number = related != null && related.getInvoiceNumber() != null ? related.getInvoiceNumber()
: fallbackNumber != null ? fallbackNumber : invoiceId;
if (related != null && related.getPdfData() != null && related.getPdfData().length > 0) {
Anchor link = new Anchor("javascript:void(0)", number);
link.getElement().addEventListener("click", e -> downloadInvoicePdf(related));
row.add(link);
} else {
row.add(new Span(number));
}
return row;
}
private Div renderAuditEntry(InvoiceAuditEntry entry) {
Div container = new Div();
container.getStyle().set("padding", "8px 12px").set("margin-bottom", "6px")
.set("border-left", "3px solid var(--lumo-contrast-30pct)")
.set("background", "var(--lumo-contrast-5pct)");
String timestamp = entry.getTimestamp() != null ? entry.getTimestamp().format(DATE_TIME_FMT) : "";
String actionLabel = entry.getAction() != null
? getTranslation("invoices.audit.action." + entry.getAction().name().toLowerCase(Locale.ROOT))
: "?";
String userLabel = entry.getUserDisplayName() != null ? entry.getUserDisplayName() : "system";
Span header = new Span(timestamp + " · " + actionLabel + " · " + userLabel);
header.getStyle().set("font-weight", "600");
container.add(header);
if (entry.getReason() != null && !entry.getReason().isBlank()) {
Div reason = new Div();
reason.setText(entry.getReason());
reason.getStyle().set("margin-top", "4px");
container.add(reason);
}
if (entry.getResultingInvoiceNumber() != null) {
Div link = new Div();
link.setText(getTranslation("invoices.audit.resulting", entry.getResultingInvoiceNumber()));
link.getStyle().set("margin-top", "4px").set("color", "var(--lumo-secondary-text-color)")
.set("font-size", "var(--lumo-font-size-s)");
container.add(link);
}
return container;
} }
private void downloadInvoicePdf(CustomerInvoice invoice) { private void downloadInvoicePdf(CustomerInvoice invoice) {
@@ -123,10 +685,6 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
} }
} }
private boolean hasPdfData(CustomerInvoice invoice) {
return invoice != null && invoice.getPdfData() != null && invoice.getPdfData().length > 0;
}
private String getRecipientLabel(CustomerInvoice invoice) { private String getRecipientLabel(CustomerInvoice invoice) {
return firstNonBlank(invoice.getRecipientCompany(), invoice.getRecipientName(), ""); return firstNonBlank(invoice.getRecipientCompany(), invoice.getRecipientName(), "");
} }

View File

@@ -16,9 +16,11 @@ 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;
import com.vaadin.flow.router.Route; // Route deaktiviert (siehe Klassen-Header).
// import com.vaadin.flow.router.Route;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar; import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import de.assecutor.votianlt.pages.base.ui.view.MainLayout; // import bleibt auskommentiert, solange die @Route oben deaktiviert ist:
// import de.assecutor.votianlt.pages.base.ui.view.MainLayout;
import jakarta.annotation.security.RolesAllowed; import 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;
@@ -41,7 +43,8 @@ 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(value = "my-invoices", layout = MainLayout.class) // @Route deaktiviert — Rechnungs-UI ist durch DATEV-Export ersetzt. Reaktivierung:
// @Route(value = "my-invoices", layout = MainLayout.class)
@RolesAllowed("USER") @RolesAllowed("USER")
public class MyInvoicesView extends Main implements HasDynamicTitle { public class MyInvoicesView extends Main implements HasDynamicTitle {

View File

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

View File

@@ -1,6 +1,7 @@
package de.assecutor.votianlt.repository; package de.assecutor.votianlt.repository;
import de.assecutor.votianlt.model.invoices.CustomerInvoice; import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
import org.springframework.data.mongodb.repository.MongoRepository; import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -13,4 +14,13 @@ public interface CustomerInvoiceRepository extends MongoRepository<CustomerInvoi
Optional<CustomerInvoice> findByJobId(String jobId); Optional<CustomerInvoice> findByJobId(String jobId);
List<CustomerInvoice> findByUserId(String userId); List<CustomerInvoice> findByUserId(String userId);
/** Liefert die höchstens eine aktive (nicht stornierte) Rechnung mit dieser Nummer (R-11). */
Optional<CustomerInvoice> findByInvoiceNumberAndStatusNot(String invoiceNumber, InvoiceStatus status);
/** Alle Folgebelege (Storno, Korrektur, Ersatzrechnung), die auf diese Originalrechnung verweisen. */
List<CustomerInvoice> findByOriginalInvoiceId(String originalInvoiceId);
/** Findet alle Rechnungen ohne expliziten Status — wird für die Bestandsdatenmigration genutzt. */
List<CustomerInvoice> findByStatusIsNull();
} }

View File

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

View File

@@ -0,0 +1,23 @@
package de.assecutor.votianlt.repository;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface InvoiceNumberReservationRepository extends MongoRepository<InvoiceNumberReservation, ObjectId> {
Optional<InvoiceNumberReservation> findByUserIdAndNumber(ObjectId userId, String number);
Optional<InvoiceNumberReservation> findByUserIdAndSequence(ObjectId userId, long sequence);
List<InvoiceNumberReservation> findByUserIdOrderBySequenceAsc(ObjectId userId);
List<InvoiceNumberReservation> findByUserIdAndStatusOrderBySequenceAsc(ObjectId userId,
InvoiceNumberReservationStatus status);
}

View File

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

View File

@@ -0,0 +1,26 @@
package de.assecutor.votianlt.security;
/**
* Rollen für die Bearbeitung von Rechnungen gemäß R-40 bis R-42.
*
* Die Rollen sind als Konstanten definiert und werden in der bestehenden
* {@link de.assecutor.votianlt.model.User#getRoles()}-Sammlung als String hinterlegt.
*
* Backwards-compat: Bestehende Nutzer haben keine dieser Rollen — die
* {@code USER}-Rolle bleibt vollumfänglich berechtigt, sofern keine speziellen
* Rechnungsrollen explizit zugewiesen sind.
*/
public final class InvoiceRoles {
/** Erstellt Entwürfe und stellt Rechnungen aus. */
public static final String CREATOR = "INVOICE_CREATOR";
/** Prüft Entwürfe und Folgebelege vor Freigabe. */
public static final String REVIEWER = "INVOICE_REVIEWER";
/** Gibt Storno- und Berichtigungsbelege frei (R-42). */
public static final String APPROVER = "INVOICE_APPROVER";
/** Erfasst Zahlungen und buchhalterische Vorgänge (R-25). */
public static final String ACCOUNTANT = "INVOICE_ACCOUNTANT";
private InvoiceRoles() {
}
}

View File

@@ -0,0 +1,71 @@
package de.assecutor.votianlt.service;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.security.MessageDigest;
import java.security.SecureRandom;
/**
* Symmetrische AES-256-GCM-Verschlüsselung mit Master-Key-Ableitung über SHA-256.
*
* Format der erzeugten Bytes: {@code IV (12 Byte) || Ciphertext+Tag (16 Byte Tag)}.
* Der IV wird pro Verschlüsselung neu zufällig erzeugt; ein Master-Key liefert
* deterministisch denselben AES-Schlüssel.
*
* Nicht für hochfrequente Krypto-Operationen optimiert — bewusst minimal gehalten.
*/
final class AesGcmCipher {
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final int IV_LENGTH = 12;
private static final int TAG_LENGTH_BITS = 128;
private final SecretKeySpec key;
private final SecureRandom random = new SecureRandom();
AesGcmCipher(String masterKey) {
if (masterKey == null || masterKey.length() < 16) {
throw new IllegalArgumentException("Master-Key muss mindestens 16 Zeichen lang sein.");
}
try {
byte[] derived = MessageDigest.getInstance("SHA-256").digest(masterKey.getBytes("UTF-8"));
this.key = new SecretKeySpec(derived, "AES");
} catch (Exception ex) {
throw new IllegalStateException("Master-Key konnte nicht abgeleitet werden.", ex);
}
}
byte[] encrypt(byte[] plaintext) {
try {
byte[] iv = new byte[IV_LENGTH];
random.nextBytes(iv);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, iv));
byte[] ciphertext = cipher.doFinal(plaintext);
return ByteBuffer.allocate(IV_LENGTH + ciphertext.length).put(iv).put(ciphertext).array();
} catch (Exception ex) {
throw new IllegalStateException("Verschlüsselung fehlgeschlagen: " + ex.getMessage(), ex);
}
}
byte[] decrypt(byte[] ivAndCiphertext) {
if (ivAndCiphertext == null || ivAndCiphertext.length < IV_LENGTH + 16) {
throw new IllegalArgumentException("Ciphertext zu kurz.");
}
try {
ByteBuffer buffer = ByteBuffer.wrap(ivAndCiphertext);
byte[] iv = new byte[IV_LENGTH];
buffer.get(iv);
byte[] ciphertext = new byte[buffer.remaining()];
buffer.get(ciphertext);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, iv));
return cipher.doFinal(ciphertext);
} catch (Exception ex) {
throw new IllegalStateException("Entschlüsselung fehlgeschlagen: " + ex.getMessage(), ex);
}
}
}

View File

@@ -921,4 +921,181 @@ public class CustomerInvoiceService {
return input.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;") return input.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
.replace("'", "&#x27;"); .replace("'", "&#x27;");
} }
/**
* Erzeugt ein einfaches PDF für einen Stornobeleg gemäß R-19.
* Verweist eindeutig auf die Originalrechnung (Nummer + Datum) und stellt die
* Beträge als negative Werte dar.
*/
public byte[] generateCancellationPdf(de.assecutor.votianlt.model.invoices.CustomerInvoice original,
String cancellationNumber, java.time.LocalDate cancellationDate, String reason) throws Exception {
return generateCorrectionDocumentPdf("STORNORECHNUNG", original, cancellationNumber, cancellationDate, reason,
null, true);
}
/**
* Erzeugt ein einfaches PDF für einen Berichtigungsbeleg gemäß R-13/R-14.
* Verweist eindeutig auf die Originalrechnung und beschreibt die berichtigten Angaben.
*/
public byte[] generateCorrectionPdf(de.assecutor.votianlt.model.invoices.CustomerInvoice original,
String correctionNumber, java.time.LocalDate correctionDate, String reason, String correctedFields)
throws Exception {
return generateCorrectionDocumentPdf("RECHNUNGSBERICHTIGUNG", original, correctionNumber, correctionDate,
reason, correctedFields, false);
}
private byte[] generateCorrectionDocumentPdf(String documentLabel,
de.assecutor.votianlt.model.invoices.CustomerInvoice original, String number,
java.time.LocalDate documentDate, String reason, String correctedFields, boolean negateAmounts)
throws Exception {
java.time.LocalDate effectiveDate = documentDate != null ? documentDate : java.time.LocalDate.now();
java.time.format.DateTimeFormatter dateFmt = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy",
Locale.GERMANY);
BigDecimal net = negateAmounts ? negateOrZero(original.getNetAmount()) : safeAmount(original.getNetAmount());
BigDecimal vat = negateAmounts ? negateOrZero(original.getVatAmount()) : safeAmount(original.getVatAmount());
BigDecimal total = negateAmounts ? negateOrZero(original.getTotalAmount())
: safeAmount(original.getTotalAmount());
StringBuilder html = new StringBuilder();
html.append("<!DOCTYPE html><html><head><meta charset='UTF-8'><style>");
html.append("@page { size: A4; margin: 20mm 18mm 20mm 18mm; }");
html.append("body { font-family: Arial, sans-serif; font-size: 11pt; color: #222; }");
html.append("h1 { font-size: 18pt; letter-spacing: 0.05em; margin: 0 0 8pt 0; }");
html.append("h2 { font-size: 13pt; margin: 18pt 0 6pt 0; }");
html.append(".doc-number { font-size: 11pt; color: #555; }");
html.append(".section { margin-top: 14pt; }");
html.append(".reference { background: #f6f6f6; border-left: 3px solid #888; padding: 8pt 12pt; }");
html.append(".reason { background: #fff8e6; border-left: 3px solid #d9a300; padding: 8pt 12pt; }");
html.append("table.amounts { width: 60%; margin-left: 40%; border-collapse: collapse; margin-top: 10pt; }");
html.append("table.amounts td { padding: 3pt 6pt; }");
html.append("table.amounts td.value { text-align: right; }");
html.append("table.amounts tr.total td { font-weight: bold; border-top: 1px solid #333; }");
html.append(".addresses { width: 100%; margin-top: 14pt; }");
html.append(".addresses td { width: 50%; vertical-align: top; }");
html.append(".muted { color: #666; font-size: 9pt; }");
html.append("</style></head><body>");
html.append("<h1>").append(escapeHtml(documentLabel)).append("</h1>");
html.append("<div class='doc-number'>Beleg-Nr.: ")
.append(escapeHtml(safe(number)))
.append(" &nbsp;·&nbsp; Datum: ").append(escapeHtml(effectiveDate.format(dateFmt)))
.append("</div>");
// Sender / Empfänger
html.append("<table class='addresses'><tr><td>");
html.append("<strong>Aussteller</strong><br/>");
html.append(formatAddressBlock(original.getSenderName(), original.getSenderAddress(),
original.getSenderPostcode(), original.getSenderCity(), original.getSenderCountry()));
if (original.getSenderTaxNumber() != null && !original.getSenderTaxNumber().isBlank()) {
html.append("<div class='muted'>Steuernr.: ").append(escapeHtml(original.getSenderTaxNumber()))
.append("</div>");
}
if (original.getSenderVatId() != null && !original.getSenderVatId().isBlank()) {
html.append("<div class='muted'>USt-IdNr.: ").append(escapeHtml(original.getSenderVatId()))
.append("</div>");
}
html.append("</td><td>");
html.append("<strong>Empfänger</strong><br/>");
html.append(formatAddressBlock(
firstNonBlank(original.getRecipientCompany(), original.getRecipientName()),
original.getRecipientAddress(), original.getRecipientPostcode(), original.getRecipientCity(),
original.getRecipientCountry()));
html.append("</td></tr></table>");
// Eindeutige Referenz auf Originalrechnung (R-13/R-19/R-28)
html.append("<div class='section reference'>");
html.append("<strong>Bezug:</strong> ");
html.append("Diese ").append(escapeHtml(documentLabel.toLowerCase(Locale.GERMANY))).append(" bezieht sich ");
html.append("eindeutig auf die Rechnung <strong>")
.append(escapeHtml(safe(original.getInvoiceNumber()))).append("</strong>");
if (original.getInvoiceDate() != null) {
html.append(" vom ").append(escapeHtml(original.getInvoiceDate().format(dateFmt)));
}
html.append(".");
html.append("</div>");
if (correctedFields != null && !correctedFields.isBlank()) {
html.append("<div class='section'><h2>Berichtigte Angaben</h2>");
html.append("<div>").append(escapeHtml(correctedFields).replace("\n", "<br/>")).append("</div></div>");
}
if (reason != null && !reason.isBlank()) {
html.append("<div class='section reason'><strong>Grund:</strong> ")
.append(escapeHtml(reason).replace("\n", "<br/>")).append("</div>");
}
html.append("<h2>Beträge</h2>");
html.append("<table class='amounts'>");
html.append("<tr><td>Nettobetrag</td><td class='value'>").append(formatCurrency(net)).append("</td></tr>");
if (original.getVatRate() != null) {
BigDecimal vatPct = original.getVatRate().multiply(new BigDecimal("100"))
.setScale(2, java.math.RoundingMode.HALF_UP).stripTrailingZeros();
if (vatPct.scale() < 0) {
vatPct = vatPct.setScale(0);
}
html.append("<tr><td>zzgl. ").append(vatPct.toPlainString().replace('.', ','))
.append("% USt</td><td class='value'>").append(formatCurrency(vat)).append("</td></tr>");
} else {
html.append("<tr><td>zzgl. USt</td><td class='value'>").append(formatCurrency(vat)).append("</td></tr>");
}
html.append("<tr class='total'><td>Gesamtbetrag</td><td class='value'>").append(formatCurrency(total))
.append("</td></tr>");
html.append("</table>");
html.append("<div class='section muted'>");
html.append("Hinweis: Dieser Beleg ersetzt die Originalrechnung nicht. Original und ");
html.append(escapeHtml(documentLabel.toLowerCase(Locale.GERMANY)));
html.append(" sind gemeinsam aufzubewahren.");
html.append("</div>");
html.append("</body></html>");
return generatePdfFromHtmlString(html.toString());
}
private BigDecimal safeAmount(BigDecimal value) {
return value != null ? value : BigDecimal.ZERO;
}
private BigDecimal negateOrZero(BigDecimal value) {
return value != null ? value.negate() : BigDecimal.ZERO;
}
private String formatAddressBlock(String name, String street, String postcode, String city, String country) {
StringBuilder sb = new StringBuilder();
if (name != null && !name.isBlank()) {
sb.append(escapeHtml(name)).append("<br/>");
}
if (street != null && !street.isBlank()) {
sb.append(escapeHtml(street)).append("<br/>");
}
String line = String.join(" ", filterBlanks(postcode, city)).trim();
if (!line.isEmpty()) {
sb.append(escapeHtml(line)).append("<br/>");
}
if (country != null && !country.isBlank()) {
sb.append(escapeHtml(country));
}
return sb.toString();
}
private java.util.List<String> filterBlanks(String... values) {
java.util.List<String> out = new java.util.ArrayList<>();
for (String v : values) {
if (v != null && !v.isBlank()) {
out.add(v);
}
}
return out;
}
private String firstNonBlank(String... values) {
for (String v : values) {
if (v != null && !v.isBlank()) {
return v;
}
}
return "";
}
} }

View File

@@ -0,0 +1,230 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.Charset;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
/**
* Erzeugt einen DATEV-kompatiblen Buchungsstapel (Format „DTVF"/„EXTF",
* Version 7) als CSV mit den festgeschriebenen Rechnungen eines Nutzers
* im gewählten Zeitraum. Die Datei lässt sich von DATEV Unternehmen
* Online sowie den meisten DATEV-importfähigen Drittprogrammen
* (z.B. sevDesk, lexoffice, GnuCash) als Buchungsstapel einlesen.
*
* <p>Inhaltliche Defaults — bewusst konservativ und für SKR03 ausgelegt;
* der Mandant kann sie im Abgleich mit dem Steuerberater später anpassen:
* <ul>
* <li>Sammeldebitor 10000 (Forderungen aus Lieferungen und Leistungen)</li>
* <li>Erlöskonto 8400 für 19 % USt, 8300 für 7 %, 8125 für innergemeinschaftliche
* Lieferungen / Reverse-Charge / sonstige steuerfreie Erlöse (rate = 0)</li>
* <li>Stornorechnungen werden mit umgekehrtem Soll/Haben-Kennzeichen gebucht.</li>
* </ul>
*
* <p>Formatdetails:
* <ul>
* <li>Zeichensatz: Windows-1252 (DATEV-Vorgabe).</li>
* <li>Feldtrenner: Semikolon, Texte in Anführungszeichen.</li>
* <li>Beträge mit Komma als Dezimaltrenner, ohne Tausender-Trenner.</li>
* <li>Zeilenumbruch: CRLF.</li>
* </ul>
*/
@Service
public class DatevExportService {
private static final String CSV_LINE_SEPARATOR = "\r\n";
private static final Charset DATEV_CHARSET = Charset.forName("Windows-1252");
private static final DateTimeFormatter HEADER_TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS",
Locale.GERMANY);
private static final DateTimeFormatter HEADER_DATE = DateTimeFormatter.ofPattern("yyyyMMdd", Locale.GERMANY);
private static final DateTimeFormatter VOUCHER_DATE = DateTimeFormatter.ofPattern("ddMM", Locale.GERMANY);
/** Sammeldebitor-Konto laut SKR03. */
static final String DEFAULT_DEBTOR_ACCOUNT = "10000";
private final CustomerInvoiceRepository invoiceRepository;
public DatevExportService(CustomerInvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
/**
* Lädt die Rechnungen des Nutzers im Bereich [from, to] (inkl.) und liefert
* die DATEV-CSV als Byte-Array. Zeitraum bezieht sich auf das Rechnungsdatum.
* Entwürfe werden ignoriert — nur festgeschriebene Belege gehören in die
* Buchhaltung. Liefert eine Datei mit Header und leerer Buchungsliste, wenn
* keine Rechnungen im Zeitraum vorliegen — das ist gewollt, weil DATEV einen
* leeren Stapel als „nichts zu importieren" akzeptiert.
*/
public byte[] export(String userId, LocalDate from, LocalDate to) {
if (userId == null || userId.isBlank()) {
throw new IllegalArgumentException("userId ist Pflicht.");
}
if (from == null || to == null) {
throw new IllegalArgumentException("Zeitraum (from/to) ist Pflicht.");
}
if (to.isBefore(from)) {
throw new IllegalArgumentException("Bis-Datum darf nicht vor dem Von-Datum liegen.");
}
List<CustomerInvoice> invoices = invoiceRepository.findByUserId(userId).stream()
.filter(this::isExportable)
.filter(inv -> inv.getInvoiceDate() != null
&& !inv.getInvoiceDate().isBefore(from)
&& !inv.getInvoiceDate().isAfter(to))
.sorted(Comparator.comparing(CustomerInvoice::getInvoiceDate)
.thenComparing(CustomerInvoice::getInvoiceNumber, Comparator.nullsLast(String::compareTo)))
.toList();
StringBuilder sb = new StringBuilder();
sb.append(buildHeader(from, to)).append(CSV_LINE_SEPARATOR);
sb.append(buildColumnHeader()).append(CSV_LINE_SEPARATOR);
for (CustomerInvoice invoice : invoices) {
sb.append(buildBookingRow(invoice, from)).append(CSV_LINE_SEPARATOR);
}
return sb.toString().getBytes(DATEV_CHARSET);
}
/**
* Schlägt einen Dateinamen vor: EXTF_Buchungsstapel_<userId>_<von>_<bis>.csv.
*/
public String suggestFilename(LocalDate from, LocalDate to) {
DateTimeFormatter df = DateTimeFormatter.ofPattern("yyyyMMdd");
return "EXTF_Buchungsstapel_" + from.format(df) + "_" + to.format(df) + ".csv";
}
private boolean isExportable(CustomerInvoice invoice) {
// Entwürfe werden nicht exportiert — sie sind buchhalterisch nicht relevant.
// Stornos und Korrekturen jedoch schon, damit der Saldo sauber bleibt.
return invoice.getStatus() != null && invoice.getStatus() != InvoiceStatus.DRAFT;
}
/**
* Header gemäß DATEV-Format „EXTF" Version 7. Felder, die wir nicht belegen
* können (Berater-/Mandantennummer), bleiben leer — DATEV importiert dann
* den Stapel ohne Zwangs-Mapping.
*/
private String buildHeader(LocalDate from, LocalDate to) {
String now = LocalDateTime.now().format(HEADER_TS);
return String.join(";",
quote("EXTF"), // Format-Kennzeichen
"700", // Versions-Nummer
"21", // Datenkategorie: Buchungsstapel
quote("Buchungsstapel"),
"7", // Format-Version
now, // Erzeugt am
"", // Importiert
quote("votianlt"), // Herkunft / erzeugendes System
quote(""), // Exportiert von
quote(""), // importiert von
"", // Beraternummer
"", // Mandantennummer
from.withDayOfMonth(1).format(HEADER_DATE), // Wirtschaftsjahresbeginn
"4", // Sachkontenlänge
from.format(HEADER_DATE), // Datum von
to.format(HEADER_DATE), // Datum bis
quote("Rechnungsexport"), // Bezeichnung
quote(""), // Diktatkürzel
"1", // Buchungstyp: Finanzbuchhaltung
"0", // Rechnungslegungszweck
quote("EUR"), // Festschreibung / WKZ
"", "", "", "", "", "", "", "");
}
private String buildColumnHeader() {
return String.join(";",
"Umsatz (ohne Soll/Haben-Kz)",
"Soll/Haben-Kennzeichen",
"WKZ Umsatz",
"Konto",
"Gegenkonto (ohne BU-Schlüssel)",
"BU-Schlüssel",
"Belegdatum",
"Belegfeld 1",
"Belegfeld 2",
"Buchungstext");
}
private String buildBookingRow(CustomerInvoice invoice, LocalDate periodFrom) {
BigDecimal gross = nonNull(invoice.getTotalAmount());
boolean isCancellation = invoice.getType() == InvoiceType.CANCELLATION
|| invoice.getStatus() == InvoiceStatus.CANCELLED;
BigDecimal absoluteGross = gross.abs().setScale(2, RoundingMode.HALF_UP);
// Reguläre Forderung: S an Erlöskonto. Storno dreht das Vorzeichen über
// S/H-Kennzeichen, nicht über negative Beträge — DATEV-konventionell.
String solHaben = isCancellation ? "H" : "S";
String revenueAccount = resolveRevenueAccount(invoice);
String voucherDate = invoice.getInvoiceDate().format(VOUCHER_DATE);
String invoiceNumber = invoice.getInvoiceNumber() != null ? invoice.getInvoiceNumber() : "";
String recipient = invoice.getRecipientName() != null ? invoice.getRecipientName() : "";
String text = (isCancellation ? "Storno: " : "Rechnung an ") + recipient;
return String.join(";",
formatAmount(absoluteGross),
solHaben,
quote("EUR"),
DEFAULT_DEBTOR_ACCOUNT,
revenueAccount,
"", // BU-Schlüssel: leer, USt steckt im Erlöskonto
voucherDate,
quote(invoiceNumber),
"",
quote(text));
}
/**
* Mappt den Steuersatz auf das passende SKR03-Erlöskonto. Konservative Wahl:
* Bei unbekannten Sätzen fällt der Service auf das 19 %-Konto zurück und
* markiert das via Buchungstext implizit — der Steuerberater sieht das beim
* Import und kann es korrigieren.
*/
private String resolveRevenueAccount(CustomerInvoice invoice) {
BigDecimal rate = invoice.getVatRate();
if (rate == null) {
return "8400";
}
BigDecimal scaled = rate.setScale(2, RoundingMode.HALF_UP);
if (scaled.compareTo(new BigDecimal("0.19")) == 0) {
return "8400";
}
if (scaled.compareTo(new BigDecimal("0.07")) == 0) {
return "8300";
}
if (scaled.signum() == 0) {
// Reverse-Charge / innergemeinschaftliche Lieferung / Kleinunternehmer
return "8125";
}
return "8400";
}
private BigDecimal nonNull(BigDecimal value) {
return value != null ? value : BigDecimal.ZERO;
}
private String formatAmount(BigDecimal value) {
// Komma als Dezimaltrenner, kein Tausender-Trenner.
return value.setScale(2, RoundingMode.HALF_UP).toPlainString().replace('.', ',');
}
private String quote(String value) {
if (value == null) {
return "\"\"";
}
// DATEV: doppelte Anführungszeichen werden durch Verdoppeln escaped.
return "\"" + value.replace("\"", "\"\"") + "\"";
}
}

View File

@@ -0,0 +1,472 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.config.EInvoiceProperties;
import de.assecutor.votianlt.model.invoices.UserSigningCredentials;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
import de.assecutor.votianlt.model.invoices.EInvoiceFormat;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.io.IOUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureOptions;
import org.bouncycastle.cert.jcajce.JcaCertStore;
import org.bouncycastle.cms.CMSException;
import org.bouncycastle.cms.CMSProcessableByteArray;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.cms.CMSSignedDataGenerator;
import org.bouncycastle.cms.CMSTypedData;
import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
import org.bouncycastle.util.Store;
import org.mustangproject.Contact;
import org.mustangproject.Invoice;
import org.mustangproject.Item;
import org.mustangproject.Product;
import org.mustangproject.TradeParty;
import org.mustangproject.ZUGFeRD.PDFAConformanceLevel;
import org.mustangproject.ZUGFeRD.Profiles;
import org.mustangproject.ZUGFeRD.ZUGFeRDExporterFromA1;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.Provider;
import java.security.Security;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
/**
* Erzeugt ZUGFeRD/Factur-X-konforme E-Rechnungen mit Mustangproject und
* signiert das Ergebnis optional via PAdES (iText + BouncyCastle).
*
* Aufrufer übergeben ein bereits gerendertes PDF (z.B. aus
* {@link CustomerInvoiceService}) und die Rechnungsdaten. Der Service
* konvertiert das PDF in ein PDF/A-3 mit eingebettetem ZUGFeRD-XML und
* hängt anschließend sofern konfiguriert eine PAdES-Signatur an.
*
* Schlägt einer der Schritte fehl, wird das Original-PDF unverändert zurückgegeben
* und der Fehler protokolliert. So bleibt die Rechnungserstellung als Ganzes
* funktionsfähig, falls die E-Rechnung-Konfiguration unvollständig ist.
*/
@Service
public class EInvoiceService {
private static final Logger log = LoggerFactory.getLogger(EInvoiceService.class);
private static final String DEFAULT_UNIT_CODE = "C62"; // ISO 6E - "one"
private static final String DEFAULT_COUNTRY = "DE";
private final EInvoiceProperties properties;
private final SigningCredentialsService signingCredentialsService;
static {
ensureBouncyCastleProvider();
}
public EInvoiceService(EInvoiceProperties properties, SigningCredentialsService signingCredentialsService) {
this.properties = properties;
this.signingCredentialsService = signingCredentialsService;
}
public boolean isEInvoiceEnabledGlobally() {
return properties.isEnabled();
}
public boolean isSigningConfigured() {
return properties.getSigning().isFullyConfigured();
}
public boolean isUserSigningAvailable(String userId) {
return signingCredentialsService.loadActive(userId).isPresent();
}
/**
* Reichert ein gerendertes PDF mit ZUGFeRD-XML an und/oder signiert es.
* Markiert das Ergebnis im übergebenen {@link CustomerInvoice} (Format/Signatur),
* persistiert die Rechnung selbst aber nicht.
*
* @param withZugferd ZUGFeRD/Factur-X-XML einbetten (graceful: bei Fehler Roh-PDF)
* @param withSignature PAdES-Signatur erzeugen (strikt: wirft
* {@link InvoiceLifecycleException}, wenn Zertifikat fehlt
* oder nicht entschlüsselt werden kann)
*/
public byte[] enhanceAndSign(byte[] basePdf, CustomerInvoice invoice, boolean withZugferd,
boolean withSignature) {
byte[] result = basePdf;
if (withZugferd && properties.isEnabled()) {
try {
result = embedZugferdXml(result, invoice);
invoice.setEInvoiceFormat(resolveFormat(properties.getProfile()));
} catch (Exception ex) {
log.warn("ZUGFeRD-Anreicherung fehlgeschlagen, fahre mit Roh-PDF fort: {}", ex.getMessage(), ex);
invoice.setEInvoiceFormat(EInvoiceFormat.NONE);
}
} else {
invoice.setEInvoiceFormat(EInvoiceFormat.NONE);
}
if (withSignature) {
result = signOrFail(result, invoice);
}
return result;
}
/**
* Convenience-Variante: ZUGFeRD und Signatur. Bestand für Aufrufer, die nicht
* die feingranulare Variante nutzen wollen.
*/
public byte[] enhanceAndSign(byte[] basePdf, CustomerInvoice invoice) {
return enhanceAndSign(basePdf, invoice, true, true);
}
/**
* Signiert das PDF — bevorzugt mit dem nutzerseitigen Keystore, ansonsten mit dem
* systemweit konfigurierten. Wirft eine {@link InvoiceLifecycleException}, wenn:
* <ul>
* <li>der Nutzer Credentials hinterlegt hat, diese aber nicht entschlüsselt
* oder geladen werden können (z.B. fehlender/falscher Master-Key,
* beschädigter Keystore),</li>
* <li>der Nutzer keine Credentials hinterlegt hat und auch kein System-Keystore
* konfiguriert ist,</li>
* <li>die eigentliche Signatur-Operation fehlschlägt.</li>
* </ul>
* Die Exception trägt eine anwendertaugliche Nachricht und wird in der UI als
* Notification angezeigt eine stille Rückfall-Strategie zu unsignierten PDFs
* findet absichtlich nicht mehr statt.
*/
private byte[] signOrFail(byte[] pdfBytes, CustomerInvoice invoice) {
String userId = invoice.getUserId();
java.util.Optional<UserSigningCredentials> stored = userId != null
? signingCredentialsService.findCredentials(userId)
: java.util.Optional.empty();
if (stored.isPresent()) {
UserSigningCredentials credentials = stored.get();
if (!credentials.isEnabled()) {
throw new InvoiceLifecycleException(
"Ihr hinterlegtes Signatur-Zertifikat ist deaktiviert. "
+ "Bitte aktivieren Sie es im Profil oder entfernen Sie es, um den System-Keystore zu nutzen.");
}
java.util.Optional<SigningCredentialsService.LoadedCredentials> loaded = signingCredentialsService
.loadActive(userId);
if (loaded.isEmpty()) {
throw new InvoiceLifecycleException(
"Ihr Signatur-Zertifikat konnte nicht entschlüsselt werden. "
+ "Möglicherweise ist der Server-Master-Key nicht gesetzt oder wurde geändert. "
+ "Bitte laden Sie das Zertifikat erneut hoch oder kontaktieren Sie den Administrator.");
}
try {
SigningCredentialsService.LoadedCredentials creds = loaded.get();
byte[] signed = signWithCredentials(pdfBytes, creds.getKeystore(), creds.getPassword(),
creds.getAlias());
invoice.setSigned(true);
invoice.setSignedAt(java.time.LocalDateTime.now());
invoice.setSignedBy(creds.getMetadata() != null && creds.getMetadata().getSubjectDn() != null
? creds.getMetadata().getSubjectDn()
: creds.getAlias());
return signed;
} catch (Exception ex) {
log.error("PAdES-Signatur mit Nutzer-Keystore fehlgeschlagen", ex);
throw new InvoiceLifecycleException(
"Die digitale Signatur ist fehlgeschlagen: " + ex.getMessage()
+ ". Bitte prüfen Sie Ihr Zertifikat im Profil.",
ex);
}
}
if (properties.getSigning().isFullyConfigured()) {
try {
byte[] signed = signPdf(pdfBytes);
invoice.setSigned(true);
invoice.setSignedAt(java.time.LocalDateTime.now());
invoice.setSignedBy(properties.getSigning().getKeyAlias());
return signed;
} catch (Exception ex) {
log.error("PAdES-Signatur mit System-Keystore fehlgeschlagen", ex);
throw new InvoiceLifecycleException(
"Die digitale Signatur ist fehlgeschlagen: " + ex.getMessage()
+ ". Bitte kontaktieren Sie den Administrator.",
ex);
}
}
throw new InvoiceLifecycleException(
"Es ist kein Signatur-Zertifikat verfügbar. "
+ "Bitte hinterlegen Sie ein eigenes Zertifikat in Ihrem Profil "
+ "oder bitten Sie den Administrator, einen System-Keystore zu konfigurieren.");
}
/**
* Bettet ZUGFeRD-XML in ein PDF ein und liefert ein PDF/A-3 zurück.
* Wirft bei Fehlschlag eine {@link RuntimeException} der Caller entscheidet,
* ob mit dem Original-PDF fortgefahren wird.
*/
public byte[] embedZugferdXml(byte[] basePdf, CustomerInvoice invoice) throws Exception {
Invoice mustangInvoice = toMustangInvoice(invoice);
try (ZUGFeRDExporterFromA1 exporter = new ZUGFeRDExporterFromA1()) {
exporter.setProducer("votianlt");
exporter.setCreator("votianlt");
exporter.ignorePDFAErrors();
exporter.setConformanceLevel(PDFAConformanceLevel.UNICODE);
exporter.setProfile(resolveProfile(properties.getProfile()));
exporter.load(basePdf);
exporter.setTransaction(mustangInvoice);
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
exporter.export(baos);
return baos.toByteArray();
}
}
}
/**
* PAdES-Signatur mit dem systemweit konfigurierten Keystore (Fallback-Pfad).
* Liest Keystore-Datei und Passwort aus {@link EInvoiceProperties.Signing}.
*/
public byte[] signPdf(byte[] pdfBytes) throws Exception {
EInvoiceProperties.Signing config = properties.getSigning();
if (!config.isFullyConfigured()) {
throw new IllegalStateException("Signatur-Konfiguration unvollständig.");
}
KeyStore keystore = KeyStore.getInstance("PKCS12");
try (FileInputStream fis = new FileInputStream(Path.of(config.getKeystorePath()).toFile())) {
keystore.load(fis, config.getKeystorePassword().toCharArray());
}
return signWithCredentials(pdfBytes, keystore, config.getKeystorePassword().toCharArray(),
config.getKeyAlias());
}
/**
* Kern-Signaturpfad — wird von System- wie Nutzer-Keystore-Variante geteilt.
* Erzeugt eine PAdES-Detached-CMS-Signatur (SHA-256 / RSA) via PDFBox + BouncyCastle.
*/
private byte[] signWithCredentials(byte[] pdfBytes, KeyStore keystore, char[] password, String alias)
throws Exception {
if (alias == null || alias.isBlank()) {
throw new IllegalStateException("Alias zum Signieren erforderlich.");
}
PrivateKey privateKey = (PrivateKey) keystore.getKey(alias, password);
if (privateKey == null) {
throw new IllegalStateException("Schlüssel '" + alias + "' im Keystore nicht gefunden.");
}
Certificate[] chain = keystore.getCertificateChain(alias);
if (chain == null || chain.length == 0) {
throw new IllegalStateException("Zertifikatskette für Alias '" + alias + "' leer.");
}
EInvoiceProperties.Signing config = properties.getSigning();
SignatureInterface signer = content -> buildCmsSignature(content, privateKey, chain);
try (PDDocument document = Loader.loadPDF(pdfBytes);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
PDSignature pdSignature = new PDSignature();
pdSignature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
pdSignature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED);
pdSignature.setName("votianlt");
if (notBlank(config.getReason())) {
pdSignature.setReason(config.getReason());
}
if (notBlank(config.getLocation())) {
pdSignature.setLocation(config.getLocation());
}
if (notBlank(config.getContact())) {
pdSignature.setContactInfo(config.getContact());
}
pdSignature.setSignDate(Calendar.getInstance());
SignatureOptions options = new SignatureOptions();
options.setPreferredSignatureSize(SignatureOptions.DEFAULT_SIGNATURE_SIZE * 2);
document.addSignature(pdSignature, signer, options);
document.saveIncremental(baos);
options.close();
return baos.toByteArray();
}
}
private byte[] buildCmsSignature(InputStream content, PrivateKey privateKey, Certificate[] chain)
throws IOException {
try {
byte[] data = IOUtils.toByteArray(content);
CMSTypedData typedData = new CMSProcessableByteArray(data);
String providerName = bouncyCastleProviderName();
JcaContentSignerBuilder signerBuilder = new JcaContentSignerBuilder("SHA256withRSA");
if (providerName != null) {
signerBuilder.setProvider(providerName);
}
ContentSigner contentSigner = signerBuilder.build(privateKey);
JcaDigestCalculatorProviderBuilder digestBuilder = new JcaDigestCalculatorProviderBuilder();
if (providerName != null) {
digestBuilder.setProvider(providerName);
}
JcaSignerInfoGeneratorBuilder infoGenBuilder = new JcaSignerInfoGeneratorBuilder(digestBuilder.build());
CMSSignedDataGenerator gen = new CMSSignedDataGenerator();
gen.addSignerInfoGenerator(infoGenBuilder.build(contentSigner, (X509Certificate) chain[0]));
List<X509Certificate> certList = new ArrayList<>();
for (Certificate cert : chain) {
if (cert instanceof X509Certificate x509) {
certList.add(x509);
}
}
Store<?> certStore = new JcaCertStore(certList);
gen.addCertificates(certStore);
CMSSignedData signedData = gen.generate(typedData, false);
return signedData.getEncoded();
} catch (CMSException | OperatorCreationException | java.security.cert.CertificateEncodingException ex) {
throw new IOException("CMS-Signatur fehlgeschlagen: " + ex.getMessage(), ex);
}
}
private Invoice toMustangInvoice(CustomerInvoice invoice) {
Invoice mustang = new Invoice();
mustang.setNumber(safe(invoice.getInvoiceNumber()));
mustang.setIssueDate(toDate(invoice.getInvoiceDate()));
mustang.setDeliveryDate(toDate(invoice.getDeliveryDate() != null ? invoice.getDeliveryDate()
: invoice.getInvoiceDate()));
if (invoice.getPaymentDueDate() != null) {
mustang.setDueDate(toDate(invoice.getPaymentDueDate()));
}
mustang.setSender(buildSender(invoice));
mustang.setRecipient(buildRecipient(invoice));
BigDecimal vatPercent = toPercent(invoice.getVatRate());
List<CustomerInvoiceItem> items = invoice.getItems();
if (items != null && !items.isEmpty()) {
for (CustomerInvoiceItem source : items) {
Product product = new Product(safe(source.getDescription()), "", DEFAULT_UNIT_CODE, vatPercent);
BigDecimal qty = source.getQuantity() != null ? source.getQuantity() : BigDecimal.ONE;
BigDecimal price = source.getUnitPrice() != null ? source.getUnitPrice() : BigDecimal.ZERO;
Item item = new Item(product, price, qty);
mustang.addItem(item);
}
} else {
// Fallback: ein Sammelposten mit dem Nettobetrag
Product product = new Product(safe(invoice.getDescription()), "", DEFAULT_UNIT_CODE, vatPercent);
BigDecimal price = invoice.getNetAmount() != null ? invoice.getNetAmount() : BigDecimal.ZERO;
mustang.addItem(new Item(product, price, BigDecimal.ONE));
}
return mustang;
}
private TradeParty buildSender(CustomerInvoice invoice) {
TradeParty sender = new TradeParty(safe(invoice.getSenderName()), safe(invoice.getSenderAddress()),
safe(invoice.getSenderPostcode()), safe(invoice.getSenderCity()),
safe(invoice.getSenderCountry(), DEFAULT_COUNTRY));
if (notBlank(invoice.getSenderVatId())) {
sender.setVATID(invoice.getSenderVatId());
}
if (notBlank(invoice.getSenderTaxNumber())) {
sender.setTaxID(invoice.getSenderTaxNumber());
}
Contact contact = new Contact(safe(invoice.getSenderName()), safe(invoice.getSenderPhone()),
safe(invoice.getSenderEmail()));
sender.setContact(contact);
return sender;
}
private TradeParty buildRecipient(CustomerInvoice invoice) {
String displayName = notBlank(invoice.getRecipientCompany()) ? invoice.getRecipientCompany()
: safe(invoice.getRecipientName());
TradeParty recipient = new TradeParty(displayName, safe(invoice.getRecipientAddress()),
safe(invoice.getRecipientPostcode()), safe(invoice.getRecipientCity()),
safe(invoice.getRecipientCountry(), DEFAULT_COUNTRY));
if (notBlank(invoice.getRecipientVatId())) {
recipient.setVATID(invoice.getRecipientVatId());
}
if (notBlank(invoice.getRecipientName())) {
recipient.setContact(new Contact(invoice.getRecipientName(), "", ""));
}
return recipient;
}
private BigDecimal toPercent(BigDecimal vatRate) {
if (vatRate == null) {
return new BigDecimal("19");
}
return vatRate.multiply(new BigDecimal("100")).setScale(2, RoundingMode.HALF_UP);
}
private Date toDate(LocalDate date) {
if (date == null) {
return Date.from(LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant());
}
return Date.from(date.atStartOfDay(ZoneId.systemDefault()).toInstant());
}
private org.mustangproject.ZUGFeRD.Profile resolveProfile(String requested) {
if (requested == null) {
return Profiles.getByName("EN16931");
}
try {
return Profiles.getByName(requested.toUpperCase());
} catch (Exception ex) {
log.warn("Unbekanntes ZUGFeRD-Profil '{}', verwende EN16931.", requested);
return Profiles.getByName("EN16931");
}
}
private EInvoiceFormat resolveFormat(String profile) {
if (profile != null && profile.toUpperCase().contains("XRECHNUNG")) {
return EInvoiceFormat.XRECHNUNG;
}
return EInvoiceFormat.ZUGFERD;
}
private static void ensureBouncyCastleProvider() {
if (Security.getProvider("BC") == null) {
try {
Class<?> providerClass = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider");
Provider provider = (Provider) providerClass.getDeclaredConstructor().newInstance();
Security.addProvider(provider);
} catch (Exception ex) {
LoggerFactory.getLogger(EInvoiceService.class)
.warn("BouncyCastle Provider konnte nicht registriert werden: {}", ex.getMessage());
}
}
}
private static String bouncyCastleProviderName() {
return Security.getProvider("BC") != null ? "BC" : null;
}
private boolean notBlank(String value) {
return value != null && !value.isBlank();
}
private String safe(String value) {
return value != null ? value : "";
}
private String safe(String value, String fallback) {
return value != null && !value.isBlank() ? value : fallback;
}
}

View File

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

View File

@@ -0,0 +1,35 @@
package de.assecutor.votianlt.service;
import java.util.Collections;
import java.util.List;
/**
* Wird geworfen, wenn eine Rechnung beim Festschreiben gegen Pflichtangaben
* nach § 14 UStG bzw. interne Konsistenzregeln verstößt. Die Verstöße werden
* gesammelt geliefert, damit der Anwender alle Korrekturen in einem Schritt
* durchführen kann statt jeden Fehler einzeln zu beheben.
*/
public class InvoiceComplianceException extends InvoiceLifecycleException {
private final List<String> violations;
public InvoiceComplianceException(List<String> violations) {
super(buildMessage(violations));
this.violations = List.copyOf(violations);
}
public List<String> getViolations() {
return Collections.unmodifiableList(violations);
}
private static String buildMessage(List<String> violations) {
if (violations == null || violations.isEmpty()) {
return "Die Rechnung erfüllt die gesetzlichen Pflichtangaben nicht.";
}
StringBuilder sb = new StringBuilder("Die Rechnung kann nicht festgeschrieben werden — folgende Pflichtangaben fehlen oder sind inkonsistent:");
for (String violation : violations) {
sb.append("\n • ").append(violation);
}
return sb.toString();
}
}

View File

@@ -0,0 +1,189 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
/**
* Prüft eine {@link CustomerInvoice} auf die Pflichtangaben nach § 14 UStG
* sowie auf interne Konsistenz (Beträge, Items). Wird vor jeder Festschreibung
* (Übergang DRAFT → ISSUED) aufgerufen; eine festgeschriebene Rechnung darf
* keine Pflichtfeld-Lücken mehr haben, da sie nach R-08 nicht mehr direkt
* änderbar ist.
*
* Nicht abgedeckt (bewusst):
* <ul>
* <li>Lückenlose Rechnungsnummer-Vergabe (separater Block).</li>
* <li>Online-Validierung der USt-IdNr beim Bzst (separater Block).</li>
* <li>Storno-/Korrekturbelege — diese haben eigene Beleg-Regeln (negierte Beträge,
* Pflicht-Verweis auf Originalrechnung), die hier nicht greifen.</li>
* </ul>
*
* Toleranz für Beträge: 1 Cent. Damit fängt der Validator typische Rundungs-
* differenzen aus dezimaler Arithmetik ab, ohne echte Inkonsistenzen zu schlucken.
*/
@Service
public class InvoiceComplianceValidator {
private static final BigDecimal AMOUNT_TOLERANCE = new BigDecimal("0.01");
/**
* Wirft {@link InvoiceComplianceException} mit allen gefundenen Verstößen,
* wenn die Rechnung nicht festschreibungsreif ist. Andernfalls passiert nichts.
*/
public void validateForIssuance(CustomerInvoice invoice) {
if (invoice == null) {
throw new IllegalArgumentException("Rechnung darf nicht null sein.");
}
if (invoice.getType() != null && invoice.getType() != InvoiceType.INVOICE) {
// Storno/Korrektur folgen anderen Regeln und werden hier nicht geprüft.
return;
}
List<String> violations = collectViolations(invoice);
if (!violations.isEmpty()) {
throw new InvoiceComplianceException(violations);
}
}
private List<String> collectViolations(CustomerInvoice invoice) {
List<String> violations = new ArrayList<>();
checkInvoiceNumber(invoice, violations);
checkDates(invoice, violations);
checkSender(invoice, violations);
checkRecipient(invoice, violations);
checkItems(invoice, violations);
checkAmounts(invoice, violations);
checkVatNotices(invoice, violations);
return violations;
}
private void checkInvoiceNumber(CustomerInvoice invoice, List<String> violations) {
if (isBlank(invoice.getInvoiceNumber())) {
violations.add("Rechnungsnummer fehlt (§ 14 Abs. 4 Nr. 4 UStG).");
}
}
private void checkDates(CustomerInvoice invoice, List<String> violations) {
if (invoice.getInvoiceDate() == null) {
violations.add("Rechnungsdatum (Ausstellungsdatum) fehlt (§ 14 Abs. 4 Nr. 3 UStG).");
}
if (invoice.getDeliveryDate() == null) {
violations.add("Leistungsdatum fehlt (§ 14 Abs. 4 Nr. 6 UStG). "
+ "Bei zeitgleicher Leistung kann es dem Rechnungsdatum entsprechen — muss aber gesetzt sein.");
}
}
private void checkSender(CustomerInvoice invoice, List<String> violations) {
if (isBlank(invoice.getSenderName())) {
violations.add("Name des Leistenden (Absender) fehlt (§ 14 Abs. 4 Nr. 1 UStG).");
}
if (isBlank(invoice.getSenderAddress()) || isBlank(invoice.getSenderPostcode())
|| isBlank(invoice.getSenderCity())) {
violations.add("Vollständige Anschrift des Leistenden (Straße, PLZ, Ort) fehlt (§ 14 Abs. 4 Nr. 1 UStG).");
}
if (isBlank(invoice.getSenderTaxNumber()) && isBlank(invoice.getSenderVatId())) {
violations.add("Steuernummer oder USt-IdNr des Leistenden fehlt (§ 14 Abs. 4 Nr. 2 UStG).");
}
}
private void checkRecipient(CustomerInvoice invoice, List<String> violations) {
if (isBlank(invoice.getRecipientName())) {
violations.add("Name des Leistungsempfängers fehlt (§ 14 Abs. 4 Nr. 1 UStG).");
}
if (isBlank(invoice.getRecipientAddress()) || isBlank(invoice.getRecipientPostcode())
|| isBlank(invoice.getRecipientCity())) {
violations.add("Vollständige Anschrift des Leistungsempfängers (Straße, PLZ, Ort) fehlt "
+ "(§ 14 Abs. 4 Nr. 1 UStG).");
}
}
private void checkItems(CustomerInvoice invoice, List<String> violations) {
List<CustomerInvoiceItem> items = invoice.getItems();
if (items == null || items.isEmpty()) {
violations.add("Keine Positionen erfasst — Menge und Art der Leistung sind erforderlich "
+ "(§ 14 Abs. 4 Nr. 5 UStG).");
return;
}
for (int i = 0; i < items.size(); i++) {
CustomerInvoiceItem item = items.get(i);
int rowNumber = i + 1;
if (item == null) {
violations.add("Position " + rowNumber + ": leere Position.");
continue;
}
if (isBlank(item.getDescription())) {
violations.add("Position " + rowNumber + ": Bezeichnung der Leistung fehlt.");
}
if (item.getQuantity() == null || item.getQuantity().signum() <= 0) {
violations.add("Position " + rowNumber + ": Menge muss größer 0 sein.");
}
if (item.getUnitPrice() == null || item.getUnitPrice().signum() < 0) {
violations.add("Position " + rowNumber + ": Einzelpreis fehlt oder ist negativ.");
}
}
}
private void checkAmounts(CustomerInvoice invoice, List<String> violations) {
BigDecimal net = invoice.getNetAmount();
BigDecimal vat = invoice.getVatAmount();
BigDecimal total = invoice.getTotalAmount();
if (net == null || vat == null || total == null) {
violations.add("Beträge unvollständig: Netto, Steuerbetrag und Bruttobetrag müssen ausgewiesen sein "
+ "(§ 14 Abs. 4 Nr. 7 + 8 UStG).");
return;
}
if (net.add(vat).subtract(total).abs().compareTo(AMOUNT_TOLERANCE) > 0) {
violations.add("Bruttobetrag passt nicht zu Netto + Steuerbetrag (Differenz > 1 Cent).");
}
if (invoice.getItems() != null && !invoice.getItems().isEmpty()) {
BigDecimal sumItems = invoice.getItems().stream()
.map(CustomerInvoiceItem::getNetTotal)
.filter(java.util.Objects::nonNull)
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (sumItems.subtract(net).abs().compareTo(AMOUNT_TOLERANCE) > 0) {
violations.add("Summe der Positionen (netto) " + sumItems + " weicht vom Rechnungs-Netto " + net
+ " ab (Differenz > 1 Cent).");
}
}
}
private void checkVatNotices(CustomerInvoice invoice, List<String> violations) {
BigDecimal rate = invoice.getVatRate();
if (rate == null) {
violations.add("Steuersatz fehlt (§ 14 Abs. 4 Nr. 8 UStG).");
return;
}
if (rate.signum() == 0) {
// Bei 0 % USt verlangt das UStG einen erklärenden Hinweis — entweder
// Reverse-Charge (§ 13b), Kleinunternehmerregelung (§ 19), eine
// innergemeinschaftliche Lieferung (§ 6a) oder eine andere Steuerbefreiung.
// Ohne Hinweis ist eine 0 %-Rechnung formal mangelhaft.
boolean hasNotice = !isBlank(invoice.getReverseChargeNote()) || !isBlank(invoice.getLegalNotes());
if (!hasNotice) {
violations.add("Bei 0 % USt ist ein rechtlicher Hinweis erforderlich "
+ "(z.B. \"Steuerschuldnerschaft des Leistungsempfängers\" nach § 13b UStG, "
+ "Kleinunternehmerregelung § 19 UStG oder Steuerbefreiung). "
+ "Bitte im Feld \"Reverse-Charge-Hinweis\" oder \"Rechtliche Hinweise\" ergänzen.");
}
} else {
BigDecimal expectedVat = invoice.getNetAmount() != null
? invoice.getNetAmount().multiply(rate).setScale(2, RoundingMode.HALF_UP)
: null;
if (expectedVat != null && invoice.getVatAmount() != null
&& expectedVat.subtract(invoice.getVatAmount()).abs().compareTo(AMOUNT_TOLERANCE) > 0) {
violations.add("Ausgewiesener Steuerbetrag " + invoice.getVatAmount()
+ " passt nicht zu Netto × Steuersatz (erwartet " + expectedVat + ").");
}
}
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}

View File

@@ -0,0 +1,182 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* Export-Funktion für Rechnungen gemäß R-33 und R-34.
*
* Bündelt eine Originalrechnung mit allen erzeugten Folgebelegen
* (Storno, Berichtigung, Ersatzrechnung) sowie einer Manifest-Datei
* mit Audit-Log und Verkettungsangaben in einer ZIP-Datei.
*/
@Service
public class InvoiceExportService {
private static final DateTimeFormatter TS_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss",
Locale.GERMANY);
private final CustomerInvoiceRepository invoiceRepository;
public InvoiceExportService(CustomerInvoiceRepository invoiceRepository) {
this.invoiceRepository = invoiceRepository;
}
/**
* Erzeugt ein ZIP-Archiv mit dem Originalbeleg, allen verlinkten Folgebelegen
* sowie einer Manifest-Datei. Ist die übergebene Rechnung selbst ein Folgebeleg,
* wird automatisch das Bündel um die zugehörige Originalrechnung erweitert.
*/
public byte[] exportInvoicePackage(CustomerInvoice anchor) {
if (anchor == null) {
throw new IllegalArgumentException("Rechnung erforderlich.");
}
CustomerInvoice root = resolveRoot(anchor);
List<CustomerInvoice> bundle = collectBundle(root);
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
ZipOutputStream zip = new ZipOutputStream(baos, StandardCharsets.UTF_8)) {
for (CustomerInvoice invoice : bundle) {
writePdfEntry(zip, invoice);
}
String manifest = buildManifest(root, bundle);
ZipEntry manifestEntry = new ZipEntry("MANIFEST.txt");
zip.putNextEntry(manifestEntry);
zip.write(manifest.getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
zip.finish();
return baos.toByteArray();
} catch (Exception ex) {
throw new IllegalStateException("Export fehlgeschlagen: " + ex.getMessage(), ex);
}
}
/**
* Schlägt einen Dateinamen für das ZIP-Archiv auf Basis des Originalbelegs vor.
*/
public String suggestFilename(CustomerInvoice anchor) {
CustomerInvoice root = resolveRoot(anchor);
String number = root.getInvoiceNumber() != null ? root.getInvoiceNumber() : root.getId();
return "Rechnung_" + sanitize(number) + ".zip";
}
private CustomerInvoice resolveRoot(CustomerInvoice anchor) {
if (anchor.getType() != InvoiceType.INVOICE && anchor.getOriginalInvoiceId() != null) {
return invoiceRepository.findById(anchor.getOriginalInvoiceId()).orElse(anchor);
}
return anchor;
}
private List<CustomerInvoice> collectBundle(CustomerInvoice root) {
List<CustomerInvoice> result = new ArrayList<>();
Set<String> seen = new HashSet<>();
result.add(root);
seen.add(root.getId());
for (CustomerInvoice related : invoiceRepository.findByOriginalInvoiceId(root.getId())) {
if (related.getId() != null && seen.add(related.getId())) {
result.add(related);
}
}
return result;
}
private void writePdfEntry(ZipOutputStream zip, CustomerInvoice invoice) throws java.io.IOException {
if (invoice.getPdfData() == null || invoice.getPdfData().length == 0) {
return;
}
String label = switch (invoice.getType() != null ? invoice.getType() : InvoiceType.INVOICE) {
case INVOICE -> "Rechnung";
case CANCELLATION -> "Storno";
case CORRECTION -> "Berichtigung";
};
String number = invoice.getInvoiceNumber() != null ? invoice.getInvoiceNumber() : invoice.getId();
String name = sanitize(label + "_" + number) + ".pdf";
ZipEntry entry = new ZipEntry(name);
zip.putNextEntry(entry);
zip.write(invoice.getPdfData());
zip.closeEntry();
}
private String buildManifest(CustomerInvoice root, List<CustomerInvoice> bundle) {
StringBuilder sb = new StringBuilder();
sb.append("Rechnungspaket\n");
sb.append("===============\n\n");
sb.append("Originalrechnung: ").append(safe(root.getInvoiceNumber()));
if (root.getInvoiceDate() != null) {
sb.append(" vom ").append(root.getInvoiceDate());
}
sb.append("\n");
sb.append("Status: ").append(root.getStatus()).append("\n");
sb.append("Zahlungsstatus: ").append(root.getPaymentStatus()).append("\n");
if (root.getTotalAmount() != null) {
sb.append("Gesamtbetrag: ").append(root.getTotalAmount()).append("\n");
}
if (root.getPaidAmount() != null) {
sb.append("Bezahlt: ").append(root.getPaidAmount()).append("\n");
}
sb.append("\nEnthaltene Belege:\n");
for (CustomerInvoice invoice : bundle) {
sb.append("- [").append(invoice.getType()).append("] ")
.append(safe(invoice.getInvoiceNumber()));
if (invoice.getInvoiceDate() != null) {
sb.append(" vom ").append(invoice.getInvoiceDate());
}
sb.append(" — Status ").append(invoice.getStatus()).append("\n");
}
sb.append("\nÄnderungsprotokoll der Originalrechnung:\n");
List<InvoiceAuditEntry> log = root.getAuditLog();
if (log == null || log.isEmpty()) {
sb.append("(keine Einträge)\n");
} else {
for (InvoiceAuditEntry entry : log) {
sb.append("- ");
sb.append(entry.getTimestamp() != null ? entry.getTimestamp().format(TS_FMT) : "-");
sb.append(" · ").append(entry.getAction());
sb.append(" · ").append(safe(entry.getUserDisplayName()));
if (entry.getReason() != null && !entry.getReason().isBlank()) {
sb.append("").append(entry.getReason());
}
if (entry.getResultingInvoiceNumber() != null) {
sb.append("").append(entry.getResultingInvoiceNumber());
}
sb.append("\n");
}
}
sb.append(
"\nHinweis: Dieses Paket dient der gemeinsamen Aufbewahrung von Original und Folgebelegen.\n");
sb.append("Die rechtliche Aufbewahrungspflicht liegt beim Aussteller (R-31/R-32).\n");
return sb.toString();
}
private String safe(String value) {
return value != null ? value : "";
}
private String sanitize(String input) {
if (input == null) {
return "Beleg";
}
return input.replaceAll("[^A-Za-z0-9._-]", "_");
}
}

View File

@@ -0,0 +1,18 @@
package de.assecutor.votianlt.service;
/**
* Wird geworfen, wenn ein Statusübergang oder eine Änderung an einer Rechnung
* gegen die Regeln aus <code>invoices_rules.md</code> verstößt (z.B. R-03, R-08, R-11, R-35).
*
* Die Nachricht ist als Anwendertext formuliert und kann direkt in der UI angezeigt werden.
*/
public class InvoiceLifecycleException extends RuntimeException {
public InvoiceLifecycleException(String message) {
super(message);
}
public InvoiceLifecycleException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,572 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.EInvoiceFormat;
import de.assecutor.votianlt.model.invoices.InvoiceAuditAction;
import de.assecutor.votianlt.model.invoices.InvoiceAuditEntry;
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import de.assecutor.votianlt.model.invoices.PaymentStatus;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import de.assecutor.votianlt.security.SecurityService;
import org.bson.types.ObjectId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* Verwaltet den Lebenszyklus einer {@link CustomerInvoice} gemäß den Regeln aus
* <code>invoices_rules.md</code>.
*
* Phase 1 stellt die Status- und Audit-Mechanik bereit:
* <ul>
* <li>R-02/R-03: Entwürfe sind editier-/löschbar, finalisierte Belege nicht.</li>
* <li>R-07: Finalisierung markiert eine Rechnung als verbindlich.</li>
* <li>R-08/R-11: Verhindert Doppelvergabe der Rechnungsnummer und unsichtbare Änderungen.</li>
* <li>R-35: Direktes Löschen finalisierter Belege wird abgelehnt.</li>
* <li>R-36 bis R-39: Jede Statusänderung wird im Audit-Log protokolliert.</li>
* </ul>
*
* Korrektur-/Storno-Workflows folgen in Phase 2; entsprechende Hooks werden hier vorbereitet.
*/
@Service
public class InvoiceLifecycleService {
private static final Logger log = LoggerFactory.getLogger(InvoiceLifecycleService.class);
private final CustomerInvoiceRepository invoiceRepository;
private final SecurityService securityService;
private final EInvoiceService eInvoiceService;
private final InvoiceComplianceValidator complianceValidator;
private final InvoiceNumberAuditService numberAuditService;
public InvoiceLifecycleService(CustomerInvoiceRepository invoiceRepository, SecurityService securityService,
EInvoiceService eInvoiceService, InvoiceComplianceValidator complianceValidator,
InvoiceNumberAuditService numberAuditService) {
this.invoiceRepository = invoiceRepository;
this.securityService = securityService;
this.eInvoiceService = eInvoiceService;
this.complianceValidator = complianceValidator;
this.numberAuditService = numberAuditService;
}
/**
* Persistiert einen neu erzeugten Rechnungsentwurf (Status DRAFT).
*/
public CustomerInvoice createDraft(CustomerInvoice draft, String reason) {
if (draft == null) {
throw new IllegalArgumentException("Rechnungsentwurf darf nicht null sein.");
}
draft.setStatus(InvoiceStatus.DRAFT);
if (draft.getType() == null) {
draft.setType(InvoiceType.INVOICE);
}
draft.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
return invoiceRepository.save(draft);
}
/**
* Erzeugt eine Rechnung und finalisiert sie unmittelbar (Status ISSUED).
* Wird vom bestehenden Erstell-Flow verwendet, der Vorschau und Speichern in
* einem Schritt vereint (R-06/R-07).
*/
public CustomerInvoice createAndIssue(CustomerInvoice invoice, String reason) {
if (invoice == null) {
throw new IllegalArgumentException("Rechnung darf nicht null sein.");
}
if (invoice.getType() == null) {
invoice.setType(InvoiceType.INVOICE);
}
complianceValidator.validateForIssuance(invoice);
ensureInvoiceNumberUnique(invoice);
invoice.setStatus(InvoiceStatus.ISSUED);
invoice.setIssuedAt(LocalDateTime.now());
invoice.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
invoice.addAuditEntry(audit(InvoiceAuditAction.ISSUED, reason));
CustomerInvoice saved = invoiceRepository.save(invoice);
numberAuditService.markUsed(saved);
return saved;
}
/**
* Speichert Änderungen an einem bestehenden Entwurf (R-02/R-05).
* Lehnt Änderungen an finalisierten Rechnungen ab (R-03/R-08).
*/
public CustomerInvoice updateDraft(CustomerInvoice draft, String reason) {
if (draft == null || draft.getId() == null) {
throw new IllegalArgumentException("Bestehender Entwurf erwartet.");
}
CustomerInvoice persisted = invoiceRepository.findById(draft.getId())
.orElseThrow(() -> new IllegalStateException("Rechnung nicht gefunden: " + draft.getId()));
if (!persisted.getStatus().isMutable()) {
throw new InvoiceLifecycleException(
"Diese Rechnung ist bereits ausgestellt und kann nicht mehr direkt bearbeitet werden. "
+ "Bitte erstellen Sie eine Berichtigung oder ein Storno.");
}
draft.setStatus(InvoiceStatus.DRAFT);
draft.setAuditLog(persisted.getAuditLog());
draft.addAuditEntry(audit(InvoiceAuditAction.UPDATED_DRAFT, reason));
return invoiceRepository.save(draft);
}
/**
* Finalisiert einen Entwurf (Status ISSUED). Stellt sicher, dass die Rechnungsnummer
* eindeutig ist (R-11) und protokolliert den Wechsel.
*/
public CustomerInvoice issue(String invoiceId, String reason) {
CustomerInvoice invoice = requireInvoice(invoiceId);
if (invoice.getStatus() == InvoiceStatus.ISSUED || invoice.getStatus() == InvoiceStatus.SENT) {
return invoice;
}
if (invoice.getStatus() != InvoiceStatus.DRAFT) {
throw new InvoiceLifecycleException(
"Nur Entwürfe können ausgestellt werden. Aktueller Status: " + invoice.getStatus());
}
complianceValidator.validateForIssuance(invoice);
ensureInvoiceNumberUnique(invoice);
invoice.setStatus(InvoiceStatus.ISSUED);
invoice.setIssuedAt(LocalDateTime.now());
invoice.addAuditEntry(audit(InvoiceAuditAction.ISSUED, reason));
CustomerInvoice saved = invoiceRepository.save(invoice);
numberAuditService.markUsed(saved);
return saved;
}
/**
* Markiert eine ausgestellte Rechnung als versendet (R-08).
*/
public CustomerInvoice markAsSent(String invoiceId, String reason) {
CustomerInvoice invoice = requireInvoice(invoiceId);
if (invoice.getStatus() == InvoiceStatus.DRAFT) {
throw new InvoiceLifecycleException(
"Eine Rechnung muss vor dem Versand zunächst ausgestellt werden.");
}
if (invoice.getStatus() == InvoiceStatus.CANCELLED) {
throw new InvoiceLifecycleException("Eine stornierte Rechnung kann nicht mehr versendet werden.");
}
invoice.setStatus(InvoiceStatus.SENT);
invoice.setSentAt(LocalDateTime.now());
invoice.addAuditEntry(audit(InvoiceAuditAction.SENT, reason));
return invoiceRepository.save(invoice);
}
/**
* Erzeugt einen Stornobeleg zu einer bereits ausgestellten Rechnung (R-17 bis R-22).
*
* Der Stornobeleg ist ein eigenständiger Beleg vom Typ {@link InvoiceType#CANCELLATION}
* mit eigener (neuer) Rechnungsnummer. Die Originalrechnung wird auf
* {@link InvoiceStatus#CANCELLED} gesetzt; Original und Storno sind über
* {@code originalInvoiceId} bzw. {@code cancellationInvoiceId} verlinkt.
*
* @param originalId ID der zu stornierenden Rechnung
* @param cancellationNumber neue, fortlaufende Rechnungsnummer für den Stornobeleg
* @param cancellationDate Belegdatum des Stornos
* @param pdfData generiertes PDF des Stornobelegs
* @param reason nachvollziehbarer Grund (R-36)
*/
public CustomerInvoice cancel(String originalId, String cancellationNumber, LocalDate cancellationDate,
byte[] pdfData, String reason) {
CustomerInvoice original = requireInvoice(originalId);
if (original.getType() != InvoiceType.INVOICE) {
throw new InvoiceLifecycleException(
"Nur reguläre Rechnungen können storniert werden. Belegtyp: " + original.getType());
}
if (original.getStatus() == InvoiceStatus.CANCELLED) {
throw new InvoiceLifecycleException("Diese Rechnung ist bereits storniert.");
}
if (original.getStatus() == InvoiceStatus.DRAFT) {
throw new InvoiceLifecycleException(
"Ein Entwurf wird nicht storniert, sondern gelöscht oder bearbeitet.");
}
if (cancellationNumber == null || cancellationNumber.isBlank()) {
throw new InvoiceLifecycleException("Stornobeleg benötigt eine fortlaufende Belegnummer.");
}
CustomerInvoice cancellation = new CustomerInvoice();
cancellation.setType(InvoiceType.CANCELLATION);
cancellation.setStatus(InvoiceStatus.ISSUED);
cancellation.setInvoiceNumber(cancellationNumber);
cancellation.setInvoiceDate(cancellationDate != null ? cancellationDate : LocalDate.now());
cancellation.setIssuedAt(LocalDateTime.now());
cancellation.setUserId(original.getUserId());
cancellation.setJobId(original.getJobId());
cancellation.setOriginalInvoiceId(original.getId());
cancellation.setOriginalInvoiceNumber(original.getInvoiceNumber());
cancellation.setOriginalInvoiceDate(original.getInvoiceDate());
// Empfänger-/Sender-Daten übernehmen für vollständige Pflichtangaben
copyParties(original, cancellation);
cancellation.setItems(original.getItems());
// Beträge negieren Storno bucht den Originalbetrag aus
cancellation.setNetAmount(negate(original.getNetAmount()));
cancellation.setVatRate(original.getVatRate());
cancellation.setVatAmount(negate(original.getVatAmount()));
cancellation.setTotalAmount(negate(original.getTotalAmount()));
cancellation.setDescription("Stornorechnung zu Rechnung " + original.getInvoiceNumber());
cancellation.setPdfData(applyEInvoiceIfApplicable(pdfData, cancellation, original));
cancellation.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason);
issuedEntry.setResultingInvoiceNumber(cancellationNumber);
cancellation.addAuditEntry(issuedEntry);
ensureInvoiceNumberUnique(cancellation);
CustomerInvoice savedCancellation = invoiceRepository.save(cancellation);
numberAuditService.markUsed(savedCancellation);
// Original markieren und verlinken
original.setStatus(InvoiceStatus.CANCELLED);
original.setCancelledAt(LocalDateTime.now());
original.setCancellationInvoiceId(savedCancellation.getId());
// Wenn die Originalrechnung bereits (teil-)bezahlt war, entsteht ein Erstattungsanspruch (R-26)
BigDecimal paid = original.getPaidAmount() != null ? original.getPaidAmount() : BigDecimal.ZERO;
original.setPaymentStatus(computePaymentStatus(original, paid));
InvoiceAuditEntry cancelEntry = audit(InvoiceAuditAction.CANCELLED, reason);
cancelEntry.setResultingInvoiceId(savedCancellation.getId());
cancelEntry.setResultingInvoiceNumber(savedCancellation.getInvoiceNumber());
original.addAuditEntry(cancelEntry);
invoiceRepository.save(original);
log.info("Rechnung {} storniert durch Beleg {}.", original.getInvoiceNumber(),
savedCancellation.getInvoiceNumber());
return savedCancellation;
}
/**
* Erzeugt einen Berichtigungsbeleg zu einer bereits ausgestellten Rechnung (R-12 bis R-16).
*
* Eine Berichtigung adressiert formale Fehler (Adresse, Leistungsdatum, Pflichtangabe).
* Sie ersetzt die Originalrechnung nicht, sondern verweist auf sie. Originalrechnung
* wechselt in den Status {@link InvoiceStatus#CORRECTED} und hält eine Referenz auf den
* Berichtigungsbeleg.
*
* @param originalId ID der zu berichtigenden Rechnung
* @param correctionNumber fortlaufende Belegnummer für den Berichtigungsbeleg
* @param correctionDate Belegdatum
* @param pdfData generiertes PDF des Berichtigungsbelegs
* @param correctedFields Beschreibung der ergänzten/korrigierten Angaben (R-14)
* @param reason Grund der Berichtigung (R-36)
*/
public CustomerInvoice correct(String originalId, String correctionNumber, LocalDate correctionDate,
byte[] pdfData, String correctedFields, String reason) {
CustomerInvoice original = requireInvoice(originalId);
if (original.getType() != InvoiceType.INVOICE) {
throw new InvoiceLifecycleException(
"Nur reguläre Rechnungen können berichtigt werden. Belegtyp: " + original.getType());
}
if (original.getStatus() == InvoiceStatus.DRAFT) {
throw new InvoiceLifecycleException(
"Ein Entwurf wird nicht berichtigt, sondern direkt bearbeitet.");
}
if (original.getStatus() == InvoiceStatus.CANCELLED) {
throw new InvoiceLifecycleException(
"Eine bereits stornierte Rechnung kann nicht berichtigt werden. Erstellen Sie eine neue Rechnung.");
}
if (correctionNumber == null || correctionNumber.isBlank()) {
throw new InvoiceLifecycleException("Berichtigungsbeleg benötigt eine fortlaufende Belegnummer.");
}
CustomerInvoice correction = new CustomerInvoice();
correction.setType(InvoiceType.CORRECTION);
correction.setStatus(InvoiceStatus.ISSUED);
correction.setInvoiceNumber(correctionNumber);
correction.setInvoiceDate(correctionDate != null ? correctionDate : LocalDate.now());
correction.setIssuedAt(LocalDateTime.now());
correction.setUserId(original.getUserId());
correction.setJobId(original.getJobId());
correction.setOriginalInvoiceId(original.getId());
correction.setOriginalInvoiceNumber(original.getInvoiceNumber());
correction.setOriginalInvoiceDate(original.getInvoiceDate());
copyParties(original, correction);
correction.setItems(original.getItems());
correction.setNetAmount(original.getNetAmount());
correction.setVatRate(original.getVatRate());
correction.setVatAmount(original.getVatAmount());
correction.setTotalAmount(original.getTotalAmount());
String descriptionPrefix = "Berichtigung zu Rechnung " + original.getInvoiceNumber();
correction.setDescription(
correctedFields == null || correctedFields.isBlank() ? descriptionPrefix
: descriptionPrefix + "" + correctedFields);
correction.setPdfData(applyEInvoiceIfApplicable(pdfData, correction, original));
correction.addAuditEntry(audit(InvoiceAuditAction.CREATED_DRAFT, reason));
InvoiceAuditEntry issuedEntry = audit(InvoiceAuditAction.ISSUED, reason);
issuedEntry.setResultingInvoiceNumber(correctionNumber);
correction.addAuditEntry(issuedEntry);
ensureInvoiceNumberUnique(correction);
CustomerInvoice savedCorrection = invoiceRepository.save(correction);
numberAuditService.markUsed(savedCorrection);
original.setStatus(InvoiceStatus.CORRECTED);
original.setCorrectionInvoiceId(savedCorrection.getId());
InvoiceAuditEntry correctEntry = audit(InvoiceAuditAction.CORRECTED, reason);
correctEntry.setResultingInvoiceId(savedCorrection.getId());
correctEntry.setResultingInvoiceNumber(savedCorrection.getInvoiceNumber());
original.addAuditEntry(correctEntry);
invoiceRepository.save(original);
log.info("Rechnung {} berichtigt durch Beleg {}.", original.getInvoiceNumber(),
savedCorrection.getInvoiceNumber());
return savedCorrection;
}
/**
* Speichert eine vollständig neue Ersatzrechnung nach einem Storno (R-20 bis R-22).
* Die neue Rechnung erhält eine eigene Rechnungsnummer und referenziert die stornierte
* Originalrechnung, sodass die Verkettung Original → Storno → Ersatzrechnung erhalten bleibt.
*/
public CustomerInvoice createReplacementInvoice(String cancelledOriginalId, CustomerInvoice replacement,
String reason) {
CustomerInvoice cancelledOriginal = requireInvoice(cancelledOriginalId);
if (cancelledOriginal.getStatus() != InvoiceStatus.CANCELLED) {
throw new InvoiceLifecycleException(
"Eine Ersatzrechnung kann nur zu einer stornierten Rechnung erstellt werden.");
}
if (replacement == null) {
throw new IllegalArgumentException("Ersatzrechnung darf nicht null sein.");
}
replacement.setType(InvoiceType.INVOICE);
replacement.setOriginalInvoiceId(cancelledOriginal.getId());
replacement.setOriginalInvoiceNumber(cancelledOriginal.getInvoiceNumber());
replacement.setOriginalInvoiceDate(cancelledOriginal.getInvoiceDate());
CustomerInvoice savedReplacement = createAndIssue(replacement,
reason != null ? reason : "Ersatzrechnung nach Storno");
cancelledOriginal.setReplacementInvoiceId(savedReplacement.getId());
InvoiceAuditEntry replaceEntry = audit(InvoiceAuditAction.REPLACED, reason);
replaceEntry.setResultingInvoiceId(savedReplacement.getId());
replaceEntry.setResultingInvoiceNumber(savedReplacement.getInvoiceNumber());
cancelledOriginal.addAuditEntry(replaceEntry);
invoiceRepository.save(cancelledOriginal);
return savedReplacement;
}
/**
* Liefert die Folgebelege (Storno, Berichtigung, Ersatzrechnung) zu einer Rechnung.
*/
public List<CustomerInvoice> findRelatedDocuments(String originalInvoiceId) {
if (originalInvoiceId == null) {
return List.of();
}
return invoiceRepository.findByOriginalInvoiceId(originalInvoiceId);
}
/**
* Erfasst eine Zahlung zur Rechnung (R-23 bis R-26).
*
* Eine Zahlung wird ausschließlich erfasst die Rechnungsdaten selbst werden nicht
* verändert (R-25). Der Zahlungsstatus ergibt sich aus dem Verhältnis Zahlung zu
* Bruttobetrag der Rechnung.
*
* @param invoiceId Rechnung, auf die gebucht wird
* @param amount erfasster Zahlbetrag (kann negativ sein für Korrekturen)
* @param paymentReference frei wählbarer Referenztext (z.B. Kontoauszug, Beleg)
* @param reason erläuternder Grund für das Audit-Log
*/
public CustomerInvoice registerPayment(String invoiceId, BigDecimal amount, String paymentReference,
String reason) {
if (amount == null) {
throw new IllegalArgumentException("Zahlbetrag erforderlich.");
}
CustomerInvoice invoice = requireInvoice(invoiceId);
if (invoice.getStatus() == InvoiceStatus.DRAFT) {
throw new InvoiceLifecycleException(
"Auf Entwürfen können keine Zahlungen erfasst werden. Bitte zuerst ausstellen.");
}
if (invoice.getType() == InvoiceType.CORRECTION) {
throw new InvoiceLifecycleException(
"Auf Berichtigungsbelegen werden keine Zahlungen erfasst buchen Sie auf der Originalrechnung.");
}
BigDecimal previous = invoice.getPaidAmount() != null ? invoice.getPaidAmount() : BigDecimal.ZERO;
BigDecimal newPaid = previous.add(amount);
invoice.setPaidAmount(newPaid);
invoice.setLastPaymentAt(LocalDateTime.now());
invoice.setPaymentStatus(computePaymentStatus(invoice, newPaid));
StringBuilder logReason = new StringBuilder();
logReason.append("Zahlung erfasst: ").append(amount.toPlainString());
if (paymentReference != null && !paymentReference.isBlank()) {
logReason.append(" (Referenz: ").append(paymentReference).append(")");
}
if (reason != null && !reason.isBlank()) {
logReason.append(" ").append(reason);
}
invoice.addAuditEntry(audit(InvoiceAuditAction.PAYMENT_RECORDED, logReason.toString()));
return invoiceRepository.save(invoice);
}
/**
* Liefert den noch offenen Betrag einer Rechnung. Negative Werte zeigen eine
* Überzahlung bzw. einen Erstattungsanspruch (R-26).
*/
public BigDecimal computeOutstandingAmount(CustomerInvoice invoice) {
BigDecimal total = invoice.getTotalAmount() != null ? invoice.getTotalAmount() : BigDecimal.ZERO;
BigDecimal paid = invoice.getPaidAmount() != null ? invoice.getPaidAmount() : BigDecimal.ZERO;
return total.subtract(paid);
}
private PaymentStatus computePaymentStatus(CustomerInvoice invoice, BigDecimal paid) {
BigDecimal total = invoice.getTotalAmount() != null ? invoice.getTotalAmount() : BigDecimal.ZERO;
if (invoice.getStatus() == InvoiceStatus.CANCELLED) {
return paid.signum() == 0 ? PaymentStatus.UNPAID : PaymentStatus.REFUND_DUE;
}
int cmp = paid.compareTo(total);
if (paid.signum() == 0) {
return PaymentStatus.UNPAID;
}
if (cmp == 0) {
return PaymentStatus.PAID;
}
if (cmp < 0) {
return PaymentStatus.PARTIALLY_PAID;
}
return PaymentStatus.OVERPAID;
}
/**
* Löscht einen Entwurf. Finalisierte Rechnungen dürfen nicht gelöscht werden (R-35).
*/
public void deleteDraft(String invoiceId, String reason) {
CustomerInvoice invoice = requireInvoice(invoiceId);
if (!invoice.getStatus().isMutable()) {
throw new InvoiceLifecycleException(
"Finalisierte Rechnungen können nicht gelöscht werden. "
+ "Bitte führen Sie stattdessen ein Storno oder eine Berichtigung durch.");
}
invoice.addAuditEntry(audit(InvoiceAuditAction.DELETED_DRAFT, reason));
log.info("Rechnungsentwurf {} wird gelöscht: {}", invoiceId, reason);
invoiceRepository.delete(invoice);
// Wenn dem Entwurf bereits eine Nummer reserviert wurde, dokumentiert das
// Verwerfen jetzt die Lücke im Nummernkreis — sonst bliebe die Reservierung
// als unerklärter „RESERVED"-Eintrag im Audit hängen.
if (invoice.getInvoiceNumber() != null && !invoice.getInvoiceNumber().isBlank()
&& invoice.getUserId() != null) {
try {
ObjectId userId = new ObjectId(invoice.getUserId());
String voidReason = (reason != null && !reason.isBlank())
? reason
: "Rechnungsentwurf gelöscht";
numberAuditService.markVoided(userId, invoice.getInvoiceNumber(), voidReason);
} catch (IllegalArgumentException | InvoiceLifecycleException ex) {
// Keine Reservierung vorhanden oder UserId nicht parsebar — Lücken-Detektor
// erfasst das später; das Löschen selbst soll nicht blockiert werden.
log.debug("VOIDED-Markierung beim Löschen des Entwurfs übersprungen: {}", ex.getMessage());
}
}
}
public Optional<CustomerInvoice> findById(String invoiceId) {
return invoiceRepository.findById(invoiceId);
}
private CustomerInvoice requireInvoice(String invoiceId) {
if (invoiceId == null || invoiceId.isBlank()) {
throw new IllegalArgumentException("Rechnungs-ID erforderlich.");
}
return invoiceRepository.findById(invoiceId)
.orElseThrow(() -> new IllegalStateException("Rechnung nicht gefunden: " + invoiceId));
}
private void ensureInvoiceNumberUnique(CustomerInvoice invoice) {
String number = invoice.getInvoiceNumber();
if (number == null || number.isBlank()) {
throw new InvoiceLifecycleException("Eine ausgestellte Rechnung benötigt eine Rechnungsnummer.");
}
invoiceRepository.findByInvoiceNumberAndStatusNot(number, InvoiceStatus.CANCELLED).ifPresent(existing -> {
if (!existing.getId().equals(invoice.getId())) {
throw new InvoiceLifecycleException(
"Rechnungsnummer " + number + " wird bereits von einer aktiven Rechnung verwendet.");
}
});
}
private InvoiceAuditEntry audit(InvoiceAuditAction action, String reason) {
String userId = null;
String displayName = "system";
try {
User user = securityService.getCurrentDatabaseUser();
userId = user.getId() != null ? user.getId().toHexString() : null;
String composed = (safe(user.getFirstname()) + " " + safe(user.getName())).trim();
displayName = composed.isBlank() ? safe(user.getEmail()) : composed;
} catch (Exception ignored) {
// Audit funktioniert auch außerhalb einer Vaadin-Session (z.B. Migration).
}
return new InvoiceAuditEntry(action, userId, displayName, reason);
}
private String safe(String value) {
return value != null ? value : "";
}
private void copyParties(CustomerInvoice source, CustomerInvoice target) {
target.setSenderName(source.getSenderName());
target.setSenderAddress(source.getSenderAddress());
target.setSenderPostcode(source.getSenderPostcode());
target.setSenderCity(source.getSenderCity());
target.setSenderCountry(source.getSenderCountry());
target.setSenderTaxNumber(source.getSenderTaxNumber());
target.setSenderVatId(source.getSenderVatId());
target.setSenderPhone(source.getSenderPhone());
target.setSenderEmail(source.getSenderEmail());
target.setSenderWebsite(source.getSenderWebsite());
target.setRecipientName(source.getRecipientName());
target.setRecipientCompany(source.getRecipientCompany());
target.setRecipientAddress(source.getRecipientAddress());
target.setRecipientPostcode(source.getRecipientPostcode());
target.setRecipientCity(source.getRecipientCity());
target.setRecipientCountry(source.getRecipientCountry());
target.setRecipientVatId(source.getRecipientVatId());
}
private BigDecimal negate(BigDecimal value) {
return value != null ? value.negate() : null;
}
/**
* Reichert ein PDF mit ZUGFeRD-XML an und signiert es, falls Mustangproject systemweit
* aktiviert ist und das Original bereits ein E-Rechnungsformat hatte. So bleibt das
* Format eines Storno- oder Berichtigungsbelegs konsistent zur Originalrechnung.
*
* Fehlt das Signatur-Zertifikat oder kann es nicht entschlüsselt werden, wird die
* {@link InvoiceLifecycleException} bewusst durchgereicht — der Anwender soll die
* Storno-/Berichtigungs-Aktion korrigieren können (Zertifikat hochladen,
* Master-Key prüfen). Andere Fehlerklassen (z.B. PDF-Strukturfehler bei der
* ZUGFeRD-Anreicherung) bleiben graceful: das Roh-PDF wird zurückgegeben.
*/
private byte[] applyEInvoiceIfApplicable(byte[] pdfData, CustomerInvoice followUp, CustomerInvoice original) {
if (pdfData == null || pdfData.length == 0 || eInvoiceService == null || original == null) {
return pdfData;
}
boolean originalHadZugferd = original.getEInvoiceFormat() != null
&& original.getEInvoiceFormat() != EInvoiceFormat.NONE;
boolean originalWasSigned = original.isSigned();
boolean wantsZugferd = originalHadZugferd && eInvoiceService.isEInvoiceEnabledGlobally();
if (!wantsZugferd && !originalWasSigned) {
return pdfData;
}
try {
return eInvoiceService.enhanceAndSign(pdfData, followUp, wantsZugferd, originalWasSigned);
} catch (InvoiceLifecycleException ex) {
// Signatur-/Zertifikatsproblem dem Anwender sichtbar machen
throw ex;
} catch (Exception ex) {
log.warn("E-Invoice-Anreicherung des Folgebelegs fehlgeschlagen: {}", ex.getMessage(), ex);
return pdfData;
}
}
}

View File

@@ -0,0 +1,166 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus;
import de.assecutor.votianlt.repository.InvoiceNumberReservationRepository;
import org.bson.types.ObjectId;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
/**
* Verwaltet das Audit der vergebenen Rechnungsnummern. Jede Vergabe aus dem
* Counter wird als RESERVED protokolliert; dieser Service vollzieht die
* Status-Übergänge nach (USED beim Festschreiben, VOIDED beim Verwerfen)
* und liefert Lücken-Reports für die Betriebsprüfung.
*
* Pflichtgrundlage: § 14 Abs. 4 Nr. 4 UStG verlangt eine fortlaufende
* Rechnungsnummer; lückenhafte Nummernkreise sind nur zulässig, wenn jede
* fehlende Nummer dokumentiert erklärt werden kann (GoBD).
*
* Fehler beim Audit-Schreiben werden bewusst nicht propagiert: Die
* fachliche Operation (Rechnung festschreiben) hat Vorrang. Verlorene
* USED-Markierungen sind über den Lücken-Report nachträglich rekonstruierbar.
*/
@Service
public class InvoiceNumberAuditService {
private static final Logger log = LoggerFactory.getLogger(InvoiceNumberAuditService.class);
private final InvoiceNumberReservationRepository repository;
public InvoiceNumberAuditService(InvoiceNumberReservationRepository repository) {
this.repository = repository;
}
/**
* Markiert die Reservierung der übergebenen Rechnung als USED.
* Wird vom Lifecycle nach erfolgreichem Festschreiben aufgerufen.
*/
public void markUsed(CustomerInvoice invoice) {
if (invoice == null || invoice.getInvoiceNumber() == null || invoice.getUserId() == null) {
return;
}
ObjectId userId = parseUserId(invoice.getUserId());
if (userId == null) {
return;
}
try {
Optional<InvoiceNumberReservation> existing = repository.findByUserIdAndNumber(userId,
invoice.getInvoiceNumber());
InvoiceNumberReservation reservation = existing.orElseGet(() -> bootstrapReservation(userId, invoice));
reservation.setStatus(InvoiceNumberReservationStatus.USED);
reservation.setInvoiceId(invoice.getId());
reservation.setUsedAt(Instant.now());
repository.save(reservation);
} catch (Exception ex) {
log.warn("USED-Markierung für Nummer {} (Rechnung {}) fehlgeschlagen: {}",
invoice.getInvoiceNumber(), invoice.getId(), ex.getMessage(), ex);
}
}
/**
* Markiert die zur übergebenen Nummer gehörende Reservierung als VOIDED.
* Pflichtfeld {@code reason} dokumentiert die Erklärung der Lücke.
*/
public void markVoided(ObjectId userId, String number, String reason) {
if (userId == null || number == null || number.isBlank()) {
throw new IllegalArgumentException("userId und number sind Pflichtparameter.");
}
if (reason == null || reason.isBlank()) {
throw new IllegalArgumentException("Grund (reason) ist Pflicht beim Verwerfen einer Reservierung.");
}
InvoiceNumberReservation reservation = repository.findByUserIdAndNumber(userId, number)
.orElseThrow(() -> new InvoiceLifecycleException(
"Keine Reservierung für Nummer " + number + " gefunden."));
if (reservation.getStatus() == InvoiceNumberReservationStatus.USED) {
throw new InvoiceLifecycleException(
"Nummer " + number + " ist bereits einer ausgestellten Rechnung zugeordnet "
+ "und kann nicht verworfen werden.");
}
reservation.setStatus(InvoiceNumberReservationStatus.VOIDED);
reservation.setVoidReason(reason);
reservation.setVoidedAt(Instant.now());
repository.save(reservation);
}
/**
* Liefert alle Reservierungen eines Nutzers in Sequenzreihenfolge.
* Basis für vollständige Audit-Reports.
*/
public List<InvoiceNumberReservation> findAll(ObjectId userId) {
return repository.findByUserIdOrderBySequenceAsc(userId);
}
/**
* Liefert nur die noch nicht verwendeten Reservierungen eines Nutzers
* (Status RESERVED oder VOIDED). Im Idealfall ist diese Liste leer oder
* enthält ausschließlich VOIDED-Einträge mit dokumentiertem Grund.
* Verbleibende RESERVED-Einträge nach abgeschlossenen Vorgängen sind
* unerklärte Lücken und sollten in der UI hervorgehoben werden.
*/
public List<InvoiceNumberReservation> findUnused(ObjectId userId) {
List<InvoiceNumberReservation> all = repository.findByUserIdOrderBySequenceAsc(userId);
return all.stream()
.filter(r -> r.getStatus() != InvoiceNumberReservationStatus.USED)
.toList();
}
/**
* Erzeugt einen Bootstrap-Eintrag für Rechnungen, die ohne vorausgegangene
* Reservierung festgeschrieben wurden — z.B. Bestandsdaten aus der Zeit
* vor Einführung des Reservierungs-Audits oder Storno-/Korrekturbelege,
* deren Nummer extern übergeben wurde. Status wird direkt auf USED gesetzt.
*/
private InvoiceNumberReservation bootstrapReservation(ObjectId userId, CustomerInvoice invoice) {
InvoiceNumberReservation reservation = new InvoiceNumberReservation();
reservation.setUserId(userId);
reservation.setNumber(invoice.getInvoiceNumber());
reservation.setSequence(extractSequence(invoice.getInvoiceNumber()));
reservation.setReservedAt(Instant.now());
reservation.setReservedBy("system (bootstrap)");
return reservation;
}
/**
* Best-effort-Extraktion der numerischen Sequenz aus einer formatierten
* Rechnungsnummer (z.B. „RE-2026-000123" → 123). Liefert -1, wenn keine
* trailing-Ziffern vorhanden sind — dann ist die Nummer für die
* Lücken-Sortierung ungeeignet, aber der Audit-Eintrag bleibt erhalten.
*/
private long extractSequence(String number) {
if (number == null) {
return -1L;
}
int end = number.length();
int start = end;
while (start > 0 && Character.isDigit(number.charAt(start - 1))) {
start--;
}
if (start == end) {
return -1L;
}
try {
return Long.parseLong(number.substring(start, end));
} catch (NumberFormatException ex) {
return -1L;
}
}
private ObjectId parseUserId(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return new ObjectId(value);
} catch (IllegalArgumentException ex) {
log.warn("UserId '{}' ist keine gültige ObjectId — Audit übersprungen.", value);
return null;
}
}
}

View File

@@ -0,0 +1,164 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.security.InvoiceRoles;
import de.assecutor.votianlt.security.SecurityService;
import org.springframework.stereotype.Service;
import java.util.Set;
/**
* Berechtigungs-Checks für Rechnungsaktionen gemäß R-40 bis R-42.
*
* Backwards-compat: ein Nutzer mit der bestehenden {@code USER}- oder {@code ADMIN}-Rolle,
* der keine der spezialisierten Invoice-Rollen besitzt, hat weiterhin volle Berechtigung
* — andernfalls würden alle bestehenden Installationen sofort handlungsunfähig.
*
* Sobald ein Nutzer mindestens eine {@code INVOICE_*}-Rolle hat, gelten die feingranularen
* Regeln und nur die explizit zugewiesenen Aktionen sind erlaubt.
*/
@Service
public class InvoicePermissionService {
private static final String ROLE_ADMIN = "ADMIN";
private final SecurityService securityService;
public InvoicePermissionService(SecurityService securityService) {
this.securityService = securityService;
}
public boolean canCreateOrIssue(User user) {
return hasAnyInvoiceRole(user, InvoiceRoles.CREATOR) || isUnscoped(user);
}
public boolean canMarkAsSent(User user) {
return hasAnyInvoiceRole(user, InvoiceRoles.CREATOR, InvoiceRoles.REVIEWER, InvoiceRoles.APPROVER)
|| isUnscoped(user);
}
public boolean canCancel(User user) {
return hasAnyInvoiceRole(user, InvoiceRoles.APPROVER) || isUnscoped(user);
}
public boolean canCorrect(User user) {
return hasAnyInvoiceRole(user, InvoiceRoles.APPROVER, InvoiceRoles.REVIEWER) || isUnscoped(user);
}
public boolean canRecordPayment(User user) {
return hasAnyInvoiceRole(user, InvoiceRoles.ACCOUNTANT, InvoiceRoles.APPROVER) || isUnscoped(user);
}
public boolean canApproveRequests(User user) {
return hasAnyInvoiceRole(user, InvoiceRoles.APPROVER) || isAdmin(user);
}
/**
* Liefert {@code true}, wenn die Aktion dieses Nutzers vor Ausführung eine Freigabe benötigt
* (R-42). Approver können ihre eigenen Aktionen direkt ausführen.
*/
public boolean requiresApproval(User user) {
if (user == null) {
return false;
}
if (canApproveRequests(user)) {
return false;
}
return user.isRequireApprovalForCriticalInvoiceActions();
}
public void requireCreate(User user) {
if (!canCreateOrIssue(user)) {
throw new InvoiceLifecycleException(
"Sie haben keine Berechtigung, Rechnungen zu erstellen oder auszustellen.");
}
}
public void requireSend(User user) {
if (!canMarkAsSent(user)) {
throw new InvoiceLifecycleException(
"Sie haben keine Berechtigung, Rechnungen als versendet zu markieren.");
}
}
public void requireCancel(User user) {
if (!canCancel(user)) {
throw new InvoiceLifecycleException(
"Sie haben keine Berechtigung, Rechnungen zu stornieren.");
}
}
public void requireCorrect(User user) {
if (!canCorrect(user)) {
throw new InvoiceLifecycleException(
"Sie haben keine Berechtigung, Berichtigungsbelege zu erstellen.");
}
}
public void requirePayment(User user) {
if (!canRecordPayment(user)) {
throw new InvoiceLifecycleException("Sie haben keine Berechtigung, Zahlungen zu erfassen.");
}
}
public void requireApprove(User user) {
if (!canApproveRequests(user)) {
throw new InvoiceLifecycleException("Sie haben keine Freigabe-Berechtigung.");
}
}
/**
* Convenience: prüft, ob der Nutzer Eigentümer einer Rechnung ist (oder Admin).
* Wird genutzt, um Cross-Tenant-Zugriffe zu verhindern.
*/
public boolean isOwnerOrAdmin(User user, CustomerInvoice invoice) {
if (user == null) {
return false;
}
if (isAdmin(user)) {
return true;
}
if (invoice == null || invoice.getUserId() == null || user.getId() == null) {
return false;
}
return invoice.getUserId().equals(user.getId().toHexString());
}
private boolean hasAnyInvoiceRole(User user, String... roles) {
if (user == null || user.getRoles() == null) {
return false;
}
Set<String> userRoles = user.getRoles();
if (userRoles.contains(ROLE_ADMIN)) {
return true;
}
for (String role : roles) {
if (userRoles.contains(role)) {
return true;
}
}
return false;
}
/**
* {@code true}, wenn dem Nutzer keine spezialisierte Invoice-Rolle zugewiesen ist —
* dann gilt das alte Pauschalrecht der USER-Rolle (Backwards-Compat).
*/
private boolean isUnscoped(User user) {
if (user == null || user.getRoles() == null) {
return false;
}
Set<String> roles = user.getRoles();
return roles.stream().noneMatch(r -> r.startsWith("INVOICE_"));
}
private boolean isAdmin(User user) {
return user != null && user.getRoles() != null && user.getRoles().contains(ROLE_ADMIN);
}
/** Bequemer Lookup für die UI. */
public User currentUser() {
return securityService.getCurrentDatabaseUser();
}
}

View File

@@ -0,0 +1,237 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.config.EInvoiceProperties;
import de.assecutor.votianlt.model.invoices.UserSigningCredentials;
import de.assecutor.votianlt.repository.UserSigningCredentialsRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.Optional;
/**
* Verwaltet pro Nutzer hinterlegte PKCS#12-Signatur-Credentials.
*
* Beim Speichern wird der Keystore validiert (Alias vorhanden, privater Schlüssel
* extrahierbar, Zertifikat noch gültig) und anschließend mitsamt Passwort über
* AES-256-GCM unter dem konfigurierten Master-Key verschlüsselt.
*
* Beim Laden wird der Keystore zur Laufzeit entschlüsselt und in eine
* {@link LoadedCredentials}-Instanz verpackt; die Klar-Werte verlassen nie diese
* Service-Schicht.
*/
@Service
public class SigningCredentialsService {
private static final Logger log = LoggerFactory.getLogger(SigningCredentialsService.class);
private final UserSigningCredentialsRepository repository;
private final EInvoiceProperties properties;
public SigningCredentialsService(UserSigningCredentialsRepository repository, EInvoiceProperties properties) {
this.repository = repository;
this.properties = properties;
}
public Optional<UserSigningCredentials> findCredentials(String userId) {
if (userId == null || userId.isBlank()) {
return Optional.empty();
}
return repository.findByUserId(userId);
}
/**
* Liefert die entschlüsselten Credentials, sofern hinterlegt und der Master-Key passt.
* Liefert {@link Optional#empty()}, wenn keine Credentials hinterlegt sind oder die
* Entschlüsselung fehlschlägt — dann fällt der Caller auf den System-Keystore zurück.
*/
public Optional<LoadedCredentials> loadActive(String userId) {
return findCredentials(userId).filter(UserSigningCredentials::isEnabled).flatMap(this::decrypt);
}
@Transactional
public UserSigningCredentials store(String userId, byte[] p12Bytes, String password, String alias) {
if (userId == null || userId.isBlank()) {
throw new IllegalArgumentException("userId erforderlich.");
}
if (p12Bytes == null || p12Bytes.length == 0) {
throw new IllegalArgumentException("Keystore-Inhalt erforderlich.");
}
if (password == null) {
throw new IllegalArgumentException("Passwort erforderlich.");
}
if (alias == null || alias.isBlank()) {
throw new IllegalArgumentException("Schlüssel-Alias erforderlich.");
}
ensureMasterKey();
// Validieren — wirft bei falschen Daten eine aussagekräftige Exception.
ValidationResult validation = validate(p12Bytes, password, alias);
AesGcmCipher cipher = newCipher();
byte[] encryptedKeystore = cipher.encrypt(p12Bytes);
byte[] encryptedPassword = cipher.encrypt(password.getBytes(StandardCharsets.UTF_8));
UserSigningCredentials credentials = repository.findByUserId(userId).orElseGet(UserSigningCredentials::new);
credentials.setUserId(userId);
credentials.setEncryptedKeystore(encryptedKeystore);
credentials.setEncryptedPassword(java.util.Base64.getEncoder().encodeToString(encryptedPassword));
credentials.setKeyAlias(alias);
credentials.setSubjectDn(validation.subjectDn);
credentials.setIssuerDn(validation.issuerDn);
credentials.setSerialNumber(validation.serialNumber);
credentials.setValidFrom(validation.validFrom);
credentials.setValidUntil(validation.validUntil);
credentials.setEnabled(true);
credentials.setUpdatedAt(LocalDateTime.now());
if (credentials.getCreatedAt() == null) {
credentials.setCreatedAt(LocalDateTime.now());
}
return repository.save(credentials);
}
@Transactional
public void deleteForUser(String userId) {
if (userId == null || userId.isBlank()) {
return;
}
repository.deleteByUserId(userId);
}
@Transactional
public void setEnabled(String userId, boolean enabled) {
repository.findByUserId(userId).ifPresent(credentials -> {
credentials.setEnabled(enabled);
credentials.setUpdatedAt(LocalDateTime.now());
repository.save(credentials);
});
}
private Optional<LoadedCredentials> decrypt(UserSigningCredentials credentials) {
if (!properties.getSigning().hasMasterKey()) {
log.warn("Master-Key fehlt nutzerseitige Credentials für {} können nicht entschlüsselt werden.",
credentials.getUserId());
return Optional.empty();
}
try {
AesGcmCipher cipher = newCipher();
byte[] keystoreBytes = cipher.decrypt(credentials.getEncryptedKeystore());
byte[] passwordBytes = cipher
.decrypt(java.util.Base64.getDecoder().decode(credentials.getEncryptedPassword()));
char[] password = new String(passwordBytes, StandardCharsets.UTF_8).toCharArray();
KeyStore keystore = KeyStore.getInstance("PKCS12");
try (ByteArrayInputStream in = new ByteArrayInputStream(keystoreBytes)) {
keystore.load(in, password);
}
return Optional.of(new LoadedCredentials(keystore, password, credentials.getKeyAlias(), credentials));
} catch (Exception ex) {
log.warn("Nutzer-Keystore für {} konnte nicht entschlüsselt werden: {}", credentials.getUserId(),
ex.getMessage());
return Optional.empty();
}
}
private ValidationResult validate(byte[] p12Bytes, String password, String alias) {
try {
KeyStore keystore = KeyStore.getInstance("PKCS12");
try (ByteArrayInputStream in = new ByteArrayInputStream(p12Bytes)) {
keystore.load(in, password.toCharArray());
}
if (!keystore.containsAlias(alias)) {
throw new IllegalArgumentException("Alias '" + alias + "' nicht im Keystore enthalten.");
}
PrivateKey privateKey = (PrivateKey) keystore.getKey(alias, password.toCharArray());
if (privateKey == null) {
throw new IllegalArgumentException(
"Im Alias '" + alias + "' wurde kein privater Schlüssel gefunden.");
}
Certificate[] chain = keystore.getCertificateChain(alias);
if (chain == null || chain.length == 0 || !(chain[0] instanceof X509Certificate)) {
throw new IllegalArgumentException("Kein verwendbares X.509-Zertifikat im Alias '" + alias + "'.");
}
X509Certificate cert = (X509Certificate) chain[0];
ValidationResult result = new ValidationResult();
result.subjectDn = cert.getSubjectX500Principal().getName();
result.issuerDn = cert.getIssuerX500Principal().getName();
result.serialNumber = cert.getSerialNumber().toString(16);
result.validFrom = toLocalDateTime(cert.getNotBefore());
result.validUntil = toLocalDateTime(cert.getNotAfter());
return result;
} catch (IllegalArgumentException ex) {
throw ex;
} catch (java.io.IOException ex) {
throw new IllegalArgumentException("Keystore konnte nicht gelesen werden falsches Passwort?", ex);
} catch (Exception ex) {
throw new IllegalArgumentException("Keystore-Validierung fehlgeschlagen: " + ex.getMessage(), ex);
}
}
private AesGcmCipher newCipher() {
return new AesGcmCipher(properties.getSigning().getMasterKey());
}
private void ensureMasterKey() {
if (!properties.getSigning().hasMasterKey()) {
throw new IllegalStateException(
"Master-Key (votianlt.einvoice.signing.master-key) ist nicht konfiguriert. "
+ "Setzen Sie einen mindestens 16 Zeichen langen Master-Key, bevor Sie Nutzer-Keystores hinterlegen.");
}
}
private LocalDateTime toLocalDateTime(Date date) {
return date == null ? null : date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
}
private static class ValidationResult {
String subjectDn;
String issuerDn;
String serialNumber;
LocalDateTime validFrom;
LocalDateTime validUntil;
}
/**
* Bündel aus einem im Speicher entschlüsselten Keystore plus zugehörigem Klartext-Passwort.
*/
public static final class LoadedCredentials {
private final KeyStore keystore;
private final char[] password;
private final String alias;
private final UserSigningCredentials metadata;
LoadedCredentials(KeyStore keystore, char[] password, String alias, UserSigningCredentials metadata) {
this.keystore = keystore;
this.password = password;
this.alias = alias;
this.metadata = metadata;
}
public KeyStore getKeystore() {
return keystore;
}
public char[] getPassword() {
return password;
}
public String getAlias() {
return alias;
}
public UserSigningCredentials getMetadata() {
return metadata;
}
}
}

View File

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

View File

@@ -9,6 +9,17 @@ 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
@@ -47,6 +58,36 @@ profile.settings.digitalprocess.info=Aufträge werden digital über die App abge
profile.settings.locateappuser=App-Nutzer orten profile.settings.locateappuser=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
@@ -706,6 +747,96 @@ invoices.column.amount=Betrag
invoices.column.description=Beschreibung invoices.column.description=Beschreibung
invoices.empty=Es wurden noch keine Rechnungen erstellt. invoices.empty=Es wurden noch keine Rechnungen erstellt.
invoices.notification.pdf.missing=Für diese Rechnung ist kein PDF gespeichert. invoices.notification.pdf.missing=Für diese Rechnung ist kein PDF gespeichert.
invoices.notification.pdf.error=Die PDF-Anzeige ist fehlgeschlagen: {0}
invoices.column.status=Status
invoices.column.type=Typ
invoices.column.actions=Aktionen
invoices.disclaimer=Hinweis: Die rechtliche Aufbewahrungspflicht liegt beim Aussteller. Eine bereits ausgestellte Rechnung wird nicht überschrieben — Korrekturen erfolgen über Berichtigung oder Stornorechnung mit eindeutigem Bezug.
invoices.status.draft=Entwurf
invoices.status.issued=Ausgestellt
invoices.status.sent=Versendet
invoices.status.cancelled=Storniert
invoices.status.corrected=Berichtigt
invoices.type.invoice=Rechnung
invoices.type.cancellation=Stornorechnung
invoices.type.correction=Berichtigung
invoices.action.view=PDF anzeigen
invoices.action.history=Historie
invoices.action.marksent=Versendet markieren
invoices.action.correct=Berichtigen
invoices.action.cancel=Stornieren
invoices.notification.sent=Rechnung als versendet markiert.
invoices.notification.cancelled=Stornobeleg {0} erstellt.
invoices.notification.corrected=Berichtigungsbeleg {0} erstellt.
invoices.notification.error=Aktion fehlgeschlagen: {0}
invoices.cancel.title=Rechnung {0} stornieren
invoices.cancel.hint=Die Originalrechnung bleibt unverändert sichtbar. Es wird ein eigenständiger Stornobeleg mit eigener Belegnummer erstellt, der die Originalrechnung eindeutig referenziert.
invoices.cancel.reason=Grund der Stornierung
invoices.cancel.reason.required=Bitte einen Grund angeben.
invoices.cancel.confirm=Stornobeleg erstellen
invoices.correct.title=Rechnung {0} berichtigen
invoices.correct.hint=Eine Berichtigung dient ausschließlich der Korrektur formaler Fehler (z.B. Adresse, Leistungsdatum). Die Originalrechnung bleibt sichtbar; der Berichtigungsbeleg verweist eindeutig auf sie.
invoices.correct.fields=Berichtigte Angaben
invoices.correct.fields.helper=Beschreiben Sie, welche Angaben ergänzt oder ersetzt werden.
invoices.correct.fields.required=Bitte die berichtigten Angaben beschreiben.
invoices.correct.reason=Grund der Berichtigung
invoices.correct.confirm=Berichtigung erstellen
invoices.history.title=Historie zu Rechnung {0}
invoices.history.log=Änderungsprotokoll
invoices.history.empty=Keine Einträge vorhanden.
invoices.history.original=Original­rechnung
invoices.history.cancellation=Stornobeleg
invoices.history.correction=Berichtigungsbeleg
invoices.history.replacement=Ersatzrechnung
invoices.audit.action.created_draft=Entwurf erstellt
invoices.audit.action.updated_draft=Entwurf geändert
invoices.audit.action.issued=Ausgestellt
invoices.audit.action.sent=Versendet
invoices.audit.action.cancelled=Storniert
invoices.audit.action.corrected=Berichtigt
invoices.audit.action.replaced=Ersetzt durch neue Rechnung
invoices.audit.action.deleted_draft=Entwurf gelöscht
invoices.audit.action.payment_recorded=Zahlung erfasst
invoices.audit.resulting=Erzeugter Folgebeleg: {0}
invoices.column.payment=Zahlung
invoices.column.outstanding=Offen
invoices.payment.unpaid=Offen
invoices.payment.partially_paid=Teilzahlung
invoices.payment.paid=Bezahlt
invoices.payment.overpaid=Überzahlung
invoices.payment.refund_due=Erstattung offen
invoices.action.payment=Zahlung erfassen
invoices.action.export=Exportieren
invoices.payment.title=Zahlung zu Rechnung {0}
invoices.payment.hint=Offener Restbetrag: {0}. Negative Beträge können zur Korrektur erfasst werden.
invoices.payment.amount=Zahlbetrag
invoices.payment.amount.required=Bitte einen Betrag (ungleich 0) angeben.
invoices.payment.reference=Zahlungsreferenz (z.B. Kontoauszug, Buchungs-Nr.)
invoices.payment.reason=Anmerkung
invoices.payment.confirm=Zahlung erfassen
invoices.notification.payment=Zahlung erfasst.
invoices.einvoice.tooltip=PDF/A-3 mit eingebettetem ZUGFeRD/XRechnung-XML
invoices.einvoice.signed=Signiert
invoices.action.cancel.request=Storno beantragen
invoices.action.correct.request=Berichtigung beantragen
invoices.notification.requested=Freigabe-Anfrage erstellt. Bitte auf Freigabe warten.
approvals.title=Freigaben
approvals.no.permission=Sie haben keine Berechtigung, Freigaben zu bearbeiten.
approvals.column.requested=Beantragt am
approvals.column.requester=Beantragt von
approvals.column.invoice=Rechnung
approvals.column.action=Aktion
approvals.column.reason=Grund
approvals.action.approve=Freigeben
approvals.action.reject=Ablehnen
approvals.confirm.approve.title=Anfrage zu Rechnung {0} freigeben
approvals.confirm.reject.title=Anfrage zu Rechnung {0} ablehnen
approvals.review.fields=Berichtigte Angaben
approvals.review.reason=Grund
approvals.review.comment=Kommentar (optional)
approvals.notification.approved=Anfrage freigegeben — Folgebeleg wurde erstellt.
approvals.notification.rejected=Anfrage abgelehnt.
page.title.approvals=Freigaben
# My Invoices # My Invoices
myinvoices.title=Rechnungen myinvoices.title=Rechnungen

View File

@@ -9,6 +9,17 @@ 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
@@ -47,6 +58,36 @@ 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
@@ -706,6 +747,96 @@ invoices.column.amount=Amount
invoices.column.description=Description invoices.column.description=Description
invoices.empty=No invoices have been created yet. invoices.empty=No invoices have been created yet.
invoices.notification.pdf.missing=No PDF is stored for this invoice. invoices.notification.pdf.missing=No PDF is stored for this invoice.
invoices.notification.pdf.error=Failed to display PDF: {0}
invoices.column.status=Status
invoices.column.type=Type
invoices.column.actions=Actions
invoices.disclaimer=Note: Legal retention obligations remain with the issuer. An already issued invoice is never overwritten — corrections are made through a correction document or cancellation invoice that explicitly references the original.
invoices.status.draft=Draft
invoices.status.issued=Issued
invoices.status.sent=Sent
invoices.status.cancelled=Cancelled
invoices.status.corrected=Corrected
invoices.type.invoice=Invoice
invoices.type.cancellation=Cancellation invoice
invoices.type.correction=Correction document
invoices.action.view=View PDF
invoices.action.history=History
invoices.action.marksent=Mark as sent
invoices.action.correct=Correct
invoices.action.cancel=Cancel
invoices.notification.sent=Invoice marked as sent.
invoices.notification.cancelled=Cancellation document {0} created.
invoices.notification.corrected=Correction document {0} created.
invoices.notification.error=Action failed: {0}
invoices.cancel.title=Cancel invoice {0}
invoices.cancel.hint=The original invoice remains visible. A separate cancellation document with its own number will be created, explicitly referencing the original invoice.
invoices.cancel.reason=Reason for cancellation
invoices.cancel.reason.required=Please provide a reason.
invoices.cancel.confirm=Create cancellation document
invoices.correct.title=Correct invoice {0}
invoices.correct.hint=A correction document is intended for formal errors only (e.g. address, delivery date). The original invoice remains visible and the correction document refers to it explicitly.
invoices.correct.fields=Corrected information
invoices.correct.fields.helper=Describe which fields are added or replaced.
invoices.correct.fields.required=Please describe the corrected information.
invoices.correct.reason=Reason for correction
invoices.correct.confirm=Create correction document
invoices.history.title=History for invoice {0}
invoices.history.log=Audit log
invoices.history.empty=No entries available.
invoices.history.original=Original invoice
invoices.history.cancellation=Cancellation document
invoices.history.correction=Correction document
invoices.history.replacement=Replacement invoice
invoices.audit.action.created_draft=Draft created
invoices.audit.action.updated_draft=Draft updated
invoices.audit.action.issued=Issued
invoices.audit.action.sent=Sent
invoices.audit.action.cancelled=Cancelled
invoices.audit.action.corrected=Corrected
invoices.audit.action.replaced=Replaced by new invoice
invoices.audit.action.deleted_draft=Draft deleted
invoices.audit.action.payment_recorded=Payment recorded
invoices.audit.resulting=Resulting document: {0}
invoices.column.payment=Payment
invoices.column.outstanding=Outstanding
invoices.payment.unpaid=Open
invoices.payment.partially_paid=Partially paid
invoices.payment.paid=Paid
invoices.payment.overpaid=Overpaid
invoices.payment.refund_due=Refund due
invoices.action.payment=Record payment
invoices.action.export=Export
invoices.payment.title=Payment for invoice {0}
invoices.payment.hint=Outstanding balance: {0}. Negative amounts can be recorded for corrections.
invoices.payment.amount=Amount
invoices.payment.amount.required=Please enter a non-zero amount.
invoices.payment.reference=Payment reference (e.g. statement, booking ID)
invoices.payment.reason=Note
invoices.payment.confirm=Record payment
invoices.notification.payment=Payment recorded.
invoices.einvoice.tooltip=PDF/A-3 with embedded ZUGFeRD/XRechnung XML
invoices.einvoice.signed=Signed
invoices.action.cancel.request=Request cancellation
invoices.action.correct.request=Request correction
invoices.notification.requested=Approval request created. Please wait for approval.
approvals.title=Approvals
approvals.no.permission=You do not have permission to handle approvals.
approvals.column.requested=Requested at
approvals.column.requester=Requested by
approvals.column.invoice=Invoice
approvals.column.action=Action
approvals.column.reason=Reason
approvals.action.approve=Approve
approvals.action.reject=Reject
approvals.confirm.approve.title=Approve request for invoice {0}
approvals.confirm.reject.title=Reject request for invoice {0}
approvals.review.fields=Corrected information
approvals.review.reason=Reason
approvals.review.comment=Comment (optional)
approvals.notification.approved=Request approved — follow-up document created.
approvals.notification.rejected=Request rejected.
page.title.approvals=Approvals
# My Invoices # My Invoices
myinvoices.title=Invoices myinvoices.title=Invoices

View File

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

View File

@@ -0,0 +1,271 @@
package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import de.assecutor.votianlt.config.EInvoiceProperties;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
import de.assecutor.votianlt.model.invoices.UserSigningCredentials;
import de.assecutor.votianlt.repository.UserSigningCredentialsRepository;
import eu.europa.esig.dss.diagnostic.DiagnosticData;
import eu.europa.esig.dss.diagnostic.SignatureWrapper;
import eu.europa.esig.dss.enumerations.SignatureLevel;
import eu.europa.esig.dss.model.DSSDocument;
import eu.europa.esig.dss.model.InMemoryDocument;
import eu.europa.esig.dss.model.x509.CertificateToken;
import eu.europa.esig.dss.spi.validation.CommonCertificateVerifier;
import eu.europa.esig.dss.spi.x509.CommonTrustedCertificateSource;
import eu.europa.esig.dss.validation.SignedDocumentValidator;
import eu.europa.esig.dss.validation.reports.Reports;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.Security;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
/**
* Validiert die vom Service erzeugten Signaturen gegen die EU DSS
* (Digital Signature Service) Bibliothek. Während {@link EInvoiceServiceTest}
* nur die strukturelle Korrektheit des Signature-Dictionarys prüft, parst
* DSS hier die Signatur kryptographisch nach und liefert ein Diagnostic-Data-
* Modell.
*
* <p>Geprüft wird pro Signaturpfad (System- und Nutzer-Keystore):
* <ul>
* <li>genau eine Signatur ist im Dokument enthalten,</li>
* <li>die Signatur ist kryptographisch intakt (Hash &amp; Signaturwert),</li>
* <li>das Format ist PAdES-Baseline (mehr ist mit den aktuellen
* Service-Settings nicht zu erwarten — kein TSA, kein DSS-Dictionary),</li>
* <li>der Signer-DN entspricht dem im Test erzeugten Zertifikat.</li>
* </ul>
*
* <p>Die Self-Signed-Zertifikate werden als Trust-Anchor in den
* {@link CommonCertificateVerifier} eingehängt — sonst würde DSS die Kette
* korrekt als nicht vertrauenswürdig melden, was über die rein technische
* Signaturprüfung hinausgeht und für unseren Test irrelevant ist.
*/
@ExtendWith(MockitoExtension.class)
class EInvoiceServiceDssValidationTest {
private static final String USER_ID = "user-1";
private static final String SYSTEM_ALIAS = "system-signer";
private static final String USER_ALIAS = "user-signer";
private static final String SYSTEM_PASSWORD = "system-pass";
private static final String USER_PASSWORD = "user-pass";
private static final String MASTER_KEY = "0123456789abcdef0123456789abcdef";
static {
if (Security.getProvider("BC") == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
@Mock
private UserSigningCredentialsRepository repository;
@TempDir
Path tempDir;
private EInvoiceProperties properties;
private SigningCredentialsService signingCredentialsService;
private EInvoiceService eInvoiceService;
@BeforeEach
void setUp() {
properties = new EInvoiceProperties();
properties.setEnabled(true);
properties.setProfile("EN16931");
properties.getSigning().setEnabled(false);
properties.getSigning().setMasterKey(MASTER_KEY);
signingCredentialsService = new SigningCredentialsService(repository, properties);
eInvoiceService = new EInvoiceService(properties, signingCredentialsService);
}
@Test
void systemKeystoreSignaturePassesDssValidation() throws Exception {
KeystoreFixture fixture = generateKeystore(SYSTEM_ALIAS, SYSTEM_PASSWORD);
Path keystoreFile = tempDir.resolve("system.p12");
Files.write(keystoreFile, fixture.keystoreBytes());
configureSystemKeystore(keystoreFile, SYSTEM_PASSWORD, SYSTEM_ALIAS);
when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty());
CustomerInvoice invoice = sampleInvoice();
byte[] signedPdf = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true);
assertDssAcceptsSignature(signedPdf, fixture.certificate(), "Votianlt Test " + SYSTEM_ALIAS);
}
@Test
void userKeystoreSignaturePassesDssValidation() throws Exception {
KeystoreFixture fixture = generateKeystore(USER_ALIAS, USER_PASSWORD);
when(repository.save(any(UserSigningCredentials.class))).thenAnswer(inv -> inv.getArgument(0));
UserSigningCredentials stored = signingCredentialsService.store(
USER_ID, fixture.keystoreBytes(), USER_PASSWORD, USER_ALIAS);
when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(stored));
CustomerInvoice invoice = sampleInvoice();
byte[] signedPdf = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true);
assertDssAcceptsSignature(signedPdf, fixture.certificate(), "Votianlt Test " + USER_ALIAS);
}
private void assertDssAcceptsSignature(byte[] signedPdf, X509Certificate trustAnchor,
String expectedSubjectFragment) {
DSSDocument document = new InMemoryDocument(signedPdf);
SignedDocumentValidator validator = SignedDocumentValidator.fromDocument(document);
CommonTrustedCertificateSource trustedSource = new CommonTrustedCertificateSource();
trustedSource.addCertificate(new CertificateToken(trustAnchor));
CommonCertificateVerifier verifier = new CommonCertificateVerifier();
verifier.setTrustedCertSources(trustedSource);
verifier.setAIASource(null);
verifier.setCrlSource(null);
verifier.setOcspSource(null);
validator.setCertificateVerifier(verifier);
Reports reports = validator.validateDocument();
DiagnosticData diagnosticData = reports.getDiagnosticData();
List<String> signatureIds = diagnosticData.getSignatureIdList();
assertThat(signatureIds)
.as("DSS muss genau eine PAdES-Signatur im PDF erkennen")
.hasSize(1);
String signatureId = signatureIds.get(0);
SignatureWrapper signature = diagnosticData.getSignatureById(signatureId);
assertThat(signature.isSignatureIntact())
.as("Signatur-Hash über den signierten Bytes muss aufgehen")
.isTrue();
assertThat(signature.isSignatureValid())
.as("Signaturwert muss mit dem Public Key des Signer-Zertifikats verifizierbar sein")
.isTrue();
// Aktueller Stand: der Service erzeugt eine generische PKCS#7-Signatur (subFilter
// adbe.pkcs7.detached) ohne ESS-signing-certificate-v2-Attribut. DSS klassifiziert
// das deshalb als PKCS7-B, nicht als PAdES-BASELINE-B. Hochstufung auf
// PAdES-BASELINE-B verlangt signierte Attribute (ESS Signing Certificate v2,
// signing-time im subFilter etrsi.RFC3161 oder ETSI.CAdES.detached, etc.) und
// wäre eine inhaltliche Erweiterung des Services.
SignatureLevel level = diagnosticData.getSignatureFormat(signatureId);
assertThat(level)
.as("Service erzeugt aktuell PKCS#7-B; ein Upgrade auf PAdES-BASELINE-B "
+ "wäre über zusätzliche CMS-signed-attributes möglich")
.isEqualTo(SignatureLevel.PKCS7_B);
String signerDn = signature.getSigningCertificate().getCertificateDN();
assertThat(signerDn)
.as("Signer-DN muss zum Test-Zertifikat passen")
.contains(expectedSubjectFragment);
}
private void configureSystemKeystore(Path keystoreFile, String password, String alias) {
EInvoiceProperties.Signing signing = properties.getSigning();
signing.setEnabled(true);
signing.setKeystorePath(keystoreFile.toString());
signing.setKeystorePassword(password);
signing.setKeyAlias(alias);
}
private byte[] buildBlankPdf() throws Exception {
try (PDDocument document = new PDDocument(); ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
document.addPage(new PDPage());
document.save(baos);
return baos.toByteArray();
}
}
private KeystoreFixture generateKeystore(String alias, String password) {
try {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
KeyPair keyPair = kpg.generateKeyPair();
X500Name subject = new X500Name("CN=Votianlt Test " + alias + ",O=Votianlt,C=DE");
BigInteger serial = BigInteger.valueOf(System.nanoTime());
Date notBefore = Date.from(Instant.now().minus(1, ChronoUnit.DAYS));
Date notAfter = Date.from(Instant.now().plus(365, ChronoUnit.DAYS));
JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(subject, serial, notBefore,
notAfter, subject, keyPair.getPublic());
ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").setProvider("BC")
.build(keyPair.getPrivate());
X509Certificate certificate = new JcaX509CertificateConverter().setProvider("BC")
.getCertificate(certBuilder.build(signer));
KeyStore keystore = KeyStore.getInstance("PKCS12");
keystore.load(null, null);
keystore.setKeyEntry(alias, keyPair.getPrivate(), password.toCharArray(),
new Certificate[] { certificate });
ByteArrayOutputStream baos = new ByteArrayOutputStream();
keystore.store(baos, password.toCharArray());
return new KeystoreFixture(baos.toByteArray(), certificate);
} catch (Exception ex) {
throw new IllegalStateException("Test-Keystore konnte nicht erzeugt werden: " + ex.getMessage(), ex);
}
}
private CustomerInvoice sampleInvoice() {
CustomerInvoice invoice = new CustomerInvoice();
invoice.setUserId(USER_ID);
invoice.setInvoiceNumber("R-2026-0001");
invoice.setInvoiceDate(LocalDate.now());
invoice.setDeliveryDate(LocalDate.now());
invoice.setSenderName("Votianlt Test GmbH");
invoice.setSenderAddress("Teststraße 1");
invoice.setSenderPostcode("12345");
invoice.setSenderCity("Berlin");
invoice.setSenderCountry("DE");
invoice.setRecipientName("Empfänger AG");
invoice.setRecipientAddress("Kundenweg 2");
invoice.setRecipientPostcode("54321");
invoice.setRecipientCity("Hamburg");
invoice.setRecipientCountry("DE");
invoice.setDescription("Beratungsleistung");
invoice.setVatRate(new BigDecimal("0.19"));
invoice.setNetAmount(new BigDecimal("100.00"));
invoice.setVatAmount(new BigDecimal("19.00"));
invoice.setTotalAmount(new BigDecimal("119.00"));
CustomerInvoiceItem item = new CustomerInvoiceItem(BigDecimal.ONE, "h", "Beratung", new BigDecimal("100.00"),
new BigDecimal("0.19"));
invoice.setItems(List.of(item));
return invoice;
}
private record KeystoreFixture(byte[] keystoreBytes, X509Certificate certificate) {
}
}

View File

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

View File

@@ -0,0 +1,304 @@
package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* Pflichtangaben-Validierung nach § 14 UStG. Jeder Test mutiert exakt eine
* Eigenschaft der Referenzrechnung — so bleibt sichtbar, welche Regel gerade
* geprüft wird, und die Tests fungieren gleichzeitig als ausführbare
* Spezifikation.
*/
class InvoiceComplianceValidatorTest {
private InvoiceComplianceValidator validator;
@BeforeEach
void setUp() {
validator = new InvoiceComplianceValidator();
}
@Test
void acceptsCompleteInvoice() {
CustomerInvoice invoice = validInvoice();
validator.validateForIssuance(invoice);
}
@Test
void rejectsMissingInvoiceNumber() {
CustomerInvoice invoice = validInvoice();
invoice.setInvoiceNumber(" ");
assertSingleViolation(invoice, "Rechnungsnummer fehlt");
}
@Test
void rejectsMissingInvoiceDate() {
CustomerInvoice invoice = validInvoice();
invoice.setInvoiceDate(null);
assertSingleViolation(invoice, "Rechnungsdatum");
}
@Test
void rejectsMissingDeliveryDate() {
CustomerInvoice invoice = validInvoice();
invoice.setDeliveryDate(null);
assertSingleViolation(invoice, "Leistungsdatum");
}
@Test
void rejectsMissingSenderName() {
CustomerInvoice invoice = validInvoice();
invoice.setSenderName(null);
assertSingleViolation(invoice, "Name des Leistenden");
}
@Test
void rejectsIncompleteSenderAddress() {
CustomerInvoice invoice = validInvoice();
invoice.setSenderPostcode("");
assertSingleViolation(invoice, "Anschrift des Leistenden");
}
@Test
void rejectsMissingSenderTaxIdentification() {
CustomerInvoice invoice = validInvoice();
invoice.setSenderTaxNumber(null);
invoice.setSenderVatId(null);
assertSingleViolation(invoice, "Steuernummer oder USt-IdNr");
}
@Test
void acceptsSenderWithOnlyTaxNumber() {
CustomerInvoice invoice = validInvoice();
invoice.setSenderVatId(null);
validator.validateForIssuance(invoice);
}
@Test
void acceptsSenderWithOnlyVatId() {
CustomerInvoice invoice = validInvoice();
invoice.setSenderTaxNumber(null);
validator.validateForIssuance(invoice);
}
@Test
void rejectsMissingRecipientName() {
CustomerInvoice invoice = validInvoice();
invoice.setRecipientName(null);
assertSingleViolation(invoice, "Name des Leistungsempfängers");
}
@Test
void rejectsIncompleteRecipientAddress() {
CustomerInvoice invoice = validInvoice();
invoice.setRecipientCity(null);
assertSingleViolation(invoice, "Anschrift des Leistungsempfängers");
}
@Test
void rejectsEmptyItems() {
CustomerInvoice invoice = validInvoice();
invoice.setItems(new ArrayList<>());
assertSingleViolation(invoice, "Keine Positionen erfasst");
}
@Test
void rejectsItemWithoutDescription() {
CustomerInvoice invoice = validInvoice();
invoice.getItems().get(0).setDescription("");
assertSingleViolation(invoice, "Bezeichnung der Leistung fehlt");
}
@Test
void rejectsItemWithZeroQuantity() {
CustomerInvoice invoice = validInvoice();
CustomerInvoiceItem item = invoice.getItems().get(0);
item.setQuantity(BigDecimal.ZERO);
// Zwingt netTotal = 0, damit nur die Mengenregel anschlägt und nicht zusätzlich
// die Konsistenzprüfung der Summen.
item.setNetTotal(BigDecimal.ZERO);
invoice.setNetAmount(BigDecimal.ZERO);
invoice.setVatAmount(BigDecimal.ZERO);
invoice.setTotalAmount(BigDecimal.ZERO);
// VAT-Hinweis muss bei 0 % aber gesetzt sein, sonst lösen wir zwei Verstöße aus.
invoice.setVatRate(new BigDecimal("0.19"));
assertSingleViolation(invoice, "Menge muss größer 0");
}
@Test
void rejectsItemWithNegativeUnitPrice() {
CustomerInvoice invoice = validInvoice();
invoice.getItems().get(0).setUnitPrice(new BigDecimal("-5.00"));
InvoiceComplianceException ex = catchThrowableOfType(
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
assertThat(ex).isNotNull();
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Einzelpreis"));
}
@Test
void rejectsInconsistentTotals() {
CustomerInvoice invoice = validInvoice();
invoice.setTotalAmount(new BigDecimal("999.99"));
InvoiceComplianceException ex = catchThrowableOfType(
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
assertThat(ex).isNotNull();
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Bruttobetrag passt nicht"));
}
@Test
void rejectsItemSumMismatchingNet() {
CustomerInvoice invoice = validInvoice();
// Items summieren sich weiterhin auf 100.00, aber das deklarierte Netto wird verstellt.
invoice.setNetAmount(new BigDecimal("80.00"));
invoice.setVatAmount(new BigDecimal("15.20"));
invoice.setTotalAmount(new BigDecimal("95.20"));
InvoiceComplianceException ex = catchThrowableOfType(
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
assertThat(ex).isNotNull();
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Summe der Positionen"));
}
@Test
void rejectsMissingVatRate() {
CustomerInvoice invoice = validInvoice();
invoice.setVatRate(null);
assertSingleViolation(invoice, "Steuersatz fehlt");
}
@Test
void rejectsZeroVatWithoutLegalNotice() {
CustomerInvoice invoice = zeroVatInvoice();
invoice.setReverseChargeNote(null);
invoice.setLegalNotes(null);
assertSingleViolation(invoice, "Bei 0 % USt ist ein rechtlicher Hinweis erforderlich");
}
@Test
void acceptsZeroVatWithReverseChargeNote() {
CustomerInvoice invoice = zeroVatInvoice();
invoice.setReverseChargeNote("Steuerschuldnerschaft des Leistungsempfängers (§ 13b UStG).");
invoice.setLegalNotes(null);
validator.validateForIssuance(invoice);
}
@Test
void acceptsZeroVatWithLegalNotes() {
CustomerInvoice invoice = zeroVatInvoice();
invoice.setReverseChargeNote(null);
invoice.setLegalNotes("Kleinunternehmer im Sinne des § 19 Abs. 1 UStG — keine Umsatzsteuer ausgewiesen.");
validator.validateForIssuance(invoice);
}
@Test
void rejectsMismatchingVatAmountForNonZeroRate() {
CustomerInvoice invoice = validInvoice();
// Erwartet wären 19,00 € — wir tragen 25,00 € ein.
invoice.setVatAmount(new BigDecimal("25.00"));
invoice.setTotalAmount(new BigDecimal("125.00"));
InvoiceComplianceException ex = catchThrowableOfType(
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
assertThat(ex).isNotNull();
assertThat(ex.getViolations()).anyMatch(v -> v.contains("Steuerbetrag"));
}
@Test
void collectsAllViolationsInOnePass() {
CustomerInvoice invoice = validInvoice();
invoice.setInvoiceNumber(null);
invoice.setSenderName(null);
invoice.setItems(new ArrayList<>());
InvoiceComplianceException ex = catchThrowableOfType(
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
assertThat(ex).isNotNull();
assertThat(ex.getViolations())
.as("Validator soll alle Verstöße sammeln, nicht beim ersten abbrechen")
.hasSizeGreaterThanOrEqualTo(3);
}
@Test
void cancellationIsNotValidatedHere() {
CustomerInvoice invoice = validInvoice();
invoice.setType(InvoiceType.CANCELLATION);
invoice.setItems(new ArrayList<>()); // wäre für reguläre Rechnung ein Fehler
validator.validateForIssuance(invoice);
}
@Test
void correctionIsNotValidatedHere() {
CustomerInvoice invoice = validInvoice();
invoice.setType(InvoiceType.CORRECTION);
invoice.setSenderName(null); // wäre für reguläre Rechnung ein Fehler
validator.validateForIssuance(invoice);
}
@Test
void nullInvoiceIsRejectedDirectly() {
assertThatThrownBy(() -> validator.validateForIssuance(null))
.isInstanceOf(IllegalArgumentException.class);
}
private void assertSingleViolation(CustomerInvoice invoice, String fragment) {
InvoiceComplianceException ex = catchThrowableOfType(
() -> validator.validateForIssuance(invoice), InvoiceComplianceException.class);
assertThat(ex).as("erwartete InvoiceComplianceException").isNotNull();
assertThat(ex.getViolations())
.as("Verstoß mit Fragment '%s' erwartet, war: %s", fragment, ex.getViolations())
.anyMatch(v -> v.contains(fragment));
}
private CustomerInvoice validInvoice() {
CustomerInvoice invoice = new CustomerInvoice();
invoice.setType(InvoiceType.INVOICE);
invoice.setInvoiceNumber("R-2026-0001");
invoice.setInvoiceDate(LocalDate.of(2026, 5, 3));
invoice.setDeliveryDate(LocalDate.of(2026, 5, 3));
invoice.setSenderName("Votianlt Test GmbH");
invoice.setSenderAddress("Teststraße 1");
invoice.setSenderPostcode("12345");
invoice.setSenderCity("Berlin");
invoice.setSenderCountry("DE");
invoice.setSenderTaxNumber("12/345/67890");
invoice.setSenderVatId("DE123456789");
invoice.setRecipientName("Empfänger AG");
invoice.setRecipientAddress("Kundenweg 2");
invoice.setRecipientPostcode("54321");
invoice.setRecipientCity("Hamburg");
invoice.setRecipientCountry("DE");
CustomerInvoiceItem item = new CustomerInvoiceItem(BigDecimal.ONE, "h", "Beratung",
new BigDecimal("100.00"), new BigDecimal("0.19"));
invoice.setItems(new ArrayList<>(List.of(item)));
invoice.setNetAmount(new BigDecimal("100.00"));
invoice.setVatRate(new BigDecimal("0.19"));
invoice.setVatAmount(new BigDecimal("19.00"));
invoice.setTotalAmount(new BigDecimal("119.00"));
return invoice;
}
private CustomerInvoice zeroVatInvoice() {
CustomerInvoice invoice = validInvoice();
invoice.setVatRate(BigDecimal.ZERO);
invoice.setVatAmount(BigDecimal.ZERO);
invoice.setTotalAmount(invoice.getNetAmount());
CustomerInvoiceItem item = invoice.getItems().get(0);
item.setVatRate(BigDecimal.ZERO);
item.setVatAmount(BigDecimal.ZERO);
item.setGrossTotal(item.getNetTotal());
return invoice;
}
}

View File

@@ -0,0 +1,171 @@
package de.assecutor.votianlt.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservation;
import de.assecutor.votianlt.model.invoices.InvoiceNumberReservationStatus;
import de.assecutor.votianlt.repository.InvoiceNumberReservationRepository;
import org.bson.types.ObjectId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
@ExtendWith(MockitoExtension.class)
class InvoiceNumberAuditServiceTest {
@Mock
private InvoiceNumberReservationRepository repository;
private InvoiceNumberAuditService service;
private final ObjectId userId = new ObjectId();
@BeforeEach
void setUp() {
service = new InvoiceNumberAuditService(repository);
}
@Test
void markUsedTransitionsExistingReservation() {
InvoiceNumberReservation reservation = reservation("R-2026-000010", 10L,
InvoiceNumberReservationStatus.RESERVED);
when(repository.findByUserIdAndNumber(userId, "R-2026-000010")).thenReturn(Optional.of(reservation));
when(repository.save(any(InvoiceNumberReservation.class))).thenAnswer(inv -> inv.getArgument(0));
CustomerInvoice invoice = invoice("R-2026-000010", "invoice-id-42");
service.markUsed(invoice);
ArgumentCaptor<InvoiceNumberReservation> captor = ArgumentCaptor.forClass(InvoiceNumberReservation.class);
verify(repository).save(captor.capture());
InvoiceNumberReservation saved = captor.getValue();
assertThat(saved.getStatus()).isEqualTo(InvoiceNumberReservationStatus.USED);
assertThat(saved.getInvoiceId()).isEqualTo("invoice-id-42");
assertThat(saved.getUsedAt()).isNotNull();
}
@Test
void markUsedBootstrapsReservationForLegacyInvoiceWithoutPriorReservation() {
when(repository.findByUserIdAndNumber(userId, "RE-2024-0007")).thenReturn(Optional.empty());
when(repository.save(any(InvoiceNumberReservation.class))).thenAnswer(inv -> inv.getArgument(0));
service.markUsed(invoice("RE-2024-0007", "legacy-invoice"));
ArgumentCaptor<InvoiceNumberReservation> captor = ArgumentCaptor.forClass(InvoiceNumberReservation.class);
verify(repository).save(captor.capture());
InvoiceNumberReservation saved = captor.getValue();
assertThat(saved.getStatus()).isEqualTo(InvoiceNumberReservationStatus.USED);
assertThat(saved.getNumber()).isEqualTo("RE-2024-0007");
assertThat(saved.getSequence()).isEqualTo(7L);
assertThat(saved.getReservedBy()).contains("bootstrap");
}
@Test
void markUsedSwallowsRepositoryFailures() {
when(repository.findByUserIdAndNumber(any(), any())).thenThrow(new RuntimeException("Mongo down"));
// Erwartung: keine Exception nach außen — Festschreiben darf an Audit nicht scheitern.
service.markUsed(invoice("R-2026-1", "i-1"));
}
@Test
void markUsedIgnoresInvoiceWithoutNumberOrUserId() {
CustomerInvoice missingNumber = new CustomerInvoice();
missingNumber.setUserId(userId.toHexString());
service.markUsed(missingNumber);
CustomerInvoice missingUser = new CustomerInvoice();
missingUser.setInvoiceNumber("R-1");
service.markUsed(missingUser);
verify(repository, never()).findByUserIdAndNumber(any(), any());
verify(repository, never()).save(any());
}
@Test
void markVoidedRequiresReason() {
assertThatThrownBy(() -> service.markVoided(userId, "R-1", " "))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Grund");
assertThatThrownBy(() -> service.markVoided(userId, "R-1", null))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void markVoidedTransitionsReservedToVoided() {
InvoiceNumberReservation reservation = reservation("R-2026-000005", 5L,
InvoiceNumberReservationStatus.RESERVED);
when(repository.findByUserIdAndNumber(userId, "R-2026-000005")).thenReturn(Optional.of(reservation));
when(repository.save(any(InvoiceNumberReservation.class))).thenAnswer(inv -> inv.getArgument(0));
service.markVoided(userId, "R-2026-000005", "Versehentlich vergeben, Kunde widerrufen");
ArgumentCaptor<InvoiceNumberReservation> captor = ArgumentCaptor.forClass(InvoiceNumberReservation.class);
verify(repository).save(captor.capture());
InvoiceNumberReservation saved = captor.getValue();
assertThat(saved.getStatus()).isEqualTo(InvoiceNumberReservationStatus.VOIDED);
assertThat(saved.getVoidReason()).isEqualTo("Versehentlich vergeben, Kunde widerrufen");
assertThat(saved.getVoidedAt()).isNotNull();
}
@Test
void markVoidedRefusesToOverwriteUsedReservation() {
InvoiceNumberReservation reservation = reservation("R-2026-000005", 5L,
InvoiceNumberReservationStatus.USED);
when(repository.findByUserIdAndNumber(userId, "R-2026-000005")).thenReturn(Optional.of(reservation));
assertThatThrownBy(() -> service.markVoided(userId, "R-2026-000005", "Test"))
.isInstanceOf(InvoiceLifecycleException.class)
.hasMessageContaining("ausgestellten Rechnung");
}
@Test
void markVoidedFailsWhenNoReservationFound() {
when(repository.findByUserIdAndNumber(userId, "R-NOPE")).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.markVoided(userId, "R-NOPE", "irrelevant"))
.isInstanceOf(InvoiceLifecycleException.class)
.hasMessageContaining("Keine Reservierung");
}
@Test
void findUnusedReturnsOnlyReservedAndVoided() {
when(repository.findByUserIdOrderBySequenceAsc(userId)).thenReturn(List.of(
reservation("R-1", 1L, InvoiceNumberReservationStatus.USED),
reservation("R-2", 2L, InvoiceNumberReservationStatus.RESERVED),
reservation("R-3", 3L, InvoiceNumberReservationStatus.USED),
reservation("R-4", 4L, InvoiceNumberReservationStatus.VOIDED)));
List<InvoiceNumberReservation> unused = service.findUnused(userId);
assertThat(unused).extracting(InvoiceNumberReservation::getNumber).containsExactly("R-2", "R-4");
}
private CustomerInvoice invoice(String number, String invoiceId) {
CustomerInvoice invoice = new CustomerInvoice();
invoice.setId(invoiceId);
invoice.setInvoiceNumber(number);
invoice.setUserId(userId.toHexString());
return invoice;
}
private InvoiceNumberReservation reservation(String number, long sequence,
InvoiceNumberReservationStatus status) {
InvoiceNumberReservation reservation = new InvoiceNumberReservation();
reservation.setUserId(userId);
reservation.setNumber(number);
reservation.setSequence(sequence);
reservation.setStatus(status);
return reservation;
}
}