From 3d408e43c9c1a6e0ea8205274691a85cfe3a0e70 Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Mon, 13 Jul 2026 14:59:52 +0200 Subject: [PATCH] feat: Rechnungsversand als signierte E-Rechnung mit Sammel-ZIP-Download, Abbruch bei fehlgeschlagener Validierung (HTTP 422) Co-Authored-By: Claude Fable 5 --- .../pages/view/AdminCreateInvoicesView.java | 91 +++++++++++++++++-- .../votianlt/service/PdfToolService.java | 24 +++-- .../src/main/resources/messages_de.properties | 3 + .../src/main/resources/messages_ee.properties | 3 + .../src/main/resources/messages_en.properties | 3 + .../src/main/resources/messages_es.properties | 3 + .../src/main/resources/messages_fr.properties | 3 + .../src/main/resources/messages_lt.properties | 3 + .../src/main/resources/messages_lv.properties | 3 + .../src/main/resources/messages_pl.properties | 3 + .../src/main/resources/messages_ru.properties | 3 + .../src/main/resources/messages_tr.properties | 3 + 12 files changed, 129 insertions(+), 16 deletions(-) diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminCreateInvoicesView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminCreateInvoicesView.java index d56ae75..e28ef2a 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminCreateInvoicesView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminCreateInvoicesView.java @@ -552,10 +552,42 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle { } /** - * Sends each invoice as PDF attachment to the respective customer's email - * address and reports how many mails were (not) sent. + * Sends the invoices as signed ZUGFeRD e-invoice (ZIP) to the respective + * customers. First all signed ZIPs are created and cached; only when every + * ZIP could be created and passed the validation the mails are sent. + * Afterwards a combined ZIP containing all individual invoice ZIPs is + * offered as browser download. */ private void sendInvoices(List previews) { + // Phase 1: create and cache the signed ZIP of every invoice; abort + // without sending anything if a single one fails + Map signedZips = new LinkedHashMap<>(); + for (InvoicePreview preview : previews) { + try { + signedZips.put(preview, pdfToolService.signInvoice(preview.pdf(), buildZugferdMetadata(preview))); + } catch (Exception ex) { + log.error("Error creating ZUGFeRD e-invoice {} for batch send: {}", preview.invoiceNumber(), + ex.getMessage(), ex); + Notification + .show(getTranslation("admincreateinvoices.notification.zugferd.batcherror", + preview.invoiceNumber(), ex.getMessage()), 7000, Notification.Position.BOTTOM_CENTER) + .addThemeVariants(NotificationVariant.LUMO_ERROR); + return; + } + } + // Invoices failing the Mustang validation stop the whole batch: no + // mail is sent as long as a single invoice is invalid + String invalidNumbers = signedZips.entrySet().stream().filter(entry -> !entry.getValue().valid()) + .map(entry -> entry.getKey().invoiceNumber()).collect(java.util.stream.Collectors.joining(", ")); + if (!invalidNumbers.isEmpty()) { + log.warn("Invoice batch send aborted, validation failed for: {}", invalidNumbers); + Notification + .show(getTranslation("admincreateinvoices.notification.zugferd.invalidsend", invalidNumbers), + 7000, Notification.Position.BOTTOM_CENTER) + .addThemeVariants(NotificationVariant.LUMO_ERROR); + return; + } + String issuerName = ""; try { issuerName = safe(systemCompanyProfileService.createSystemInvoiceData().getCompanyName()); @@ -564,9 +596,12 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle { } String monthLabel = billingMonth.format(DateTimeFormatter.ofPattern("LLLL yyyy", getLocale())); + // Phase 2: all ZIPs exist, send one mail per invoice with its ZIP int sent = 0; int failed = 0; - for (InvoicePreview preview : previews) { + for (Map.Entry entry : signedZips.entrySet()) { + InvoicePreview preview = entry.getKey(); + PdfToolService.SignedInvoice signed = entry.getValue(); String email = preview.info().user().getEmail(); if (email == null || email.isBlank()) { log.warn("Customer {} has no email address, invoice {} not sent", @@ -579,7 +614,7 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle { getTranslation("admincreateinvoices.mail.subject", preview.invoiceNumber()), getTranslation("admincreateinvoices.mail.body", preview.invoiceNumber(), monthLabel, issuerName), - preview.pdf(), preview.invoiceNumber() + ".pdf"); + signed.zip(), signed.fileName()); recordDispatch(preview, email); sent++; } catch (Exception ex) { @@ -587,6 +622,22 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle { failed++; } } + + // Phase 3: bundle all individual invoice ZIPs and offer the download + if (signedZips.size() > 1) { + try { + String archiveName = "rechnungen-" + billingMonth.format(DateTimeFormatter.ofPattern("yyyy-MM")) + + "-zugferd.zip"; + triggerZipDownload(buildCombinedZip(signedZips.values()), archiveName); + } catch (Exception ex) { + log.error("Error creating combined invoice ZIP: {}", ex.getMessage(), ex); + Notification + .show(getTranslation("admincreateinvoices.notification.zip.combined.error", ex.getMessage()), + 7000, Notification.Position.BOTTOM_CENTER) + .addThemeVariants(NotificationVariant.LUMO_ERROR); + } + } + if (sent > 0) { grid.deselectAll(); grid.setItems(loadBillingInfos()); @@ -748,11 +799,7 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle { try { PdfToolService.SignedInvoice signed = pdfToolService.signInvoice(preview.pdf(), buildZugferdMetadata(preview)); - String base64Zip = Base64.getEncoder().encodeToString(signed.zip()); - getElement().executeJs("const link = document.createElement('a');" - + "link.href = 'data:application/zip;base64," + base64Zip + "';" + "link.download = '" - + signed.fileName().replace("'", "") + "';" - + "document.body.appendChild(link); link.click(); document.body.removeChild(link);"); + triggerZipDownload(signed.zip(), signed.fileName()); if (signed.valid()) { Notification .show(getTranslation("admincreateinvoices.notification.zugferd.created", @@ -773,6 +820,32 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle { } } + /** + * Packs the individual invoice ZIPs into one combined ZIP so all sent + * e-invoices can be archived with a single download. + */ + private byte[] buildCombinedZip(java.util.Collection signedInvoices) + throws java.io.IOException { + java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream(); + try (java.util.zip.ZipOutputStream zipStream = new java.util.zip.ZipOutputStream(buffer)) { + for (PdfToolService.SignedInvoice signed : signedInvoices) { + zipStream.putNextEntry(new java.util.zip.ZipEntry(signed.fileName())); + zipStream.write(signed.zip()); + zipStream.closeEntry(); + } + } + return buffer.toByteArray(); + } + + /** Offers the given ZIP bytes as browser download. */ + private void triggerZipDownload(byte[] zip, String fileName) { + String base64Zip = Base64.getEncoder().encodeToString(zip); + getElement().executeJs("const link = document.createElement('a');" + + "link.href = 'data:application/zip;base64," + base64Zip + "';" + "link.download = '" + + fileName.replace("'", "") + "';" + + "document.body.appendChild(link); link.click(); document.body.removeChild(link);"); + } + /** * Builds the EN16931 metadata for the PDF Tool from the billing data: the * system operator as sender, the customer as recipient and the billed diff --git a/backend/src/main/java/de/assecutor/votianlt/service/PdfToolService.java b/backend/src/main/java/de/assecutor/votianlt/service/PdfToolService.java index 98ee522..b24cfbf 100644 --- a/backend/src/main/java/de/assecutor/votianlt/service/PdfToolService.java +++ b/backend/src/main/java/de/assecutor/votianlt/service/PdfToolService.java @@ -65,7 +65,10 @@ public class PdfToolService { /** * Converts the given invoice PDF into a ZUGFeRD e-invoice. Returns the ZIP * provided by the PDF Tool; throws with the tool's error message if the - * conversion is rejected (e.g. incomplete metadata). + * conversion is rejected (e.g. incomplete metadata). A failed Mustang + * validation is reported by the tool as HTTP 422 but still carries the ZIP + * (including the validation report) in the body; it is returned as a + * {@link SignedInvoice} with {@code valid=false} instead of throwing. */ public SignedInvoice signInvoice(byte[] pdfBytes, InvoiceMetadata metadata) throws Exception { String metadataJson = objectMapper.writeValueAsString(metadata); @@ -79,23 +82,30 @@ public class PdfToolService { }, MediaType.APPLICATION_PDF); body.part("metadata", metadataJson, MediaType.APPLICATION_JSON); - ResponseEntity response; try { - response = restClient.post().uri("/api/invoices/sign").contentType(MediaType.MULTIPART_FORM_DATA) - .body(body.build()).retrieve().toEntity(byte[].class); + ResponseEntity response = restClient.post().uri("/api/invoices/sign") + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .toEntity(byte[].class); + return toSignedInvoice(response.getBody(), response.getHeaders(), metadata); } catch (RestClientResponseException ex) { + if (ex.getStatusCode().value() == 422 && ex.getResponseBodyAsByteArray().length > 0) { + return toSignedInvoice(ex.getResponseBodyAsByteArray(), ex.getResponseHeaders(), metadata); + } throw new IllegalStateException(extractErrorMessage(ex), ex); } + } - byte[] zip = response.getBody(); + /** Builds the result from the response ZIP, file name and validation header. */ + private SignedInvoice toSignedInvoice(byte[] zip, org.springframework.http.HttpHeaders headers, + InvoiceMetadata metadata) { if (zip == null || zip.length == 0) { throw new IllegalStateException("PDF Tool returned an empty response"); } - String fileName = response.getHeaders().getContentDisposition().getFilename(); + String fileName = headers != null ? headers.getContentDisposition().getFilename() : null; if (fileName == null || fileName.isBlank()) { fileName = metadata.invoiceNumber() + "-zugferd.zip"; } - boolean valid = Boolean.parseBoolean(response.getHeaders().getFirst(VALIDATION_HEADER)); + boolean valid = headers != null && Boolean.parseBoolean(headers.getFirst(VALIDATION_HEADER)); return new SignedInvoice(zip, fileName, valid); } diff --git a/backend/src/main/resources/messages_de.properties b/backend/src/main/resources/messages_de.properties index a451728..af7f288 100644 --- a/backend/src/main/resources/messages_de.properties +++ b/backend/src/main/resources/messages_de.properties @@ -1153,3 +1153,6 @@ admincreateinvoices.notification.zugferd.created=E-Rechnung {0} erstellt admincreateinvoices.notification.zugferd.invalid=E-Rechnung erstellt, aber die Validierung ist fehlgeschlagen. Details im Prüfbericht in der ZIP-Datei. admincreateinvoices.notification.zugferd.error=Fehler beim Erstellen der E-Rechnung: {0} admincreateinvoices.notification.zugferd.nopositions=Keine abrechenbaren Positionen für die E-Rechnung vorhanden +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} diff --git a/backend/src/main/resources/messages_ee.properties b/backend/src/main/resources/messages_ee.properties index 33b3ee4..01ba996 100644 --- a/backend/src/main/resources/messages_ee.properties +++ b/backend/src/main/resources/messages_ee.properties @@ -983,3 +983,6 @@ admincreateinvoices.notification.zugferd.created=E-arve {0} loodud admincreateinvoices.notification.zugferd.invalid=E-arve on loodud, kuid valideerimine ebaõnnestus. Üksikasjad on ZIP-failis olevas valideerimisaruandes. admincreateinvoices.notification.zugferd.error=Viga e-arve loomisel: {0} admincreateinvoices.notification.zugferd.nopositions=E-arve jaoks puuduvad arveldatavad positsioonid +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} diff --git a/backend/src/main/resources/messages_en.properties b/backend/src/main/resources/messages_en.properties index a27e4bd..78c440a 100644 --- a/backend/src/main/resources/messages_en.properties +++ b/backend/src/main/resources/messages_en.properties @@ -1153,3 +1153,6 @@ admincreateinvoices.notification.zugferd.created=E-invoice {0} created admincreateinvoices.notification.zugferd.invalid=E-invoice created, but validation failed. See the validation report in the ZIP file. admincreateinvoices.notification.zugferd.error=Error creating the e-invoice: {0} admincreateinvoices.notification.zugferd.nopositions=No billable positions available for the e-invoice +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} diff --git a/backend/src/main/resources/messages_es.properties b/backend/src/main/resources/messages_es.properties index 264d817..5356668 100644 --- a/backend/src/main/resources/messages_es.properties +++ b/backend/src/main/resources/messages_es.properties @@ -1084,3 +1084,6 @@ admincreateinvoices.notification.zugferd.created=Factura electrónica {0} creada admincreateinvoices.notification.zugferd.invalid=Factura electrónica creada, pero la validación ha fallado. Consulte el informe de validación en el archivo ZIP. admincreateinvoices.notification.zugferd.error=Error al crear la factura electrónica: {0} admincreateinvoices.notification.zugferd.nopositions=No hay posiciones facturables disponibles para la factura electrónica +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} diff --git a/backend/src/main/resources/messages_fr.properties b/backend/src/main/resources/messages_fr.properties index 3f8b69d..2f0ca51 100644 --- a/backend/src/main/resources/messages_fr.properties +++ b/backend/src/main/resources/messages_fr.properties @@ -1084,3 +1084,6 @@ admincreateinvoices.notification.zugferd.created=Facture électronique {0} cré admincreateinvoices.notification.zugferd.invalid=Facture électronique créée, mais la validation a échoué. Détails dans le rapport de validation du fichier ZIP. admincreateinvoices.notification.zugferd.error=Erreur lors de la création de la facture électronique : {0} admincreateinvoices.notification.zugferd.nopositions=Aucune position facturable disponible pour la facture électronique +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} diff --git a/backend/src/main/resources/messages_lt.properties b/backend/src/main/resources/messages_lt.properties index 00d6730..cbde87a 100644 --- a/backend/src/main/resources/messages_lt.properties +++ b/backend/src/main/resources/messages_lt.properties @@ -1086,3 +1086,6 @@ admincreateinvoices.notification.zugferd.created=E. sąskaita {0} sukurta admincreateinvoices.notification.zugferd.invalid=E. sąskaita sukurta, tačiau patikra nepavyko. Išsami informacija patikros ataskaitoje ZIP faile. admincreateinvoices.notification.zugferd.error=Klaida kuriant e. sąskaitą: {0} admincreateinvoices.notification.zugferd.nopositions=E. sąskaitai nėra apmokestinamų pozicijų +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} diff --git a/backend/src/main/resources/messages_lv.properties b/backend/src/main/resources/messages_lv.properties index 4322be8..6ecd6d0 100644 --- a/backend/src/main/resources/messages_lv.properties +++ b/backend/src/main/resources/messages_lv.properties @@ -1084,3 +1084,6 @@ admincreateinvoices.notification.zugferd.created=E-rēķins {0} izveidots admincreateinvoices.notification.zugferd.invalid=E-rēķins izveidots, bet validācija neizdevās. Detaļas skatiet validācijas atskaitē ZIP failā. admincreateinvoices.notification.zugferd.error=Kļūda, veidojot e-rēķinu: {0} admincreateinvoices.notification.zugferd.nopositions=E-rēķinam nav pieejamu norēķinu pozīciju +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} diff --git a/backend/src/main/resources/messages_pl.properties b/backend/src/main/resources/messages_pl.properties index 13f1068..a3490b9 100644 --- a/backend/src/main/resources/messages_pl.properties +++ b/backend/src/main/resources/messages_pl.properties @@ -1084,3 +1084,6 @@ admincreateinvoices.notification.zugferd.created=Utworzono e-fakturę {0} admincreateinvoices.notification.zugferd.invalid=E-faktura została utworzona, ale walidacja nie powiodła się. Szczegóły w raporcie walidacji w pliku ZIP. admincreateinvoices.notification.zugferd.error=Błąd podczas tworzenia e-faktury: {0} admincreateinvoices.notification.zugferd.nopositions=Brak pozycji rozliczeniowych dla e-faktury +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} diff --git a/backend/src/main/resources/messages_ru.properties b/backend/src/main/resources/messages_ru.properties index 59a22bf..933da21 100644 --- a/backend/src/main/resources/messages_ru.properties +++ b/backend/src/main/resources/messages_ru.properties @@ -1084,3 +1084,6 @@ admincreateinvoices.notification.zugferd.created=Электронный счёт admincreateinvoices.notification.zugferd.invalid=Электронный счёт создан, но проверка не пройдена. Подробности в отчёте о проверке в ZIP-файле. admincreateinvoices.notification.zugferd.error=Ошибка при создании электронного счёта: {0} admincreateinvoices.notification.zugferd.nopositions=Нет позиций для выставления электронного счёта +admincreateinvoices.notification.zugferd.batcherror=Не удалось создать электронный счёт {0}, счета не были отправлены: {1} +admincreateinvoices.notification.zugferd.invalidsend=Проверка не пройдена для: {0}. Счета не были отправлены. +admincreateinvoices.notification.zip.combined.error=Ошибка при создании общего ZIP-файла: {0} diff --git a/backend/src/main/resources/messages_tr.properties b/backend/src/main/resources/messages_tr.properties index ecec7bf..8e8a94c 100644 --- a/backend/src/main/resources/messages_tr.properties +++ b/backend/src/main/resources/messages_tr.properties @@ -1084,3 +1084,6 @@ admincreateinvoices.notification.zugferd.created={0} numaralı e-fatura oluştur admincreateinvoices.notification.zugferd.invalid=E-fatura oluşturuldu ancak doğrulama başarısız oldu. Ayrıntılar ZIP dosyasındaki doğrulama raporunda. admincreateinvoices.notification.zugferd.error=E-fatura oluşturulurken hata: {0} admincreateinvoices.notification.zugferd.nopositions=E-fatura için faturalandırılabilir pozisyon yok +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}