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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 Aufmerksamkeitssignal für Bare-Metal- oder VM-Setups.
|
||||
*/
|
||||
private void warnIfPermissionsTooOpen(Path path) {
|
||||
try {
|
||||
Set<PosixFilePermission> perms = Files.getPosixFilePermissions(path);
|
||||
boolean groupReadable = perms.contains(PosixFilePermission.GROUP_READ);
|
||||
boolean otherReadable = perms.contains(PosixFilePermission.OTHERS_READ);
|
||||
boolean otherWritable = perms.contains(PosixFilePermission.OTHERS_WRITE);
|
||||
boolean groupWritable = perms.contains(PosixFilePermission.GROUP_WRITE);
|
||||
|
||||
if (otherWritable || groupWritable) {
|
||||
log.error("E-Invoice: Master-Key-Datei {} ist für andere Nutzer SCHREIBBAR ({}). "
|
||||
+ "Sofort die Berechtigungen härten (chmod 600).", path,
|
||||
PosixFilePermissions.toString(perms));
|
||||
} else if (otherReadable || groupReadable) {
|
||||
log.warn("E-Invoice: Master-Key-Datei {} ist für andere Nutzer lesbar ({}). "
|
||||
+ "Empfehlung für Bare-Metal/VM-Deployments: chmod 600. "
|
||||
+ "Im Container ist dies meist akzeptabel.", path, PosixFilePermissions.toString(perms));
|
||||
}
|
||||
} catch (UnsupportedOperationException ex) {
|
||||
// POSIX-Permissions auf Nicht-Unix (z.B. Windows) nicht verfügbar — Check überspringen.
|
||||
} catch (IOException ex) {
|
||||
log.warn("E-Invoice: Berechtigungsprüfung der Master-Key-Datei {} fehlgeschlagen: {}", path,
|
||||
ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package de.assecutor.votianlt.model.invoices;
|
||||
|
||||
public enum InvoiceApprovalStatus {
|
||||
PENDING, APPROVED, REJECTED
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 : "";
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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(), "");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -921,4 +921,181 @@ public class CustomerInvoiceService {
|
||||
return input.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(" · 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 "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("\"", "\"\"") + "\"";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 : "";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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._-]", "_");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:}
|
||||
@@ -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=Originalrechnung
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 & 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) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user