feat: E-Rechnung (ZUGFeRD) für Nutzer-Rechnungen mit ZIP-Download und E-Mail-Versand, Empfänger-E-Mail und Bankdaten im Profil

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 13:46:30 +02:00
parent 3d408e43c9
commit d146d2f319
16 changed files with 690 additions and 1 deletions

View File

@@ -66,6 +66,7 @@ public class CustomerInvoice {
private String recipientCity; private String recipientCity;
private String recipientCountry; private String recipientCountry;
private String recipientVatId; // USt-IdNr. des Empfängers (falls vorhanden) private String recipientVatId; // USt-IdNr. des Empfängers (falls vorhanden)
private String recipientEmail; // E-Mail für den Rechnungsversand
// Rechnungsdetails // Rechnungsdetails
private String description; // Beschreibung der Leistung private String description; // Beschreibung der Leistung
@@ -274,6 +275,14 @@ public class CustomerInvoice {
this.recipientVatId = recipientVatId; this.recipientVatId = recipientVatId;
} }
public String getRecipientEmail() {
return recipientEmail;
}
public void setRecipientEmail(String recipientEmail) {
this.recipientEmail = recipientEmail;
}
public String getDescription() { public String getDescription() {
return description; return description;
} }

View File

@@ -24,6 +24,7 @@ import de.assecutor.votianlt.model.Service;
import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.InvoiceTemplate; import de.assecutor.votianlt.model.InvoiceTemplate;
import de.assecutor.votianlt.model.invoices.CustomerInvoice; import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper; import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.service.CustomerService; import de.assecutor.votianlt.pages.service.CustomerService;
import de.assecutor.votianlt.pages.service.UserInvoiceDataService; import de.assecutor.votianlt.pages.service.UserInvoiceDataService;
@@ -586,6 +587,14 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
invoice.setVatAmount(vatAmount); invoice.setVatAmount(vatAmount);
invoice.setTotalAmount(totalAmount); invoice.setTotalAmount(totalAmount);
// Pflichtangaben nach §14 UStG: Leistungsdatum, Absender, Empfänger und
// Positionen — auch Grundlage der ZUGFeRD-Metadaten für die E-Rechnung.
invoice.setDeliveryDate(currentJob.getDeliveryDate() != null ? currentJob.getDeliveryDate()
: java.time.LocalDate.now());
applySenderData(invoice, user);
applyRecipientData(invoice);
invoice.setItems(buildInvoiceItems(vatRate));
invoice.setPdfData(pdfBytes); invoice.setPdfData(pdfBytes);
// Finalisierung mit Audit-Eintrag und Eindeutigkeitsprüfung der Rechnungsnummer (R-07/R-11/R-36). // Finalisierung mit Audit-Eintrag und Eindeutigkeitsprüfung der Rechnungsnummer (R-07/R-11/R-36).
@@ -609,6 +618,105 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter
} }
} }
/**
* Absenderdaten aus dem Nutzerprofil und den Rechnungs-Stammdaten
* (USt-IdNr./Steuernummer, Bankverbindung, Zahlungsbedingungen) übernehmen.
*/
private void applySenderData(CustomerInvoice invoice, User user) {
invoice.setSenderName(firstNonBlank(user.getCompany(),
(safe(user.getFirstname()) + " " + safe(user.getName())).trim()));
invoice.setSenderAddress((safe(user.getStreet()) + " " + safe(user.getHouseNumber())).trim());
invoice.setSenderPostcode(safe(user.getZip()));
invoice.setSenderCity(safe(user.getCity()));
invoice.setSenderCountry("DE");
invoice.setSenderPhone(safe(user.getPhone()));
invoice.setSenderEmail(safe(user.getEmail()));
userInvoiceDataService.findByUserId(user.getId()).ifPresent(invoiceData -> {
invoice.setSenderVatId(safe(invoiceData.getUstId()));
invoice.setSenderTaxNumber(safe(invoiceData.getTaxNumber()));
invoice.setIban(safe(invoiceData.getIban()));
invoice.setBankAccount(safe(invoiceData.getBankName()));
invoice.setPaymentTerms(safe(invoiceData.getPaymentTerms()));
});
if (invoice.getPaymentDueDate() == null) {
invoice.setPaymentDueDate(invoice.getInvoiceDate().plusDays(14));
}
}
/**
* Empfängerdaten aus der Kundenauswahl des Auftrags übernehmen; die volle
* Anschrift und E-Mail kommen aus dem passenden Adressbuch-Eintrag.
*/
private void applyRecipientData(CustomerInvoice invoice) {
String customerSelection = currentJob.getCustomerSelection();
if (customerSelection == null || customerSelection.isBlank()) {
return;
}
// Format: "Firmenname | Vorname Nachname"
String[] parts = customerSelection.split("\\|");
String companyName = parts.length > 0 ? parts[0].trim() : "";
String contactName = parts.length > 1 ? parts[1].trim() : "";
invoice.setRecipientCompany(companyName);
invoice.setRecipientName(firstNonBlank(contactName, companyName));
Customer matchedCustomer = customerService.findAllForCurrentOwner().stream()
.filter(c -> companyName.equalsIgnoreCase(c.getCompanyName())).findFirst().orElse(null);
if (matchedCustomer != null) {
invoice.setRecipientAddress(
(safe(matchedCustomer.getStreet()) + " " + safe(matchedCustomer.getHouseNumber())).trim());
invoice.setRecipientPostcode(safe(matchedCustomer.getZip()));
invoice.setRecipientCity(safe(matchedCustomer.getCity()));
invoice.setRecipientCountry("DE");
invoice.setRecipientEmail(safe(matchedCustomer.getMail()));
}
}
/**
* Rechnungspositionen aus den ausgewählten Leistungen aufbauen — mit
* denselben Mengen-/Preisregeln wie {@link #calculateNetAmount()}, damit
* die Positionssumme exakt zum Rechnungs-Netto passt.
*/
private List<CustomerInvoiceItem> buildInvoiceItems(BigDecimal vatRate) {
List<CustomerInvoiceItem> items = new ArrayList<>();
for (ServiceRow row : getSelectedServices()) {
Service service = row.getService();
BigDecimal quantity = null;
BigDecimal unitPrice = null;
String unit = null;
if (service.getCalculationBasis() == Service.CalculationBasis.FLAT_RATE && service.getPrice() != null) {
quantity = BigDecimal.ONE;
unitPrice = service.getPrice();
unit = "pauschal";
} else if (service.getCalculationBasis() == Service.CalculationBasis.DISTANCE
&& service.getPricePerKilometer() != null && getRouteDistanceKm(row.getSelection()) != null) {
quantity = BigDecimal.valueOf(getRouteDistanceKm(row.getSelection()));
unitPrice = service.getPricePerKilometer();
unit = "km";
} else if (service.getCalculationBasis() == Service.CalculationBasis.TIME
&& service.getPricePer15Minutes() != null && getTimeIn15MinUnits(row.getSelection()) != null) {
quantity = new BigDecimal(getTimeIn15MinUnits(row.getSelection()));
unitPrice = service.getPricePer15Minutes();
unit = "x 15 Min.";
}
if (quantity == null || quantity.signum() <= 0 || unitPrice == null) {
continue;
}
items.add(new CustomerInvoiceItem(quantity, unit, service.getName(), unitPrice, vatRate));
}
return items;
}
private String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return "";
}
/** /**
* Generates an invoice PDF from the template with actual job data. * Generates an invoice PDF from the template with actual job data.
*/ */

View File

@@ -372,6 +372,24 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle
billingHeaderLayout.setAlignItems(FlexComponent.Alignment.BASELINE); billingHeaderLayout.setAlignItems(FlexComponent.Alignment.BASELINE);
billingTab.add(billingHeaderLayout); billingTab.add(billingHeaderLayout);
// Steuer- und Bankdaten: Pflichtangaben nach §14 UStG bzw. für die
// ZUGFeRD-E-Rechnung (Absender-USt-IdNr./Steuernummer, IBAN)
ustIdField.setLabel(getTranslation("profile.billing.ustid"));
ustIdField.setPlaceholder("DE123456789");
ustIdField.addBlurListener(e -> saveInvoiceData());
taxNumberField.setLabel(getTranslation("profile.billing.taxnumber"));
taxNumberField.addBlurListener(e -> saveInvoiceData());
bankNameField.setLabel(getTranslation("profile.billing.bankname"));
bankNameField.addBlurListener(e -> saveInvoiceData());
ibanField.setLabel(getTranslation("profile.billing.iban"));
ibanField.setPlaceholder("DE00 0000 0000 0000 0000 00");
ibanField.addBlurListener(e -> saveInvoiceData());
HorizontalLayout billingTaxLayout = new HorizontalLayout(ustIdField, taxNumberField, bankNameField, ibanField);
billingTaxLayout.setSpacing(true);
billingTaxLayout.setAlignItems(FlexComponent.Alignment.BASELINE);
billingTab.add(billingTaxLayout);
// Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften) // Hauptlayout: Links (Templates) | Mitte (Canvas) | Rechts (Eigenschaften)
final HorizontalLayout mainLayout = new HorizontalLayout(); final HorizontalLayout mainLayout = new HorizontalLayout();
mainLayout.setWidthFull(); mainLayout.setWidthFull();

View File

@@ -33,11 +33,15 @@ import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
import de.assecutor.votianlt.repository.UserRepository; import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.security.SecurityService; import de.assecutor.votianlt.security.SecurityService;
import de.assecutor.votianlt.service.CustomerInvoiceService; import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.EmailService;
import de.assecutor.votianlt.service.InvoiceExportService; import de.assecutor.votianlt.service.InvoiceExportService;
import de.assecutor.votianlt.service.InvoiceLifecycleException; import de.assecutor.votianlt.service.InvoiceLifecycleException;
import de.assecutor.votianlt.service.InvoiceLifecycleService; import de.assecutor.votianlt.service.InvoiceLifecycleService;
import de.assecutor.votianlt.service.InvoicePermissionService; import de.assecutor.votianlt.service.InvoicePermissionService;
import de.assecutor.votianlt.service.PdfToolService;
import de.assecutor.votianlt.service.UserInvoiceZugferdService;
import jakarta.annotation.security.RolesAllowed; import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.time.LocalDate; import java.time.LocalDate;
@@ -49,6 +53,7 @@ import java.util.Optional;
@Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class) @Route(value = "invoices", layout = de.assecutor.votianlt.pages.base.ui.view.MainLayout.class)
@RolesAllowed({ "USER", "ADMIN" }) @RolesAllowed({ "USER", "ADMIN" })
@Slf4j
public class InvoicesView extends VerticalLayout implements HasDynamicTitle { public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm", private static final DateTimeFormatter DATE_TIME_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm",
@@ -63,12 +68,15 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
private final InvoicePermissionService invoicePermissionService; private final InvoicePermissionService invoicePermissionService;
private final UserInvoiceDataService userInvoiceDataService; private final UserInvoiceDataService userInvoiceDataService;
private final UserRepository userRepository; private final UserRepository userRepository;
private final UserInvoiceZugferdService userInvoiceZugferdService;
private final EmailService emailService;
public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService, public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService,
InvoiceLifecycleService invoiceLifecycleService, CustomerInvoiceService customerInvoiceService, InvoiceLifecycleService invoiceLifecycleService, CustomerInvoiceService customerInvoiceService,
InvoiceExportService invoiceExportService, InvoicePermissionService invoicePermissionService, InvoiceExportService invoiceExportService, InvoicePermissionService invoicePermissionService,
UserInvoiceDataService userInvoiceDataService, UserInvoiceDataService userInvoiceDataService,
UserRepository userRepository) { UserRepository userRepository, UserInvoiceZugferdService userInvoiceZugferdService,
EmailService emailService) {
this.customerInvoiceRepository = customerInvoiceRepository; this.customerInvoiceRepository = customerInvoiceRepository;
this.securityService = securityService; this.securityService = securityService;
this.invoiceLifecycleService = invoiceLifecycleService; this.invoiceLifecycleService = invoiceLifecycleService;
@@ -77,6 +85,8 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
this.invoicePermissionService = invoicePermissionService; this.invoicePermissionService = invoicePermissionService;
this.userInvoiceDataService = userInvoiceDataService; this.userInvoiceDataService = userInvoiceDataService;
this.userRepository = userRepository; this.userRepository = userRepository;
this.userInvoiceZugferdService = userInvoiceZugferdService;
this.emailService = emailService;
setSizeFull(); setSizeFull();
setPadding(true); setPadding(true);
@@ -201,6 +211,22 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
actions.add(sentBtn); actions.add(sentBtn);
} }
// E-Rechnung (ZUGFeRD) über das externe PDF Tool — analog zu den
// System-Rechnungen der Admin-Seite
if (type == InvoiceType.INVOICE && invoice.getPdfData() != null && invoice.getPdfData().length > 0) {
Button zugferdBtn = new Button(getTranslation("invoices.action.zugferd"),
e -> downloadZugferdZip(invoice));
zugferdBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL);
actions.add(zugferdBtn);
if (isLiveInvoice && invoicePermissionService.canMarkAsSent(currentUser)) {
Button sendBtn = new Button(getTranslation("invoices.action.sendinvoice"),
e -> sendAsEInvoice(invoice));
sendBtn.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_PRIMARY);
actions.add(sendBtn);
}
}
if (isLiveInvoice) { if (isLiveInvoice) {
if (invoicePermissionService.canCorrect(currentUser)) { if (invoicePermissionService.canCorrect(currentUser)) {
Button correctBtn = new Button(getTranslation("invoices.action.correct"), Button correctBtn = new Button(getTranslation("invoices.action.correct"),
@@ -318,6 +344,91 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle {
} }
} }
/**
* Wandelt die Rechnung über das externe PDF Tool in eine signierte
* ZUGFeRD-E-Rechnung um und bietet das ZIP (PDF/A-3, factur-x.xml,
* Prüfbericht) als Download an.
*/
private void downloadZugferdZip(CustomerInvoice invoice) {
try {
PdfToolService.SignedInvoice signed = userInvoiceZugferdService.sign(invoice);
triggerZipDownload(signed.zip(), signed.fileName());
if (signed.valid()) {
Notification.show(getTranslation("invoices.notification.zugferd.created", invoice.getInvoiceNumber()),
5000, Notification.Position.BOTTOM_END);
} else {
Notification.show(getTranslation("invoices.notification.zugferd.invalid"), 7000,
Notification.Position.MIDDLE);
}
} catch (Exception ex) {
log.error("Fehler beim Erstellen der ZUGFeRD-E-Rechnung {}: {}", invoice.getInvoiceNumber(),
ex.getMessage(), ex);
Notification.show(getTranslation("invoices.notification.zugferd.error", ex.getMessage()), 7000,
Notification.Position.MIDDLE);
}
}
/**
* Versendet die Rechnung als signierte ZUGFeRD-E-Rechnung (ZIP) per E-Mail
* an den Kunden. Besteht die Rechnung die Validierung nicht (HTTP 422 des
* PDF Tools), wird der Versand abgebrochen — wie beim Sammelversand der
* System-Rechnungen. Nach erfolgreichem Versand wird der Status auf
* "Versendet" gesetzt.
*/
private void sendAsEInvoice(CustomerInvoice invoice) {
String recipientEmail = invoice.getRecipientEmail();
if (recipientEmail == null || recipientEmail.isBlank()) {
Notification.show(getTranslation("invoices.notification.email.missing"), 6000,
Notification.Position.MIDDLE);
return;
}
try {
invoicePermissionService.requireSend(invoicePermissionService.currentUser());
PdfToolService.SignedInvoice signed = userInvoiceZugferdService.sign(invoice);
if (!signed.valid()) {
log.warn("Versand der Rechnung {} abgebrochen, ZUGFeRD-Validierung fehlgeschlagen",
invoice.getInvoiceNumber());
Notification.show(
getTranslation("invoices.notification.zugferd.invalidsend", invoice.getInvoiceNumber()), 7000,
Notification.Position.MIDDLE);
return;
}
emailService.sendEmailWithAttachment(recipientEmail,
getTranslation("invoices.mail.subject", invoice.getInvoiceNumber()),
getTranslation("invoices.mail.body", invoice.getInvoiceNumber(),
firstNonBlank(invoice.getSenderName(), "")),
signed.zip(), signed.fileName());
if (invoice.getStatus() == InvoiceStatus.ISSUED) {
invoiceLifecycleService.markAsSent(invoice.getId(),
"Als E-Rechnung per E-Mail versendet an " + recipientEmail);
}
Notification.show(
getTranslation("invoices.notification.emailsent", invoice.getInvoiceNumber(), recipientEmail),
5000, Notification.Position.BOTTOM_END);
loadInvoices();
} catch (InvoiceLifecycleException ex) {
Notification.show(ex.getMessage(), 6000, Notification.Position.MIDDLE);
} catch (Exception ex) {
log.error("Fehler beim Versand der Rechnung {} an {}: {}", invoice.getInvoiceNumber(), recipientEmail,
ex.getMessage(), ex);
Notification.show(getTranslation("invoices.notification.zugferd.error", ex.getMessage()), 7000,
Notification.Position.MIDDLE);
}
}
/** Bietet die ZIP-Bytes als Browser-Download an. */
private void triggerZipDownload(byte[] zip, String fileName) {
StreamResource resource = new StreamResource(fileName, () -> new ByteArrayInputStream(zip));
resource.setContentType("application/zip");
resource.setCacheTime(0);
StreamRegistration registration = UI.getCurrent().getSession().getResourceRegistry()
.registerResource(resource);
UI.getCurrent().getPage().open(registration.getResourceUri().toString());
}
private void markAsSent(CustomerInvoice invoice) { private void markAsSent(CustomerInvoice invoice) {
try { try {
invoicePermissionService.requireSend(invoicePermissionService.currentUser()); invoicePermissionService.requireSend(invoicePermissionService.currentUser());

View File

@@ -0,0 +1,122 @@
package de.assecutor.votianlt.service;
import de.assecutor.votianlt.model.invoices.CustomerInvoice;
import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* Wandelt Nutzer-Rechnungen (Rechnungen der Systemnutzer an ihre eigenen
* Kunden) über das externe PDF Tool in signierte ZUGFeRD-E-Rechnungen um —
* dasselbe Verfahren, das {@code AdminCreateInvoicesView} für die
* System-Rechnungen verwendet. Die EN16931-Metadaten werden vollständig aus
* der persistierten {@link CustomerInvoice} aufgebaut (Absender, Empfänger,
* Positionen), die beim Erstellen der Rechnung in {@code CreateInvoiceView}
* befüllt werden.
*/
@Service
public class UserInvoiceZugferdService {
private final PdfToolService pdfToolService;
public UserInvoiceZugferdService(PdfToolService pdfToolService) {
this.pdfToolService = pdfToolService;
}
/**
* Signiert die Rechnung über das PDF Tool. Wirft eine
* {@link IllegalStateException} mit verständlicher Meldung, wenn der
* Rechnung Daten fehlen, die für eine EN16931-E-Rechnung zwingend sind.
*/
public PdfToolService.SignedInvoice sign(CustomerInvoice invoice) throws Exception {
if (invoice.getPdfData() == null || invoice.getPdfData().length == 0) {
throw new IllegalStateException("Für diese Rechnung ist kein PDF gespeichert.");
}
return pdfToolService.signInvoice(invoice.getPdfData(), buildMetadata(invoice));
}
private PdfToolService.InvoiceMetadata buildMetadata(CustomerInvoice invoice) {
if (isBlank(invoice.getSenderName()) || isBlank(invoice.getRecipientName())) {
throw new IllegalStateException(
"Absender- oder Empfängerdaten fehlen — diese Rechnung wurde vor der E-Rechnungs-Umstellung "
+ "erstellt und kann nicht als E-Rechnung aufbereitet werden.");
}
PdfToolService.Party sender = new PdfToolService.Party(invoice.getSenderName(),
safe(invoice.getSenderAddress()), safe(invoice.getSenderPostcode()), safe(invoice.getSenderCity()),
firstNonBlank(invoice.getSenderCountry(), "DE"), blankToNull(invoice.getSenderVatId()),
safe(invoice.getSenderEmail()), safe(invoice.getSenderPhone()));
PdfToolService.Party recipient = new PdfToolService.Party(
firstNonBlank(invoice.getRecipientCompany(), invoice.getRecipientName()),
safe(invoice.getRecipientAddress()), safe(invoice.getRecipientPostcode()),
safe(invoice.getRecipientCity()), firstNonBlank(invoice.getRecipientCountry(), "DE"),
blankToNull(invoice.getRecipientVatId()), safe(invoice.getRecipientEmail()), null);
List<PdfToolService.LineItem> items = buildLineItems(invoice);
if (items.isEmpty()) {
throw new IllegalStateException("Die Rechnung enthält keine Positionen mit Betrag.");
}
LocalDate issueDate = invoice.getInvoiceDate() != null ? invoice.getInvoiceDate() : LocalDate.now();
LocalDate deliveryDate = invoice.getDeliveryDate() != null ? invoice.getDeliveryDate() : issueDate;
LocalDate dueDate = invoice.getPaymentDueDate() != null ? invoice.getPaymentDueDate() : issueDate.plusDays(14);
return new PdfToolService.InvoiceMetadata(invoice.getInvoiceNumber(), issueDate, deliveryDate, dueDate, "EUR",
safe(invoice.getPaymentTerms()), null, blankToNull(invoice.getIban()), blankToNull(invoice.getBic()),
sender, recipient, items);
}
private List<PdfToolService.LineItem> buildLineItems(CustomerInvoice invoice) {
BigDecimal vatPercent = invoice.getVatRate() != null
? invoice.getVatRate().multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP)
: new BigDecimal("19");
List<PdfToolService.LineItem> items = new ArrayList<>();
if (invoice.getItems() != null) {
for (CustomerInvoiceItem item : invoice.getItems()) {
if (item == null || item.getQuantity() == null || item.getUnitPrice() == null) {
continue;
}
BigDecimal itemVatPercent = item.getVatRate() != null
? item.getVatRate().multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP)
: vatPercent;
items.add(new PdfToolService.LineItem(safe(item.getDescription()), item.getQuantity(),
item.getUnitPrice(), itemVatPercent));
}
}
// Ältere Rechnungen ohne Positionsliste: eine Sammelposition aus dem
// Rechnungs-Netto, damit sie trotzdem als E-Rechnung versendbar bleiben.
if (items.isEmpty() && invoice.getNetAmount() != null && invoice.getNetAmount().signum() > 0) {
items.add(new PdfToolService.LineItem(firstNonBlank(invoice.getDescription(), "Leistung gemäß Rechnung"),
BigDecimal.ONE, invoice.getNetAmount(), vatPercent));
}
return items;
}
private String safe(String value) {
return value != null ? value : "";
}
private String blankToNull(String value) {
return value == null || value.isBlank() ? null : value;
}
private String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return "";
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}

View File

@@ -1156,3 +1156,19 @@ admincreateinvoices.notification.zugferd.nopositions=Keine abrechenbaren Positio
admincreateinvoices.notification.zugferd.batcherror=E-Rechnung {0} konnte nicht erstellt werden, es wurden keine Rechnungen versendet: {1} admincreateinvoices.notification.zugferd.batcherror=E-Rechnung {0} konnte nicht erstellt werden, es wurden keine Rechnungen versendet: {1}
admincreateinvoices.notification.zugferd.invalidsend=Validierung fehlgeschlagen für: {0}. Es wurden keine Rechnungen versendet. admincreateinvoices.notification.zugferd.invalidsend=Validierung fehlgeschlagen für: {0}. Es wurden keine Rechnungen versendet.
admincreateinvoices.notification.zip.combined.error=Fehler beim Erstellen der Sammel-ZIP-Datei: {0} admincreateinvoices.notification.zip.combined.error=Fehler beim Erstellen der Sammel-ZIP-Datei: {0}
# E-Rechnung (ZUGFeRD) für Nutzer-Rechnungen an ihre Kunden
invoices.action.zugferd=E-Rechnung (ZIP)
invoices.action.sendinvoice=Per E-Mail senden
invoices.notification.zugferd.created=E-Rechnung {0} erstellt
invoices.notification.zugferd.invalid=E-Rechnung erstellt, aber die Validierung ist fehlgeschlagen. Details im Prüfbericht in der ZIP-Datei.
invoices.notification.zugferd.invalidsend=Versand abgebrochen: Die Validierung der E-Rechnung {0} ist fehlgeschlagen.
invoices.notification.zugferd.error=Fehler bei der E-Rechnung: {0}
invoices.notification.email.missing=Für den Rechnungsempfänger ist keine E-Mail-Adresse hinterlegt.
invoices.notification.emailsent=Rechnung {0} wurde als E-Rechnung an {1} versendet.
invoices.mail.subject=Ihre Rechnung {0}
invoices.mail.body=Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung {0} als E-Rechnung (ZIP mit PDF und Rechnungsdaten).\n\nMit freundlichen Grüßen\n{1}
profile.billing.ustid=USt-IdNr.
profile.billing.taxnumber=Steuernummer
profile.billing.bankname=Bank
profile.billing.iban=IBAN

View File

@@ -986,3 +986,19 @@ admincreateinvoices.notification.zugferd.nopositions=E-arve jaoks puuduvad arvel
admincreateinvoices.notification.zugferd.batcherror=E-arvet {0} ei õnnestunud luua, ühtegi arvet ei saadetud: {1} admincreateinvoices.notification.zugferd.batcherror=E-arvet {0} ei õnnestunud luua, ühtegi arvet ei saadetud: {1}
admincreateinvoices.notification.zugferd.invalidsend=Valideerimine ebaõnnestus: {0}. Ühtegi arvet ei saadetud. admincreateinvoices.notification.zugferd.invalidsend=Valideerimine ebaõnnestus: {0}. Ühtegi arvet ei saadetud.
admincreateinvoices.notification.zip.combined.error=Viga koond-ZIP-faili loomisel: {0} admincreateinvoices.notification.zip.combined.error=Viga koond-ZIP-faili loomisel: {0}
# E-arve (ZUGFeRD) kasutajate arvetele nende klientidele
invoices.action.zugferd=E-arve (ZIP)
invoices.action.sendinvoice=Saada e-postiga
invoices.notification.zugferd.created=E-arve {0} loodud
invoices.notification.zugferd.invalid=E-arve loodi, kuid valideerimine ebaõnnestus. Vaadake kontrolliaruannet ZIP-failis.
invoices.notification.zugferd.invalidsend=Saatmine katkestati: e-arve {0} valideerimine ebaõnnestus.
invoices.notification.zugferd.error=E-arve viga: {0}
invoices.notification.email.missing=Arve saajale pole e-posti aadressi salvestatud.
invoices.notification.emailsent=Arve {0} saadeti e-arvena aadressile {1}.
invoices.mail.subject=Teie arve {0}
invoices.mail.body=Lugupeetud klient,\n\nmanuses on Teie arve {0} e-arvena (ZIP koos PDF-i ja arveandmetega).\n\nLugupidamisega\n{1}
profile.billing.ustid=KMKR nr
profile.billing.taxnumber=Maksunumber
profile.billing.bankname=Pank
profile.billing.iban=IBAN

View File

@@ -1156,3 +1156,19 @@ admincreateinvoices.notification.zugferd.nopositions=No billable positions avail
admincreateinvoices.notification.zugferd.batcherror=E-invoice {0} could not be created, no invoices were sent: {1} admincreateinvoices.notification.zugferd.batcherror=E-invoice {0} could not be created, no invoices were sent: {1}
admincreateinvoices.notification.zugferd.invalidsend=Validation failed for: {0}. No invoices were sent. admincreateinvoices.notification.zugferd.invalidsend=Validation failed for: {0}. No invoices were sent.
admincreateinvoices.notification.zip.combined.error=Error creating the combined ZIP file: {0} admincreateinvoices.notification.zip.combined.error=Error creating the combined ZIP file: {0}
# E-invoice (ZUGFeRD) for user invoices to their customers
invoices.action.zugferd=E-invoice (ZIP)
invoices.action.sendinvoice=Send by email
invoices.notification.zugferd.created=E-invoice {0} created
invoices.notification.zugferd.invalid=E-invoice created, but validation failed. See the validation report in the ZIP file.
invoices.notification.zugferd.invalidsend=Sending aborted: validation of e-invoice {0} failed.
invoices.notification.zugferd.error=E-invoice error: {0}
invoices.notification.email.missing=No email address is stored for the invoice recipient.
invoices.notification.emailsent=Invoice {0} was sent as an e-invoice to {1}.
invoices.mail.subject=Your invoice {0}
invoices.mail.body=Dear Sir or Madam,\n\nPlease find attached your invoice {0} as an e-invoice (ZIP with PDF and invoice data).\n\nKind regards\n{1}
profile.billing.ustid=VAT ID
profile.billing.taxnumber=Tax number
profile.billing.bankname=Bank
profile.billing.iban=IBAN

View File

@@ -1087,3 +1087,19 @@ admincreateinvoices.notification.zugferd.nopositions=No hay posiciones facturabl
admincreateinvoices.notification.zugferd.batcherror=No se pudo crear la factura electrónica {0}, no se envió ninguna factura: {1} admincreateinvoices.notification.zugferd.batcherror=No se pudo crear la factura electrónica {0}, no se envió ninguna factura: {1}
admincreateinvoices.notification.zugferd.invalidsend=La validación falló para: {0}. No se envió ninguna factura. admincreateinvoices.notification.zugferd.invalidsend=La validación falló para: {0}. No se envió ninguna factura.
admincreateinvoices.notification.zip.combined.error=Error al crear el archivo ZIP combinado: {0} admincreateinvoices.notification.zip.combined.error=Error al crear el archivo ZIP combinado: {0}
# Factura electrónica (ZUGFeRD) para facturas de usuarios a sus clientes
invoices.action.zugferd=Factura electrónica (ZIP)
invoices.action.sendinvoice=Enviar por correo
invoices.notification.zugferd.created=Factura electrónica {0} creada
invoices.notification.zugferd.invalid=Factura electrónica creada, pero la validación ha fallado. Consulte el informe de validación en el archivo ZIP.
invoices.notification.zugferd.invalidsend=Envío cancelado: la validación de la factura electrónica {0} ha fallado.
invoices.notification.zugferd.error=Error en la factura electrónica: {0}
invoices.notification.email.missing=No hay una dirección de correo electrónico registrada para el destinatario de la factura.
invoices.notification.emailsent=La factura {0} se envió como factura electrónica a {1}.
invoices.mail.subject=Su factura {0}
invoices.mail.body=Estimados señores:\n\nAdjunto encontrará su factura {0} como factura electrónica (ZIP con PDF y datos de la factura).\n\nAtentamente\n{1}
profile.billing.ustid=NIF-IVA
profile.billing.taxnumber=Número fiscal
profile.billing.bankname=Banco
profile.billing.iban=IBAN

View File

@@ -1087,3 +1087,19 @@ admincreateinvoices.notification.zugferd.nopositions=Aucune position facturable
admincreateinvoices.notification.zugferd.batcherror=La facture électronique {0} n''a pas pu être créée, aucune facture n''a été envoyée : {1} admincreateinvoices.notification.zugferd.batcherror=La facture électronique {0} n''a pas pu être créée, aucune facture n''a été envoyée : {1}
admincreateinvoices.notification.zugferd.invalidsend=La validation a échoué pour : {0}. Aucune facture n''a été envoyée. admincreateinvoices.notification.zugferd.invalidsend=La validation a échoué pour : {0}. Aucune facture n''a été envoyée.
admincreateinvoices.notification.zip.combined.error=Erreur lors de la création du fichier ZIP combiné : {0} admincreateinvoices.notification.zip.combined.error=Erreur lors de la création du fichier ZIP combiné : {0}
# Facture électronique (ZUGFeRD) pour les factures des utilisateurs à leurs clients
invoices.action.zugferd=Facture électronique (ZIP)
invoices.action.sendinvoice=Envoyer par e-mail
invoices.notification.zugferd.created=Facture électronique {0} créée
invoices.notification.zugferd.invalid=Facture électronique créée, mais la validation a échoué. Voir le rapport de validation dans le fichier ZIP.
invoices.notification.zugferd.invalidsend=Envoi annulé : la validation de la facture électronique {0} a échoué.
invoices.notification.zugferd.error=Erreur de facture électronique : {0}
invoices.notification.email.missing=Aucune adresse e-mail n'est enregistrée pour le destinataire de la facture.
invoices.notification.emailsent=La facture {0} a été envoyée en tant que facture électronique à {1}.
invoices.mail.subject=Votre facture {0}
invoices.mail.body=Madame, Monsieur,\n\nVeuillez trouver ci-joint votre facture {0} sous forme de facture électronique (ZIP avec PDF et données de facturation).\n\nCordialement\n{1}
profile.billing.ustid=N° TVA intracommunautaire
profile.billing.taxnumber=Numéro fiscal
profile.billing.bankname=Banque
profile.billing.iban=IBAN

View File

@@ -1089,3 +1089,19 @@ admincreateinvoices.notification.zugferd.nopositions=E. sąskaitai nėra apmokes
admincreateinvoices.notification.zugferd.batcherror=Nepavyko sukurti e. sąskaitos {0}, sąskaitos nebuvo išsiųstos: {1} admincreateinvoices.notification.zugferd.batcherror=Nepavyko sukurti e. sąskaitos {0}, sąskaitos nebuvo išsiųstos: {1}
admincreateinvoices.notification.zugferd.invalidsend=Patikrinimas nepavyko: {0}. Sąskaitos nebuvo išsiųstos. admincreateinvoices.notification.zugferd.invalidsend=Patikrinimas nepavyko: {0}. Sąskaitos nebuvo išsiųstos.
admincreateinvoices.notification.zip.combined.error=Klaida kuriant bendrą ZIP failą: {0} admincreateinvoices.notification.zip.combined.error=Klaida kuriant bendrą ZIP failą: {0}
# E. sąskaita (ZUGFeRD) naudotojų sąskaitoms jų klientams
invoices.action.zugferd=E. sąskaita (ZIP)
invoices.action.sendinvoice=Siųsti el. paštu
invoices.notification.zugferd.created=E. sąskaita {0} sukurta
invoices.notification.zugferd.invalid=E. sąskaita sukurta, bet patikra nepavyko. Žr. patikros ataskaitą ZIP faile.
invoices.notification.zugferd.invalidsend=Siuntimas nutrauktas: e. sąskaitos {0} patikra nepavyko.
invoices.notification.zugferd.error=E. sąskaitos klaida: {0}
invoices.notification.email.missing=Sąskaitos gavėjui nėra išsaugoto el. pašto adreso.
invoices.notification.emailsent=Sąskaita {0} išsiųsta kaip e. sąskaita adresu {1}.
invoices.mail.subject=Jūsų sąskaita {0}
invoices.mail.body=Gerbiamieji,\n\npridedame Jūsų sąskaitą {0} kaip e. sąskaitą (ZIP su PDF ir sąskaitos duomenimis).\n\nPagarbiai\n{1}
profile.billing.ustid=PVM mokėtojo kodas
profile.billing.taxnumber=Mokesčių mokėtojo numeris
profile.billing.bankname=Bankas
profile.billing.iban=IBAN

View File

@@ -1087,3 +1087,19 @@ admincreateinvoices.notification.zugferd.nopositions=E-rēķinam nav pieejamu no
admincreateinvoices.notification.zugferd.batcherror=Neizdevās izveidot e-rēķinu {0}, rēķini netika nosūtīti: {1} admincreateinvoices.notification.zugferd.batcherror=Neizdevās izveidot e-rēķinu {0}, rēķini netika nosūtīti: {1}
admincreateinvoices.notification.zugferd.invalidsend=Validācija neizdevās: {0}. Rēķini netika nosūtīti. admincreateinvoices.notification.zugferd.invalidsend=Validācija neizdevās: {0}. Rēķini netika nosūtīti.
admincreateinvoices.notification.zip.combined.error=Kļūda, veidojot apvienoto ZIP failu: {0} admincreateinvoices.notification.zip.combined.error=Kļūda, veidojot apvienoto ZIP failu: {0}
# E-rēķins (ZUGFeRD) lietotāju rēķiniem viņu klientiem
invoices.action.zugferd=E-rēķins (ZIP)
invoices.action.sendinvoice=Sūtīt pa e-pastu
invoices.notification.zugferd.created=E-rēķins {0} izveidots
invoices.notification.zugferd.invalid=E-rēķins izveidots, bet validācija neizdevās. Skatiet pārbaudes atskaiti ZIP failā.
invoices.notification.zugferd.invalidsend=Sūtīšana pārtraukta: e-rēķina {0} validācija neizdevās.
invoices.notification.zugferd.error=E-rēķina kļūda: {0}
invoices.notification.email.missing=Rēķina saņēmējam nav saglabāta e-pasta adrese.
invoices.notification.emailsent=Rēķins {0} nosūtīts kā e-rēķins uz {1}.
invoices.mail.subject=Jūsu rēķins {0}
invoices.mail.body=Cienījamie klienti!\n\nPielikumā Jūsu rēķins {0} kā e-rēķins (ZIP ar PDF un rēķina datiem).\n\nAr cieņu\n{1}
profile.billing.ustid=PVN numurs
profile.billing.taxnumber=Nodokļu maksātāja numurs
profile.billing.bankname=Banka
profile.billing.iban=IBAN

View File

@@ -1087,3 +1087,19 @@ admincreateinvoices.notification.zugferd.nopositions=Brak pozycji rozliczeniowyc
admincreateinvoices.notification.zugferd.batcherror=Nie udało się utworzyć e-faktury {0}, żadne faktury nie zostały wysłane: {1} admincreateinvoices.notification.zugferd.batcherror=Nie udało się utworzyć e-faktury {0}, żadne faktury nie zostały wysłane: {1}
admincreateinvoices.notification.zugferd.invalidsend=Walidacja nie powiodła się dla: {0}. Żadne faktury nie zostały wysłane. admincreateinvoices.notification.zugferd.invalidsend=Walidacja nie powiodła się dla: {0}. Żadne faktury nie zostały wysłane.
admincreateinvoices.notification.zip.combined.error=Błąd podczas tworzenia zbiorczego pliku ZIP: {0} admincreateinvoices.notification.zip.combined.error=Błąd podczas tworzenia zbiorczego pliku ZIP: {0}
# E-faktura (ZUGFeRD) dla faktur użytkowników dla ich klientów
invoices.action.zugferd=E-faktura (ZIP)
invoices.action.sendinvoice=Wyślij e-mailem
invoices.notification.zugferd.created=E-faktura {0} utworzona
invoices.notification.zugferd.invalid=E-faktura została utworzona, ale walidacja nie powiodła się. Zobacz raport walidacji w pliku ZIP.
invoices.notification.zugferd.invalidsend=Wysyłka przerwana: walidacja e-faktury {0} nie powiodła się.
invoices.notification.zugferd.error=Błąd e-faktury: {0}
invoices.notification.email.missing=Dla odbiorcy faktury nie zapisano adresu e-mail.
invoices.notification.emailsent=Faktura {0} została wysłana jako e-faktura na adres {1}.
invoices.mail.subject=Państwa faktura {0}
invoices.mail.body=Szanowni Państwo,\n\nw załączeniu przesyłamy fakturę {0} jako e-fakturę (ZIP z plikiem PDF i danymi faktury).\n\nZ poważaniem\n{1}
profile.billing.ustid=NIP UE
profile.billing.taxnumber=Numer podatkowy
profile.billing.bankname=Bank
profile.billing.iban=IBAN

View File

@@ -1087,3 +1087,19 @@ admincreateinvoices.notification.zugferd.nopositions=Нет позиций дл
admincreateinvoices.notification.zugferd.batcherror=Не удалось создать электронный счёт {0}, счета не были отправлены: {1} admincreateinvoices.notification.zugferd.batcherror=Не удалось создать электронный счёт {0}, счета не были отправлены: {1}
admincreateinvoices.notification.zugferd.invalidsend=Проверка не пройдена для: {0}. Счета не были отправлены. admincreateinvoices.notification.zugferd.invalidsend=Проверка не пройдена для: {0}. Счета не были отправлены.
admincreateinvoices.notification.zip.combined.error=Ошибка при создании общего ZIP-файла: {0} admincreateinvoices.notification.zip.combined.error=Ошибка при создании общего ZIP-файла: {0}
# Электронный счёт (ZUGFeRD) для счетов пользователей их клиентам
invoices.action.zugferd=Электронный счёт (ZIP)
invoices.action.sendinvoice=Отправить по эл. почте
invoices.notification.zugferd.created=Электронный счёт {0} создан
invoices.notification.zugferd.invalid=Электронный счёт создан, но проверка не пройдена. См. отчёт о проверке в ZIP-файле.
invoices.notification.zugferd.invalidsend=Отправка отменена: проверка электронного счёта {0} не пройдена.
invoices.notification.zugferd.error=Ошибка электронного счёта: {0}
invoices.notification.email.missing=Для получателя счёта не сохранён адрес электронной почты.
invoices.notification.emailsent=Счёт {0} отправлен как электронный счёт на {1}.
invoices.mail.subject=Ваш счёт {0}
invoices.mail.body=Уважаемые дамы и господа!\n\nВо вложении Ваш счёт {0} в виде электронного счёта (ZIP с PDF и данными счёта).\n\nС уважением\n{1}
profile.billing.ustid=Идентификатор плательщика НДС
profile.billing.taxnumber=Налоговый номер
profile.billing.bankname=Банк
profile.billing.iban=IBAN

View File

@@ -1087,3 +1087,19 @@ admincreateinvoices.notification.zugferd.nopositions=E-fatura için faturalandı
admincreateinvoices.notification.zugferd.batcherror=E-fatura {0} oluşturulamadı, hiçbir fatura gönderilmedi: {1} admincreateinvoices.notification.zugferd.batcherror=E-fatura {0} oluşturulamadı, hiçbir fatura gönderilmedi: {1}
admincreateinvoices.notification.zugferd.invalidsend=Doğrulama başarısız oldu: {0}. Hiçbir fatura gönderilmedi. admincreateinvoices.notification.zugferd.invalidsend=Doğrulama başarısız oldu: {0}. Hiçbir fatura gönderilmedi.
admincreateinvoices.notification.zip.combined.error=Toplu ZIP dosyası oluşturulurken hata: {0} admincreateinvoices.notification.zip.combined.error=Toplu ZIP dosyası oluşturulurken hata: {0}
# Kullanıcıların müşterilerine kestiği faturalar için e-fatura (ZUGFeRD)
invoices.action.zugferd=E-fatura (ZIP)
invoices.action.sendinvoice=E-posta ile gönder
invoices.notification.zugferd.created=E-fatura {0} oluşturuldu
invoices.notification.zugferd.invalid=E-fatura oluşturuldu, ancak doğrulama başarısız oldu. ZIP dosyasındaki doğrulama raporuna bakın.
invoices.notification.zugferd.invalidsend=Gönderim iptal edildi: {0} e-faturasının doğrulaması başarısız oldu.
invoices.notification.zugferd.error=E-fatura hatası: {0}
invoices.notification.email.missing=Fatura alıcısı için kayıtlı bir e-posta adresi yok.
invoices.notification.emailsent={0} numaralı fatura e-fatura olarak {1} adresine gönderildi.
invoices.mail.subject=Faturanız {0}
invoices.mail.body=Sayın yetkili,\n\nekte {0} numaralı faturanızı e-fatura olarak (PDF ve fatura verilerini içeren ZIP) bulabilirsiniz.\n\nSaygılarımızla\n{1}
profile.billing.ustid=KDV kimlik no
profile.billing.taxnumber=Vergi numarası
profile.billing.bankname=Banka
profile.billing.iban=IBAN

View File

@@ -0,0 +1,161 @@
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.ArgumentMatchers.eq;
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.CustomerInvoiceItem;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
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.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class UserInvoiceZugferdServiceTest {
@Mock
private PdfToolService pdfToolService;
@Captor
private ArgumentCaptor<PdfToolService.InvoiceMetadata> metadataCaptor;
private UserInvoiceZugferdService service;
@BeforeEach
void setUp() {
service = new UserInvoiceZugferdService(pdfToolService);
}
private CustomerInvoice fullyPopulatedInvoice() {
CustomerInvoice invoice = new CustomerInvoice();
invoice.setInvoiceNumber("RE-2026-0001");
invoice.setInvoiceDate(LocalDate.of(2026, 7, 14));
invoice.setDeliveryDate(LocalDate.of(2026, 7, 10));
invoice.setPaymentDueDate(LocalDate.of(2026, 7, 28));
invoice.setSenderName("Spedition Muster GmbH");
invoice.setSenderAddress("Musterstraße 1");
invoice.setSenderPostcode("20095");
invoice.setSenderCity("Hamburg");
invoice.setSenderVatId("DE123456789");
invoice.setSenderEmail("info@muster.de");
invoice.setSenderPhone("040 123456");
invoice.setRecipientCompany("Kunde AG");
invoice.setRecipientName("Erika Beispiel");
invoice.setRecipientAddress("Beispielweg 2");
invoice.setRecipientPostcode("10115");
invoice.setRecipientCity("Berlin");
invoice.setRecipientEmail("rechnung@kunde-ag.de");
invoice.setVatRate(new BigDecimal("0.19"));
invoice.setNetAmount(new BigDecimal("100.00"));
invoice.setIban("DE02120300000000202051");
invoice.setPaymentTerms("Zahlbar innerhalb von 14 Tagen");
invoice.setItems(List.of(
new CustomerInvoiceItem(new BigDecimal("12"), "km", "Transport", new BigDecimal("5.00"),
new BigDecimal("0.19")),
new CustomerInvoiceItem(BigDecimal.ONE, "pauschal", "Grundpreis", new BigDecimal("40.00"),
new BigDecimal("0.19"))));
invoice.setPdfData(new byte[] { 1, 2, 3 });
return invoice;
}
@Test
void signBuildsMetadataFromInvoice() throws Exception {
CustomerInvoice invoice = fullyPopulatedInvoice();
PdfToolService.SignedInvoice signed = new PdfToolService.SignedInvoice(new byte[] { 9 }, "re.zip", true);
when(pdfToolService.signInvoice(eq(invoice.getPdfData()), any())).thenReturn(signed);
PdfToolService.SignedInvoice result = service.sign(invoice);
assertThat(result).isSameAs(signed);
verify(pdfToolService).signInvoice(eq(invoice.getPdfData()), metadataCaptor.capture());
PdfToolService.InvoiceMetadata metadata = metadataCaptor.getValue();
assertThat(metadata.invoiceNumber()).isEqualTo("RE-2026-0001");
assertThat(metadata.issueDate()).isEqualTo(LocalDate.of(2026, 7, 14));
assertThat(metadata.deliveryDate()).isEqualTo(LocalDate.of(2026, 7, 10));
assertThat(metadata.dueDate()).isEqualTo(LocalDate.of(2026, 7, 28));
assertThat(metadata.currency()).isEqualTo("EUR");
assertThat(metadata.iban()).isEqualTo("DE02120300000000202051");
assertThat(metadata.sender().name()).isEqualTo("Spedition Muster GmbH");
assertThat(metadata.sender().vatId()).isEqualTo("DE123456789");
assertThat(metadata.recipient().name()).isEqualTo("Kunde AG");
assertThat(metadata.recipient().zip()).isEqualTo("10115");
assertThat(metadata.recipient().email()).isEqualTo("rechnung@kunde-ag.de");
assertThat(metadata.items()).hasSize(2);
assertThat(metadata.items().get(0).description()).isEqualTo("Transport");
assertThat(metadata.items().get(0).quantity()).isEqualByComparingTo("12");
assertThat(metadata.items().get(0).unitPriceNet()).isEqualByComparingTo("5.00");
assertThat(metadata.items().get(0).vatPercent()).isEqualByComparingTo("19");
}
@Test
void signFallsBackToSingleLineForLegacyInvoiceWithoutItems() throws Exception {
CustomerInvoice invoice = fullyPopulatedInvoice();
invoice.setItems(null);
invoice.setDescription("Kurierfahrt HamburgBerlin");
when(pdfToolService.signInvoice(any(), any()))
.thenReturn(new PdfToolService.SignedInvoice(new byte[] { 9 }, "re.zip", true));
service.sign(invoice);
verify(pdfToolService).signInvoice(any(), metadataCaptor.capture());
List<PdfToolService.LineItem> items = metadataCaptor.getValue().items();
assertThat(items).hasSize(1);
assertThat(items.get(0).description()).isEqualTo("Kurierfahrt HamburgBerlin");
assertThat(items.get(0).quantity()).isEqualByComparingTo("1");
assertThat(items.get(0).unitPriceNet()).isEqualByComparingTo("100.00");
}
@Test
void signUsesDefaultDatesWhenMissing() throws Exception {
CustomerInvoice invoice = fullyPopulatedInvoice();
invoice.setDeliveryDate(null);
invoice.setPaymentDueDate(null);
when(pdfToolService.signInvoice(any(), any()))
.thenReturn(new PdfToolService.SignedInvoice(new byte[] { 9 }, "re.zip", true));
service.sign(invoice);
verify(pdfToolService).signInvoice(any(), metadataCaptor.capture());
PdfToolService.InvoiceMetadata metadata = metadataCaptor.getValue();
assertThat(metadata.deliveryDate()).isEqualTo(invoice.getInvoiceDate());
assertThat(metadata.dueDate()).isEqualTo(invoice.getInvoiceDate().plusDays(14));
}
@Test
void signRejectsInvoiceWithoutPdf() {
CustomerInvoice invoice = fullyPopulatedInvoice();
invoice.setPdfData(null);
assertThatThrownBy(() -> service.sign(invoice)).isInstanceOf(IllegalStateException.class)
.hasMessageContaining("kein PDF");
}
@Test
void signRejectsInvoiceWithoutParties() {
CustomerInvoice invoice = fullyPopulatedInvoice();
invoice.setSenderName(null);
assertThatThrownBy(() -> service.sign(invoice)).isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Absender");
}
@Test
void signRejectsInvoiceWithoutAnyAmount() {
CustomerInvoice invoice = fullyPopulatedInvoice();
invoice.setItems(null);
invoice.setNetAmount(null);
assertThatThrownBy(() -> service.sign(invoice)).isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Positionen");
}
}