diff --git a/backend/src/main/java/de/assecutor/votianlt/model/invoices/CustomerInvoice.java b/backend/src/main/java/de/assecutor/votianlt/model/invoices/CustomerInvoice.java index f919d53..11283dc 100644 --- a/backend/src/main/java/de/assecutor/votianlt/model/invoices/CustomerInvoice.java +++ b/backend/src/main/java/de/assecutor/votianlt/model/invoices/CustomerInvoice.java @@ -66,6 +66,7 @@ public class CustomerInvoice { private String recipientCity; private String recipientCountry; private String recipientVatId; // USt-IdNr. des Empfängers (falls vorhanden) + private String recipientEmail; // E-Mail für den Rechnungsversand // Rechnungsdetails private String description; // Beschreibung der Leistung @@ -274,6 +275,14 @@ public class CustomerInvoice { this.recipientVatId = recipientVatId; } + public String getRecipientEmail() { + return recipientEmail; + } + + public void setRecipientEmail(String recipientEmail) { + this.recipientEmail = recipientEmail; + } + public String getDescription() { return description; } diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java index 68208f9..4f2d9c7 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/CreateInvoiceView.java @@ -24,6 +24,7 @@ import de.assecutor.votianlt.model.Service; import de.assecutor.votianlt.model.User; import de.assecutor.votianlt.model.InvoiceTemplate; 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.service.CustomerService; import de.assecutor.votianlt.pages.service.UserInvoiceDataService; @@ -586,6 +587,14 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter invoice.setVatAmount(vatAmount); 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); // 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 buildInvoiceItems(BigDecimal vatRate) { + List 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. */ diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java index f5833d1..6d421e4 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/EditProfileView.java @@ -372,6 +372,24 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle billingHeaderLayout.setAlignItems(FlexComponent.Alignment.BASELINE); 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) final HorizontalLayout mainLayout = new HorizontalLayout(); mainLayout.setWidthFull(); diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/InvoicesView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/InvoicesView.java index d173d45..5350aea 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/InvoicesView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/InvoicesView.java @@ -33,11 +33,15 @@ 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.EmailService; 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 de.assecutor.votianlt.service.PdfToolService; +import de.assecutor.votianlt.service.UserInvoiceZugferdService; import jakarta.annotation.security.RolesAllowed; +import lombok.extern.slf4j.Slf4j; import java.io.ByteArrayInputStream; 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) @RolesAllowed({ "USER", "ADMIN" }) +@Slf4j public class InvoicesView extends VerticalLayout implements HasDynamicTitle { 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 UserInvoiceDataService userInvoiceDataService; private final UserRepository userRepository; + private final UserInvoiceZugferdService userInvoiceZugferdService; + private final EmailService emailService; public InvoicesView(CustomerInvoiceRepository customerInvoiceRepository, SecurityService securityService, InvoiceLifecycleService invoiceLifecycleService, CustomerInvoiceService customerInvoiceService, InvoiceExportService invoiceExportService, InvoicePermissionService invoicePermissionService, UserInvoiceDataService userInvoiceDataService, - UserRepository userRepository) { + UserRepository userRepository, UserInvoiceZugferdService userInvoiceZugferdService, + EmailService emailService) { this.customerInvoiceRepository = customerInvoiceRepository; this.securityService = securityService; this.invoiceLifecycleService = invoiceLifecycleService; @@ -77,6 +85,8 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle { this.invoicePermissionService = invoicePermissionService; this.userInvoiceDataService = userInvoiceDataService; this.userRepository = userRepository; + this.userInvoiceZugferdService = userInvoiceZugferdService; + this.emailService = emailService; setSizeFull(); setPadding(true); @@ -201,6 +211,22 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle { 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 (invoicePermissionService.canCorrect(currentUser)) { 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) { try { invoicePermissionService.requireSend(invoicePermissionService.currentUser()); diff --git a/backend/src/main/java/de/assecutor/votianlt/service/UserInvoiceZugferdService.java b/backend/src/main/java/de/assecutor/votianlt/service/UserInvoiceZugferdService.java new file mode 100644 index 0000000..b3fba36 --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/service/UserInvoiceZugferdService.java @@ -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 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 buildLineItems(CustomerInvoice invoice) { + BigDecimal vatPercent = invoice.getVatRate() != null + ? invoice.getVatRate().multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP) + : new BigDecimal("19"); + + List 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(); + } +} diff --git a/backend/src/main/resources/messages_de.properties b/backend/src/main/resources/messages_de.properties index af7f288..9fc4edf 100644 --- a/backend/src/main/resources/messages_de.properties +++ b/backend/src/main/resources/messages_de.properties @@ -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.invalidsend=Validierung fehlgeschlagen für: {0}. Es wurden keine Rechnungen versendet. 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 diff --git a/backend/src/main/resources/messages_ee.properties b/backend/src/main/resources/messages_ee.properties index 01ba996..0d81fac 100644 --- a/backend/src/main/resources/messages_ee.properties +++ b/backend/src/main/resources/messages_ee.properties @@ -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.invalidsend=Valideerimine ebaõnnestus: {0}. Ühtegi arvet ei saadetud. 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 diff --git a/backend/src/main/resources/messages_en.properties b/backend/src/main/resources/messages_en.properties index 78c440a..12cc5cd 100644 --- a/backend/src/main/resources/messages_en.properties +++ b/backend/src/main/resources/messages_en.properties @@ -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.invalidsend=Validation failed for: {0}. No invoices were sent. 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 diff --git a/backend/src/main/resources/messages_es.properties b/backend/src/main/resources/messages_es.properties index 5356668..ce5f166 100644 --- a/backend/src/main/resources/messages_es.properties +++ b/backend/src/main/resources/messages_es.properties @@ -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.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} + +# 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 diff --git a/backend/src/main/resources/messages_fr.properties b/backend/src/main/resources/messages_fr.properties index 2f0ca51..e156c13 100644 --- a/backend/src/main/resources/messages_fr.properties +++ b/backend/src/main/resources/messages_fr.properties @@ -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.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} + +# 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 diff --git a/backend/src/main/resources/messages_lt.properties b/backend/src/main/resources/messages_lt.properties index cbde87a..dc43c34 100644 --- a/backend/src/main/resources/messages_lt.properties +++ b/backend/src/main/resources/messages_lt.properties @@ -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.invalidsend=Patikrinimas nepavyko: {0}. Sąskaitos nebuvo išsiųstos. 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 diff --git a/backend/src/main/resources/messages_lv.properties b/backend/src/main/resources/messages_lv.properties index 6ecd6d0..c997e81 100644 --- a/backend/src/main/resources/messages_lv.properties +++ b/backend/src/main/resources/messages_lv.properties @@ -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.invalidsend=Validācija neizdevās: {0}. Rēķini netika nosūtīti. 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 diff --git a/backend/src/main/resources/messages_pl.properties b/backend/src/main/resources/messages_pl.properties index a3490b9..172bdec 100644 --- a/backend/src/main/resources/messages_pl.properties +++ b/backend/src/main/resources/messages_pl.properties @@ -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.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} + +# 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 diff --git a/backend/src/main/resources/messages_ru.properties b/backend/src/main/resources/messages_ru.properties index 933da21..6980975 100644 --- a/backend/src/main/resources/messages_ru.properties +++ b/backend/src/main/resources/messages_ru.properties @@ -1087,3 +1087,19 @@ admincreateinvoices.notification.zugferd.nopositions=Нет позиций дл admincreateinvoices.notification.zugferd.batcherror=Не удалось создать электронный счёт {0}, счета не были отправлены: {1} admincreateinvoices.notification.zugferd.invalidsend=Проверка не пройдена для: {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 diff --git a/backend/src/main/resources/messages_tr.properties b/backend/src/main/resources/messages_tr.properties index 8e8a94c..5ba9b85 100644 --- a/backend/src/main/resources/messages_tr.properties +++ b/backend/src/main/resources/messages_tr.properties @@ -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.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} + +# 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 diff --git a/backend/src/test/java/de/assecutor/votianlt/service/UserInvoiceZugferdServiceTest.java b/backend/src/test/java/de/assecutor/votianlt/service/UserInvoiceZugferdServiceTest.java new file mode 100644 index 0000000..b8c6ae1 --- /dev/null +++ b/backend/src/test/java/de/assecutor/votianlt/service/UserInvoiceZugferdServiceTest.java @@ -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 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 Hamburg–Berlin"); + 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 items = metadataCaptor.getValue().items(); + assertThat(items).hasSize(1); + assertThat(items.get(0).description()).isEqualTo("Kurierfahrt Hamburg–Berlin"); + 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"); + } +}