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>
<scope>import</scope>
</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>
</dependencyManagement>
@@ -159,6 +184,20 @@
<version>5.0.5</version>
</dependency>
<!-- BouncyCastle: CMS-Signatur für PAdES via Apache PDFBox -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
<version>1.78</version>
</dependency>
<!-- Mustangproject: ZUGFeRD/Factur-X/XRechnung E-Rechnung -->
<dependency>
<groupId>org.mustangproject</groupId>
<artifactId>library</artifactId>
<version>2.16.0</version>
</dependency>
<!-- Spring AI OpenAI (LM Studio kompatibel) -->
<dependency>
<groupId>org.springframework.ai</groupId>
@@ -183,6 +222,32 @@
<scope>test</scope>
</dependency>
<!-- EU DSS: PAdES-Validierung der signierten Test-PDFs (nur Test-Scope) -->
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-pades-pdfbox</artifactId>
<version>6.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-validation</artifactId>
<version>6.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-utils-apache-commons</artifactId>
<version>6.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>eu.europa.ec.joinup.sd-dss</groupId>
<artifactId>dss-crl-parser-x509crl</artifactId>
<version>6.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>

View File

@@ -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 %)
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.mongodb.core.mapping.Document;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
@Document(collection = "customerInvoices")
@@ -12,6 +14,39 @@ public class CustomerInvoice {
@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)
private String invoiceNumber; // Fortlaufende Rechnungsnummer
private LocalDate invoiceDate; // Rechnungsdatum
@@ -372,4 +407,166 @@ public class CustomerInvoice {
public void setPdfData(byte[] 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"
treeData.addItem(verwaltungItem,
new MenuTreeItem(getTranslation("nav.jobs"), "jobs", VaadinIcon.CLIPBOARD_TEXT));
// Eigenes Rechnungs-Modul ist deaktiviert (siehe CreateInvoiceView/InvoicesView).
// Die rechnungsrelevanten Daten werden ausschließlich per DATEV-CSV exportiert.
treeData.addItem(verwaltungItem,
new MenuTreeItem(getTranslation("nav.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,
new MenuTreeItem(getTranslation("nav.customers"), "customers", VaadinIcon.USERS));
treeData.addItem(verwaltungItem,

View File

@@ -1,8 +1,14 @@
package de.assecutor.votianlt.pages.service;
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.security.SecurityService;
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.MongoTemplate;
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.stereotype.Service;
import java.time.Instant;
import java.util.Optional;
@Service
public class UserInvoiceDataService {
private static final Logger log = LoggerFactory.getLogger(UserInvoiceDataService.class);
private final UserInvoiceDataRepository userInvoiceDataRepository;
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.mongoTemplate = mongoTemplate;
this.reservationRepository = reservationRepository;
this.securityService = securityService;
}
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
* 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) {
Query query = Query.query(Criteria.where("userId").is(userId));
@@ -75,11 +95,56 @@ public class UserInvoiceDataService {
// Kein Eintrag vorhanden - Fallback auf aktuelle Daten
return findByUserId(userId).map(d -> {
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");
}
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.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.HasUrlParameter;
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.service.CustomerService;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.ServiceRepository;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.EInvoiceService;
import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoiceLifecycleService;
import de.assecutor.votianlt.service.InvoiceTemplateService;
import jakarta.annotation.security.RolesAllowed;
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.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" })
@Slf4j
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 SecurityService securityService;
private final UserInvoiceDataService userInvoiceDataService;
private final CustomerInvoiceRepository customerInvoiceRepository;
private final CustomerService customerService;
private final InvoiceLifecycleService invoiceLifecycleService;
private final EInvoiceService eInvoiceService;
private User currentUser;
private Job currentJob;
private List<ServiceRow> gridRows = new ArrayList<>();
@@ -117,8 +125,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
public CreateInvoiceView(JobRepository jobRepository, ServiceRepository serviceRepository,
UserRepository userRepository, CustomerInvoiceService customerInvoiceService,
InvoiceTemplateService invoiceTemplateService, SecurityService securityService,
UserInvoiceDataService userInvoiceDataService, CustomerInvoiceRepository customerInvoiceRepository,
CustomerService customerService) {
UserInvoiceDataService userInvoiceDataService, CustomerService customerService,
InvoiceLifecycleService invoiceLifecycleService, EInvoiceService eInvoiceService) {
this.jobRepository = jobRepository;
this.serviceRepository = serviceRepository;
this.userRepository = userRepository;
@@ -126,8 +134,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
this.invoiceTemplateService = invoiceTemplateService;
this.securityService = securityService;
this.userInvoiceDataService = userInvoiceDataService;
this.customerInvoiceRepository = customerInvoiceRepository;
this.customerService = customerService;
this.invoiceLifecycleService = invoiceLifecycleService;
this.eInvoiceService = eInvoiceService;
setSizeFull();
setPadding(true);
setSpacing(true);
@@ -584,8 +593,22 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
invoice.setVatRate(vatRate);
invoice.setVatAmount(vatAmount);
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());
jobRepository.save(currentJob);
@@ -594,6 +617,9 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
Notification.show(getTranslation("createinvoice.notification.saved", invoiceNumber), 4000,
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) {
log.error("Fehler beim Speichern der Rechnung", ex);
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.repository.ServiceRepository;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.config.EInvoiceProperties;
import de.assecutor.votianlt.pages.base.ui.component.SigningCredentialsPanel;
import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.InvoiceTemplateService;
import de.assecutor.votianlt.service.LanguageService;
import de.assecutor.votianlt.service.SigningCredentialsService;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.combobox.ComboBox;
import com.vaadin.flow.component.textfield.NumberField;
@@ -74,6 +77,8 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
private final UserInvoiceDataService userInvoiceDataService;
private final CustomerInvoiceService customerInvoiceService;
private final InvoiceTemplateService invoiceTemplateService;
private final SigningCredentialsService signingCredentialsService;
private final EInvoiceProperties eInvoiceProperties;
private UserInvoiceData currentInvoiceData;
private Checkbox billingEnabled;
private NumberField vatRateField;
@@ -87,12 +92,15 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
public EditProfileView(UserService userService, UserInvoiceDataService userInvoiceDataService,
CustomerInvoiceService customerInvoiceService, InvoiceTemplateService invoiceTemplateService,
LanguageService languageService, SecurityService securityService, ServiceRepository serviceRepository) {
LanguageService languageService, SecurityService securityService, ServiceRepository serviceRepository,
SigningCredentialsService signingCredentialsService, EInvoiceProperties eInvoiceProperties) {
this.userInvoiceDataService = userInvoiceDataService;
this.customerInvoiceService = customerInvoiceService;
this.invoiceTemplateService = invoiceTemplateService;
this.currentUser = securityService.getCurrentDatabaseUser();
this.serviceRepository = serviceRepository;
this.signingCredentialsService = signingCredentialsService;
this.eInvoiceProperties = eInvoiceProperties;
// Store the original language before any changes
this.originalLanguage = this.currentUser != null ? this.currentUser.getLanguage() : Language.DE;
@@ -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.setAlignItems(FlexComponent.Alignment.BASELINE);
billingTab.add(billingHeaderLayout);
// Signatur-Credentials (Phase 5.5/5.6)
if (currentUser != null && currentUser.getId() != null) {
SigningCredentialsPanel signingPanel = new SigningCredentialsPanel(signingCredentialsService,
eInvoiceProperties, currentUser.getId().toHexString(), this::getTranslation);
signingPanel.setMaxWidth("760px");
billingTab.add(signingPanel);
}
// Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften)
final HorizontalLayout mainLayout = new HorizontalLayout();
mainLayout.setWidthFull();

View File

@@ -1,39 +1,89 @@
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.html.Anchor;
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.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
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.Route;
import com.vaadin.flow.component.UI;
// Route deaktiviert (siehe Klassen-Header) — die Anwendung erstellt/bearbeitet keine
// Rechnungen mehr selbst, der Bestand wird per DATEV-Export weiterverarbeitet.
// import com.vaadin.flow.router.Route;
import com.vaadin.flow.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.InvoiceAuditEntry;
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
import de.assecutor.votianlt.model.invoices.InvoiceType;
import de.assecutor.votianlt.model.invoices.PaymentStatus;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.InvoiceApprovalService;
import de.assecutor.votianlt.service.InvoiceExportService;
import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoiceLifecycleService;
import de.assecutor.votianlt.service.InvoicePermissionService;
import jakarta.annotation.security.RolesAllowed;
import java.io.ByteArrayInputStream;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import com.vaadin.flow.server.StreamResource;
import com.vaadin.flow.server.StreamRegistration;
@Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
// @Route deaktiviert — Rechnungs-UI ist durch DATEV-Export ersetzt. Reaktivierung:
// @Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER", "ADMIN" })
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 CustomerInvoiceRepository customerInvoiceRepository;
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.securityService = securityService;
this.invoiceLifecycleService = invoiceLifecycleService;
this.customerInvoiceService = customerInvoiceService;
this.invoiceExportService = invoiceExportService;
this.invoicePermissionService = invoicePermissionService;
this.invoiceApprovalService = invoiceApprovalService;
this.userInvoiceDataService = userInvoiceDataService;
this.userRepository = userRepository;
setSizeFull();
setPadding(true);
@@ -43,60 +93,572 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
addClassName("data-view");
add(new ViewToolbar(getTranslation("invoices.title")));
add(buildLegalDisclaimer());
invoiceGrid = new Grid<>(CustomerInvoice.class, false);
invoiceGrid.setWidthFull();
invoiceGrid.addClassName("data-grid");
invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getInvoiceNumber(), invoice.getId()))
.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"))
.setAutoWidth(true);
invoiceGrid.addColumn(invoice -> Optional.ofNullable(invoice.getInvoiceDate()).map(Object::toString).orElse(""))
.setHeader(getTranslation("invoices.column.date")).setAutoWidth(true);
invoiceGrid.addColumn(this::formatAmount).setHeader(getTranslation("invoices.column.amount"))
.setAutoWidth(true);
invoiceGrid.addColumn(invoice -> firstNonBlank(invoice.getDescription(), ""))
.setHeader(getTranslation("invoices.column.description")).setAutoWidth(true);
invoiceGrid.setSelectionMode(Grid.SelectionMode.SINGLE);
invoiceGrid.getStyle().set("cursor", "pointer");
invoiceGrid.addComponentColumn(this::renderPaymentBadge)
.setHeader(getTranslation("invoices.column.payment")).setAutoWidth(true);
invoiceGrid.addColumn(this::formatOutstanding).setHeader(getTranslation("invoices.column.outstanding"))
.setAutoWidth(true);
invoiceGrid.addComponentColumn(this::renderActions)
.setHeader(getTranslation("invoices.column.actions")).setAutoWidth(true).setFlexGrow(0);
invoiceGrid.addItemClickListener(event -> {
CustomerInvoice invoice = event.getItem();
if (invoice != null) {
downloadInvoicePdf(invoice);
}
});
invoiceGrid.setSelectionMode(Grid.SelectionMode.NONE);
loadInvoices();
Div gridPanel = new Div(invoiceGrid);
gridPanel.addClassNames("surface-panel", "data-grid-panel");
gridPanel.setWidthFull();
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() {
String currentUserId = securityService.getCurrentUserId().toHexString();
List<CustomerInvoice> invoices = customerInvoiceRepository.findByUserId(currentUserId).stream()
.filter(this::hasPdfData).sorted((left, right) -> {
if (left.getInvoiceDate() == null && right.getInvoiceDate() == null) {
return 0;
}
if (left.getInvoiceDate() == null) {
return 1;
}
if (right.getInvoiceDate() == null) {
return -1;
}
return right.getInvoiceDate().compareTo(left.getInvoiceDate());
}).toList();
.sorted(Comparator
.comparing((CustomerInvoice i) -> i.getInvoiceDate() == null ? LocalDate.MIN
: i.getInvoiceDate())
.reversed())
.toList();
invoiceGrid.setItems(invoices);
if (invoices.isEmpty()) {
Span emptyState = new Span(getTranslation("invoices.empty"));
emptyState.getStyle().set("color", "var(--lumo-secondary-text-color)");
add(emptyState);
}
private Component renderStatusBadge(CustomerInvoice invoice) {
InvoiceStatus status = invoice.getStatus() != null ? invoice.getStatus() : InvoiceStatus.ISSUED;
Span badge = new Span(getTranslation("invoices.status." + status.name().toLowerCase(Locale.ROOT)));
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) {
@@ -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) {
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.value.ValueChangeMode;
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.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 com.vaadin.flow.component.UI;
import com.vaadin.flow.server.StreamResource;
@@ -41,7 +43,8 @@ import java.util.Locale;
* Modernisierte Optik: Responsive Karten, Lumo-Theme-Varianten, Status-Badges,
* 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")
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();
}).setHeader("").setAutoWidth(true).setFlexGrow(0);
// Invoice column - only show for completed jobs
grid.addComponentColumn(job -> {
if (job.getStatus() == JobStatus.COMPLETED) {
if (hasInvoice(job)) {
Button invoiceBtn = new Button(new Icon(VaadinIcon.FILE_TEXT_O));
invoiceBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
invoiceBtn.setTooltipText(getTranslation("jobs.tooltip.showinvoice"));
invoiceBtn.addClickListener(e -> {
e.getSource().getElement().getNode();
customerInvoiceRepository.findById(job.getInvoiceId()).ifPresentOrElse(invoice -> {
if (invoice.getPdfData() != null) {
CreateInvoiceView.showSavedInvoiceDialog(invoice.getPdfData(),
invoice.getInvoiceNumber(), this);
} else {
getUI().ifPresent(ui -> ui.navigate("create_invoice/" + job.getId().toHexString()));
}
}, () -> getUI().ifPresent(ui -> ui.navigate("create_invoice/" + job.getId().toHexString())));
});
return invoiceBtn;
}
Button createInvoiceBtn = new Button(new Icon(VaadinIcon.DOLLAR));
createInvoiceBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SUCCESS);
createInvoiceBtn.setTooltipText(getTranslation("jobs.tooltip.createinvoice"));
createInvoiceBtn.addClickListener(e -> {
e.getSource().getElement().getNode();
getUI().ifPresent(ui -> ui.navigate("create_invoice/" + job.getId().toHexString()));
});
return createInvoiceBtn;
}
return new com.vaadin.flow.component.html.Span();
}).setHeader("").setWidth("60px").setFlexGrow(0);
// Rechnungs-Aktionen entfernt: Das System erstellt/verwaltet keine Rechnungen
// mehr aktiv aus der Jobs-Übersicht heraus. Bereits vorhandene Rechnungs-PDFs
// (Bestandsdaten) bleiben über den DATEV-Export bzw. die Backend-Repositories
// zugänglich; ein dedizierter UI-Button im Jobs-Grid ist dafür nicht mehr nötig.
// Delete column (last column, right side)
grid.addComponentColumn(job -> {

View File

@@ -1,6 +1,7 @@
package de.assecutor.votianlt.repository;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.InvoiceStatus;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
@@ -13,4 +14,13 @@ public interface CustomerInvoiceRepository extends MongoRepository<CustomerInvoi
Optional<CustomerInvoice> findByJobId(String jobId);
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;")
.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

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

View File

@@ -9,6 +9,17 @@ nav.customers=Adressbuch
nav.appusers=App-Nutzer
nav.statistics=Statistiken
nav.invoices=Rechnungen
nav.datev.export=DATEV-Export
nav.approvals=Freigaben
datev.export.title=DATEV-Export
datev.export.description=Lädt einen DATEV-kompatiblen Buchungsstapel mit allen festgeschriebenen Rechnungen des gewählten Zeitraums herunter. Die Datei kann in DATEV Unternehmen Online sowie in DATEV-importfähigen Drittprogrammen eingelesen werden.
datev.export.from=Von
datev.export.to=Bis
datev.export.button=Rechnungen exportieren
datev.export.success=Export erstellt: {0}
datev.export.error.dates=Bitte Von- und Bis-Datum auswählen.
datev.export.error.range=Das Bis-Datum darf nicht vor dem Von-Datum liegen.
datev.export.error.user=Aktueller Nutzer konnte nicht ermittelt werden.
nav.messages=Nachrichten
nav.profile=Mein Profil
nav.myinvoices=Rechnungen
@@ -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.info=Standort der App-Nutzer wird regelmäßig übertragen
profile.settings.vatrate=Umsatzsteuer
profile.settings.einvoice=ZUGFeRD-E-Rechnung erstellen
profile.settings.einvoice.helper=Erzeugt PDF/A-3 mit eingebettetem XRechnung/ZUGFeRD-XML (sofern systemweit aktiviert).
profile.settings.signinvoices=Rechnungen digital signieren
profile.settings.signinvoices.helper=Erzeugt eine PAdES-Signatur mit dem hinterlegten Zertifikat. Ohne aktives Zertifikat schlägt das Speichern fehl.
profile.signing.title=Signatur-Zertifikat
profile.signing.hint=Hinterlegen Sie Ihr eigenes PKCS#12-Zertifikat (.p12/.pfx), damit Ihre Rechnungen mit Ihrer Signatur erstellt werden. Der private Schlüssel wird verschlüsselt in der Datenbank gespeichert.
profile.signing.masterkey.missing=Hinweis: Der Server-Master-Key ist nicht gesetzt. Bitten Sie Ihren Administrator, votianlt.einvoice.signing.master-key zu konfigurieren, bevor Sie ein Zertifikat hinterlegen.
profile.signing.none=Es ist noch kein eigenes Signatur-Zertifikat hinterlegt. Beim Signieren wird der systemweit konfigurierte Schlüssel verwendet.
profile.signing.metadata.alias=Alias
profile.signing.metadata.subject=Inhaber
profile.signing.metadata.issuer=Aussteller
profile.signing.metadata.serial=Seriennummer
profile.signing.metadata.validity=Gültig
profile.signing.expired=Zertifikat abgelaufen
profile.signing.expiring=Läuft in den nächsten 30 Tagen ab
profile.signing.enabled=Eigenes Zertifikat zum Signieren verwenden
profile.signing.toggle.saved=Einstellung gespeichert.
profile.signing.delete=Zertifikat entfernen
profile.signing.deleted=Signatur-Zertifikat entfernt.
profile.signing.upload.title=Zertifikat hochladen
profile.signing.upload.drop=PKCS#12-Datei hier ablegen oder klicken
profile.signing.upload.received=Datei empfangen — bitte Alias und Passwort angeben.
profile.signing.upload.required=Bitte zuerst eine Zertifikatsdatei hochladen.
profile.signing.upload.save=Speichern
profile.signing.alias=Schlüssel-Alias
profile.signing.alias.required=Bitte den Alias des Schlüssels angeben.
profile.signing.password=Keystore-Passwort
profile.signing.password.required=Bitte das Keystore-Passwort angeben.
profile.signing.saved=Signatur-Zertifikat gespeichert.
profile.signing.error=Speichern fehlgeschlagen
profile.account=Konto
profile.security=Sicherheit
profile.security.twofactor=Zwei-Faktor-Authentifizierung
@@ -706,6 +747,96 @@ invoices.column.amount=Betrag
invoices.column.description=Beschreibung
invoices.empty=Es wurden noch keine Rechnungen erstellt.
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
myinvoices.title=Rechnungen

View File

@@ -9,6 +9,17 @@ nav.customers=Address Book
nav.appusers=App Users
nav.statistics=Statistics
nav.invoices=Invoices
nav.datev.export=DATEV Export
nav.approvals=Approvals
datev.export.title=DATEV Export
datev.export.description=Downloads a DATEV-compatible booking batch containing all finalized invoices for the selected period. The file can be imported into DATEV Unternehmen Online as well as DATEV-compatible third-party tools.
datev.export.from=From
datev.export.to=To
datev.export.button=Export invoices
datev.export.success=Export created: {0}
datev.export.error.dates=Please pick both From and To dates.
datev.export.error.range=To date must not be before From date.
datev.export.error.user=Could not determine the current user.
nav.messages=Messages
nav.profile=My Profile
nav.myinvoices=Invoices
@@ -47,6 +58,36 @@ profile.settings.digitalprocess.info=Jobs are processed digitally via the app
profile.settings.locateappuser=Locate App Users
profile.settings.locateappuser.info=App user location is transmitted regularly
profile.settings.vatrate=VAT rate
profile.settings.einvoice=Create ZUGFeRD e-invoice
profile.settings.einvoice.helper=Generates PDF/A-3 with embedded XRechnung/ZUGFeRD XML (when enabled system-wide).
profile.settings.signinvoices=Digitally sign invoices
profile.settings.signinvoices.helper=Adds a PAdES signature using the configured certificate. Saving fails if no active certificate is available.
profile.signing.title=Signing certificate
profile.signing.hint=Upload your own PKCS#12 certificate (.p12/.pfx) so invoices are signed with your signature. The private key is stored encrypted in the database.
profile.signing.masterkey.missing=Note: The server master key is not configured. Ask your administrator to set votianlt.einvoice.signing.master-key before uploading a certificate.
profile.signing.none=No personal signing certificate stored yet. The system-wide key will be used when signing.
profile.signing.metadata.alias=Alias
profile.signing.metadata.subject=Subject
profile.signing.metadata.issuer=Issuer
profile.signing.metadata.serial=Serial number
profile.signing.metadata.validity=Valid
profile.signing.expired=Certificate expired
profile.signing.expiring=Expires within the next 30 days
profile.signing.enabled=Use my certificate for signing
profile.signing.toggle.saved=Setting saved.
profile.signing.delete=Remove certificate
profile.signing.deleted=Signing certificate removed.
profile.signing.upload.title=Upload certificate
profile.signing.upload.drop=Drop your PKCS#12 file here or click to upload
profile.signing.upload.received=File received — please provide alias and password.
profile.signing.upload.required=Please upload a certificate file first.
profile.signing.upload.save=Save
profile.signing.alias=Key alias
profile.signing.alias.required=Please provide the key alias.
profile.signing.password=Keystore password
profile.signing.password.required=Please provide the keystore password.
profile.signing.saved=Signing certificate saved.
profile.signing.error=Save failed
profile.account=Account
profile.security=Security
profile.security.twofactor=Two-Factor Authentication
@@ -706,6 +747,96 @@ invoices.column.amount=Amount
invoices.column.description=Description
invoices.empty=No invoices have been created yet.
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
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;
}
}