feat: Rechnungsversand als signierte E-Rechnung mit Sammel-ZIP-Download, Abbruch bei fehlgeschlagener Validierung (HTTP 422)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 14:59:52 +02:00
parent 4e411c727e
commit 3d408e43c9
12 changed files with 129 additions and 16 deletions

View File

@@ -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<InvoicePreview> previews) {
// Phase 1: create and cache the signed ZIP of every invoice; abort
// without sending anything if a single one fails
Map<InvoicePreview, PdfToolService.SignedInvoice> 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<InvoicePreview, PdfToolService.SignedInvoice> 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<PdfToolService.SignedInvoice> 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

View File

@@ -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<byte[]> response;
try {
response = restClient.post().uri("/api/invoices/sign").contentType(MediaType.MULTIPART_FORM_DATA)
.body(body.build()).retrieve().toEntity(byte[].class);
ResponseEntity<byte[]> 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);
}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}