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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user