diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/AddCustomerView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/AddCustomerView.java index 167afb5..10d521c 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/AddCustomerView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/AddCustomerView.java @@ -63,7 +63,7 @@ public class AddCustomerView extends Main implements HasDynamicTitle { ustId.setRequiredIndicatorVisible(true); ustId.setPlaceholder("DE123456789"); ustId.setWidthFull(); - ustId.addBlurListener(e -> validateField(ustId)); + ustId.addBlurListener(e -> validateUstId()); // Anrede (Dropdown) title = new ComboBox<>(getTranslation("addjob.address.salutation")); @@ -184,8 +184,10 @@ public class AddCustomerView extends Main implements HasDynamicTitle { binder.forField(taxNumber).asRequired(getTranslation("profile.validation.taxnumber.required")) .bind(Customer::getTaxNumber, Customer::setTaxNumber); - binder.forField(ustId).asRequired(getTranslation("profile.validation.ustid.required")).bind(Customer::getUstId, - Customer::setUstId); + binder.forField(ustId).asRequired(getTranslation("profile.validation.ustid.required")) + .withValidator(de.assecutor.votianlt.util.VatIdUtil::isValid, + getTranslation("profile.validation.ustid.invalid")) + .bind(Customer::getUstId, Customer::setUstId); binder.forField(title).bind(Customer::getTitle, Customer::setTitle); @@ -262,6 +264,24 @@ public class AddCustomerView extends Main implements HasDynamicTitle { } } + /** + * Prüft die USt-IdNr. auf Pflichtangabe und gültiges Format + * (Länderkennzeichen + länderspezifischer Aufbau, z. B. DE123456789). + */ + private void validateUstId() { + String value = ustId.getValue(); + if (value == null || value.trim().isEmpty()) { + ustId.setInvalid(true); + ustId.setErrorMessage(getTranslation("profile.validation.ustid.required")); + } else if (!de.assecutor.votianlt.util.VatIdUtil.isValid(value)) { + ustId.setInvalid(true); + ustId.setErrorMessage(getTranslation("profile.validation.ustid.invalid")); + } else { + ustId.setInvalid(false); + ustId.setErrorMessage(""); + } + } + private void validateField(TextField field) { String value = field.getValue(); if (value == null || value.trim().isEmpty()) { @@ -286,7 +306,7 @@ public class AddCustomerView extends Main implements HasDynamicTitle { private boolean validateAllFields() { validateField(taxNumber); - validateField(ustId); + validateUstId(); validateField(firstName); validateField(lastName); validateField(telephone); 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 4f2d9c7..61c93c5 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 @@ -33,9 +33,12 @@ import de.assecutor.votianlt.repository.ServiceRepository; 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.InvoiceLifecycleException; import de.assecutor.votianlt.service.InvoiceLifecycleService; import de.assecutor.votianlt.service.InvoiceTemplateService; +import de.assecutor.votianlt.service.PdfToolService; +import de.assecutor.votianlt.service.UserInvoiceZugferdService; import jakarta.annotation.security.RolesAllowed; import lombok.extern.slf4j.Slf4j; import org.bson.types.ObjectId; @@ -66,6 +69,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter private final UserInvoiceDataService userInvoiceDataService; private final CustomerService customerService; private final InvoiceLifecycleService invoiceLifecycleService; + private final UserInvoiceZugferdService userInvoiceZugferdService; + private final EmailService emailService; private User currentUser; private Job currentJob; private List gridRows = new ArrayList<>(); @@ -120,7 +125,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter UserRepository userRepository, CustomerInvoiceService customerInvoiceService, InvoiceTemplateService invoiceTemplateService, SecurityService securityService, UserInvoiceDataService userInvoiceDataService, CustomerService customerService, - InvoiceLifecycleService invoiceLifecycleService) { + InvoiceLifecycleService invoiceLifecycleService, UserInvoiceZugferdService userInvoiceZugferdService, + EmailService emailService) { this.jobRepository = jobRepository; this.serviceRepository = serviceRepository; this.userRepository = userRepository; @@ -130,6 +136,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter this.userInvoiceDataService = userInvoiceDataService; this.customerService = customerService; this.invoiceLifecycleService = invoiceLifecycleService; + this.userInvoiceZugferdService = userInvoiceZugferdService; + this.emailService = emailService; setSizeFull(); setPadding(true); setSpacing(true); @@ -557,7 +565,19 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter byte[] pdfBytes = generateInvoicePdfFromTemplate(templateData, currentUser, getTranslation("createinvoice.preview.number")); - showPdfPreviewDialog(pdfBytes, templateData, currentUser); + // Schon für die Vorschau das ZUGFeRD-ZIP über das PDF Tool erzeugen + // und das signierte PDF aus dem ZIP anzeigen — so sieht der Nutzer + // exakt das Dokument, das später versendet wird. + CustomerInvoice previewInvoice = buildInvoice(getTranslation("createinvoice.preview.number"), pdfBytes, + currentUser); + PdfToolService.SignedInvoice signed = userInvoiceZugferdService.sign(previewInvoice); + if (!signed.valid()) { + Notification.show(getTranslation("invoices.notification.zugferd.invalid"), 7000, + Notification.Position.MIDDLE); + } + byte[] signedPdf = extractPdfFromZip(signed.zip()); + + showPdfPreviewDialog(signedPdf != null ? signedPdf : pdfBytes, templateData, currentUser); } catch (Exception ex) { log.error("Fehler beim Erstellen der Rechnungsvorschau", ex); @@ -566,36 +586,47 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter } } - private void saveInvoice(String templateData, User user, Dialog previewDialog) { + /** + * Baut die (noch nicht persistierte) Rechnung mit allen Pflichtangaben nach + * §14 UStG auf — auch Grundlage der ZUGFeRD-Metadaten für die E-Rechnung. + * Wird sowohl für die Vorschau (Platzhalter-Nummer, transient) als auch für + * das endgültige Speichern verwendet. + */ + private CustomerInvoice buildInvoice(String invoiceNumber, byte[] pdfBytes, User user) { + BigDecimal netAmount = calculateNetAmount(); + BigDecimal vatRate = effectiveVatRate(); + BigDecimal vatAmount = netAmount.multiply(vatRate); + BigDecimal totalAmount = netAmount.add(vatAmount); + + CustomerInvoice invoice = new CustomerInvoice(); + invoice.setInvoiceNumber(invoiceNumber); + invoice.setInvoiceDate(java.time.LocalDate.now()); + invoice.setJobId(currentJob.getId().toHexString()); + invoice.setUserId(user.getId().toHexString()); + invoice.setNetAmount(netAmount); + invoice.setVatRate(vatRate); + invoice.setVatAmount(vatAmount); + invoice.setTotalAmount(totalAmount); + + invoice.setDeliveryDate(currentJob.getDeliveryDate() != null ? currentJob.getDeliveryDate() + : java.time.LocalDate.now()); + applySenderData(invoice, user); + applyRecipientData(invoice); + invoice.setItems(buildInvoiceItems(vatRate)); + + invoice.setPdfData(pdfBytes); + return invoice; + } + + /** + * Speichert die Rechnung endgültig und versendet sie anschließend als + * signierte ZUGFeRD-E-Rechnung (ZIP) per E-Mail an den Kunden. + */ + private void saveAndSendInvoice(String templateData, User user, Dialog previewDialog) { try { String invoiceNumber = userInvoiceDataService.generateNextInvoiceNumber(user.getId()); - - BigDecimal netAmount = calculateNetAmount(); - BigDecimal vatRate = effectiveVatRate(); - BigDecimal vatAmount = netAmount.multiply(vatRate); - BigDecimal totalAmount = netAmount.add(vatAmount); - byte[] pdfBytes = generateInvoicePdfFromTemplate(templateData, user, invoiceNumber); - - CustomerInvoice invoice = new CustomerInvoice(); - invoice.setInvoiceNumber(invoiceNumber); - invoice.setInvoiceDate(java.time.LocalDate.now()); - invoice.setJobId(currentJob.getId().toHexString()); - invoice.setUserId(user.getId().toHexString()); - invoice.setNetAmount(netAmount); - invoice.setVatRate(vatRate); - 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); + CustomerInvoice invoice = buildInvoice(invoiceNumber, pdfBytes, user); // Finalisierung mit Audit-Eintrag und Eindeutigkeitsprüfung der Rechnungsnummer (R-07/R-11/R-36). CustomerInvoice savedInvoice = invoiceLifecycleService.createAndIssue(invoice, @@ -608,6 +639,8 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter Notification.show(getTranslation("createinvoice.notification.saved", invoiceNumber), 4000, Notification.Position.BOTTOM_END); + sendInvoiceEmail(savedInvoice); + } catch (InvoiceLifecycleException lifecycleEx) { log.warn("Lifecycle-Verstoß beim Speichern der Rechnung: {}", lifecycleEx.getMessage()); Notification.show(lifecycleEx.getMessage(), 5000, Notification.Position.MIDDLE); @@ -618,6 +651,66 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter } } + /** + * Versendet die gespeicherte Rechnung als E-Rechnungs-ZIP per E-Mail. + * Besteht die Rechnung die Validierung nicht, wird der Versand abgebrochen — + * die Rechnung bleibt gespeichert und kann aus der Rechnungsliste erneut + * versendet werden. + */ + private void sendInvoiceEmail(CustomerInvoice savedInvoice) { + String recipientEmail = savedInvoice.getRecipientEmail(); + if (recipientEmail == null || recipientEmail.isBlank()) { + Notification.show(getTranslation("invoices.notification.email.missing"), 6000, + Notification.Position.MIDDLE); + return; + } + try { + PdfToolService.SignedInvoice signed = userInvoiceZugferdService.sign(savedInvoice); + if (!signed.valid()) { + log.warn("Versand der Rechnung {} abgebrochen, ZUGFeRD-Validierung fehlgeschlagen", + savedInvoice.getInvoiceNumber()); + Notification.show( + getTranslation("invoices.notification.zugferd.invalidsend", savedInvoice.getInvoiceNumber()), + 7000, Notification.Position.MIDDLE); + return; + } + + emailService.sendEmailWithAttachment(recipientEmail, + getTranslation("invoices.mail.subject", savedInvoice.getInvoiceNumber()), + getTranslation("invoices.mail.body", savedInvoice.getInvoiceNumber(), + safe(savedInvoice.getSenderName())), + signed.zip(), signed.fileName()); + + invoiceLifecycleService.markAsSent(savedInvoice.getId(), + "Als E-Rechnung per E-Mail versendet an " + recipientEmail); + Notification.show( + getTranslation("createinvoice.notification.sent", savedInvoice.getInvoiceNumber(), recipientEmail), + 5000, Notification.Position.BOTTOM_END); + } catch (Exception ex) { + log.error("Fehler beim Versand der Rechnung {} an {}: {}", savedInvoice.getInvoiceNumber(), recipientEmail, + ex.getMessage(), ex); + Notification.show(getTranslation("invoices.notification.zugferd.error", ex.getMessage()), 7000, + Notification.Position.MIDDLE); + } + } + + /** + * Extrahiert das signierte PDF (PDF/A-3) aus dem vom PDF Tool gelieferten + * ZIP; {@code null}, wenn kein PDF-Eintrag enthalten ist. + */ + private byte[] extractPdfFromZip(byte[] zipBytes) throws java.io.IOException { + try (java.util.zip.ZipInputStream zip = new java.util.zip.ZipInputStream( + new java.io.ByteArrayInputStream(zipBytes))) { + java.util.zip.ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + if (!entry.isDirectory() && entry.getName().toLowerCase().endsWith(".pdf")) { + return zip.readAllBytes(); + } + } + } + return null; + } + /** * Absenderdaten aus dem Nutzerprofil und den Rechnungs-Stammdaten * (USt-IdNr./Steuernummer, Bankverbindung, Zahlungsbedingungen) übernehmen. @@ -844,21 +937,21 @@ public class CreateInvoiceView extends VerticalLayout implements HasUrlParameter Button closeButton = new Button(getTranslation("button.close"), e -> pdfDialog.close()); closeButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); - Button saveButton = new Button(getTranslation("createinvoice.button.save"), e -> { + Button sendButton = new Button(getTranslation("createinvoice.button.send"), e -> { Dialog confirm = DialogStylingHelper.createConfirmationDialog( - getTranslation("createinvoice.confirm.save.title"), - getTranslation("createinvoice.confirm.save.message"), + getTranslation("createinvoice.confirm.send.title"), + getTranslation("createinvoice.confirm.send.message"), "560px", getTranslation("button.cancel"), - getTranslation("createinvoice.confirm.save.confirm"), - () -> saveInvoice(templateData, user, pdfDialog), + getTranslation("createinvoice.confirm.send.confirm"), + () -> saveAndSendInvoice(templateData, user, pdfDialog), ButtonVariant.LUMO_PRIMARY); confirm.open(); }); - saveButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS); + sendButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SUCCESS); pdfDialog.add(DialogStylingHelper.wrapContent(pdfFrame, true)); - pdfDialog.getFooter().add(closeButton, saveButton); + pdfDialog.getFooter().add(closeButton, sendButton); pdfDialog.open(); } diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/EditCustomerView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/EditCustomerView.java index 463d5b0..16e4320 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/EditCustomerView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/EditCustomerView.java @@ -146,6 +146,8 @@ public class EditCustomerView extends VerticalLayout implements HasUrlParameter< binder.forField(taxNumberField).asRequired(getTranslation("profile.validation.taxnumber.required")) .bind(Customer::getTaxNumber, Customer::setTaxNumber); binder.forField(ustIdField).asRequired(getTranslation("profile.validation.ustid.required")) + .withValidator(de.assecutor.votianlt.util.VatIdUtil::isValid, + getTranslation("profile.validation.ustid.invalid")) .bind(Customer::getUstId, Customer::setUstId); binder.forField(firstnameField).bind(Customer::getFirstname, Customer::setFirstname); binder.forField(lastNameField).bind(Customer::getLastName, Customer::setLastName); 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 d69199c..f618931 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 @@ -378,7 +378,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle ustIdField.setPlaceholder("DE123456789"); ustIdField.setRequiredIndicatorVisible(true); ustIdField.addBlurListener(e -> { - validateField(ustIdField, getTranslation("profile.validation.ustid.required")); + validateUstIdField(); saveInvoiceData(); }); taxNumberField.setLabel(getTranslation("profile.billing.taxnumber")); @@ -650,7 +650,7 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle // Steuernummer und USt-IdNr. sind Pflicht, sobald die Rechnungslegung // aktiviert ist if (billingEnabled.getValue()) { - validateField(ustIdField, getTranslation("profile.validation.ustid.required")); + validateUstIdField(); validateField(taxNumberField, getTranslation("profile.validation.taxnumber.required")); isValid = isValid && !ustIdField.isInvalid() && !taxNumberField.isInvalid(); } @@ -864,6 +864,24 @@ public class EditProfileView extends HorizontalLayout implements HasDynamicTitle return value != null ? value : ""; } + /** + * Prüft die USt-IdNr. auf Pflichtangabe und gültiges Format + * (Länderkennzeichen + länderspezifischer Aufbau, z. B. DE123456789). + */ + private void validateUstIdField() { + String value = ustIdField.getValue(); + if (value == null || value.trim().isEmpty()) { + ustIdField.setInvalid(true); + ustIdField.setErrorMessage(getTranslation("profile.validation.ustid.required")); + } else if (!de.assecutor.votianlt.util.VatIdUtil.isValid(value)) { + ustIdField.setInvalid(true); + ustIdField.setErrorMessage(getTranslation("profile.validation.ustid.invalid")); + } else { + ustIdField.setInvalid(false); + ustIdField.setErrorMessage(""); + } + } + private void validateField(TextField field, String errorMessage) { String value = field.getValue(); if (value == null || value.trim().isEmpty()) { 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 5350aea..9549e5c 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 @@ -4,6 +4,8 @@ import com.vaadin.flow.component.Component; import com.vaadin.flow.component.UI; import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; import com.vaadin.flow.component.dialog.Dialog; import com.vaadin.flow.component.grid.Grid; import com.vaadin.flow.component.html.Anchor; @@ -186,13 +188,15 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle { actions.setSpacing(true); actions.setPadding(false); - Button viewBtn = new Button(getTranslation("invoices.action.view"), e -> downloadInvoicePdf(invoice)); - viewBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL); + Button viewBtn = actionIconButton(VaadinIcon.EYE, "invoices.action.view", + e -> downloadInvoicePdf(invoice)); + viewBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY); viewBtn.setEnabled(invoice.getPdfData() != null && invoice.getPdfData().length > 0); actions.add(viewBtn); - Button historyBtn = new Button(getTranslation("invoices.action.history"), e -> openHistoryDialog(invoice)); - historyBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL); + Button historyBtn = actionIconButton(VaadinIcon.TIME_BACKWARD, "invoices.action.history", + e -> openHistoryDialog(invoice)); + historyBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY); actions.add(historyBtn); InvoiceStatus status = invoice.getStatus() != null ? invoice.getStatus() : InvoiceStatus.ISSUED; @@ -205,40 +209,38 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle { if (type == InvoiceType.INVOICE && status == InvoiceStatus.ISSUED && invoicePermissionService.canMarkAsSent(currentUser)) { - Button sentBtn = new Button(getTranslation("invoices.action.marksent"), + Button sentBtn = actionIconButton(VaadinIcon.CHECK, "invoices.action.marksent", e -> markAsSent(invoice)); - sentBtn.addThemeVariants(ButtonVariant.LUMO_SMALL); 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"), + Button zugferdBtn = actionIconButton(VaadinIcon.FILE_ZIP, "invoices.action.zugferd", e -> downloadZugferdZip(invoice)); - zugferdBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL); + zugferdBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY); actions.add(zugferdBtn); if (isLiveInvoice && invoicePermissionService.canMarkAsSent(currentUser)) { - Button sendBtn = new Button(getTranslation("invoices.action.sendinvoice"), + Button sendBtn = actionIconButton(VaadinIcon.PAPERPLANE, "invoices.action.sendinvoice", e -> sendAsEInvoice(invoice)); - sendBtn.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_PRIMARY); + sendBtn.addThemeVariants(ButtonVariant.LUMO_PRIMARY); actions.add(sendBtn); } } if (isLiveInvoice) { if (invoicePermissionService.canCorrect(currentUser)) { - Button correctBtn = new Button(getTranslation("invoices.action.correct"), + Button correctBtn = actionIconButton(VaadinIcon.EDIT, "invoices.action.correct", e -> openCorrectionDialog(invoice)); - correctBtn.addThemeVariants(ButtonVariant.LUMO_SMALL); actions.add(correctBtn); } if (invoicePermissionService.canCancel(currentUser)) { - Button cancelBtn = new Button(getTranslation("invoices.action.cancel"), + Button cancelBtn = actionIconButton(VaadinIcon.BAN, "invoices.action.cancel", e -> openCancellationDialog(invoice)); - cancelBtn.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ERROR); + cancelBtn.addThemeVariants(ButtonVariant.LUMO_ERROR); actions.add(cancelBtn); } } @@ -246,20 +248,33 @@ public class InvoicesView extends VerticalLayout implements HasDynamicTitle { // Zahlung erfassen: nur für reguläre Rechnungen (R-25) if (type == InvoiceType.INVOICE && status != InvoiceStatus.DRAFT && invoicePermissionService.canRecordPayment(currentUser)) { - Button payBtn = new Button(getTranslation("invoices.action.payment"), + Button payBtn = actionIconButton(VaadinIcon.EURO, "invoices.action.payment", e -> openPaymentDialog(invoice)); - payBtn.addThemeVariants(ButtonVariant.LUMO_SMALL); actions.add(payBtn); } // Belegpaket exportieren (R-33/R-34) - Button exportBtn = new Button(getTranslation("invoices.action.export"), e -> exportPackage(invoice)); - exportBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_SMALL); + Button exportBtn = actionIconButton(VaadinIcon.DOWNLOAD, "invoices.action.export", + e -> exportPackage(invoice)); + exportBtn.addThemeVariants(ButtonVariant.LUMO_TERTIARY); actions.add(exportBtn); return actions; } + /** + * Icon-Button für die Aktionsspalte: die bisherige Button-Beschriftung + * bleibt als Tooltip (Bubblehelp) und Aria-Label erhalten. + */ + private Button actionIconButton(VaadinIcon icon, String labelKey, + com.vaadin.flow.component.ComponentEventListener> listener) { + Button button = new Button(new Icon(icon), listener); + button.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ICON); + button.setTooltipText(getTranslation(labelKey)); + button.setAriaLabel(getTranslation(labelKey)); + return button; + } + private void openPaymentDialog(CustomerInvoice invoice) { Dialog dialog = DialogStylingHelper.createStyledDialog( getTranslation("invoices.payment.title", invoice.getInvoiceNumber()), "480px"); diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java index 479c877..102fd88 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/ShowJobsView.java @@ -70,8 +70,10 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle { endDate.setLabel(getTranslation("jobs.filter.enddate")); searchField.setLabel(getTranslation("jobs.filter.search")); statusFilter.setLabel(getTranslation("jobs.filter.status")); - Button exportButton = new Button(getTranslation("jobs.button.csvexport")); + Button exportButton = new Button(new Icon(VaadinIcon.DOWNLOAD)); exportButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + exportButton.setTooltipText(getTranslation("jobs.button.csvexport")); + exportButton.setAriaLabel(getTranslation("jobs.button.csvexport")); ViewToolbar toolbar = new ViewToolbar(getTranslation("nav.jobs"), exportButton); add(toolbar); @@ -85,9 +87,7 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle { searchField.setClearButtonVisible(true); // Filterleiste mit Export-Button am rechten Rand - Button applyFilter = new Button(getTranslation("jobs.filter.apply")); - applyFilter.addThemeVariants(ButtonVariant.LUMO_PRIMARY); - HorizontalLayout leftFilters = new HorizontalLayout(startDate, endDate, searchField, statusFilter, applyFilter); + HorizontalLayout leftFilters = new HorizontalLayout(startDate, endDate, searchField, statusFilter); leftFilters.setAlignItems(Alignment.END); leftFilters.getStyle().set("flex-wrap", "wrap"); @@ -105,7 +105,6 @@ public class ShowJobsView extends VerticalLayout implements HasDynamicTitle { java.time.LocalDate today = java.time.LocalDate.now(); startDate.setValue(today.minusDays(30)); endDate.setValue(today); - applyFilter.addClickListener(e -> loadData()); exportButton.addClickListener(e -> exportToCsv()); // Add real-time filtering diff --git a/backend/src/main/java/de/assecutor/votianlt/util/VatIdUtil.java b/backend/src/main/java/de/assecutor/votianlt/util/VatIdUtil.java new file mode 100644 index 0000000..8a7120d --- /dev/null +++ b/backend/src/main/java/de/assecutor/votianlt/util/VatIdUtil.java @@ -0,0 +1,81 @@ +package de.assecutor.votianlt.util; + +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Formatvalidierung für Umsatzsteuer-Identifikationsnummern (USt-IdNr.). + * + * Prüft den Aufbau nach Länderkennzeichen gemäß den offiziellen Formaten der + * EU-Mitgliedstaaten (z. B. DE + 9 Ziffern). Für Länderkennzeichen ohne + * hinterlegtes Muster wird ein generisches Format (2 Buchstaben + 2–12 + * alphanumerische Zeichen) akzeptiert. Es findet keine Online-Prüfung (VIES) + * statt — validiert wird ausschließlich das Format. + */ +public final class VatIdUtil { + + /** Offizielle USt-IdNr.-Formate der EU-Mitgliedstaaten (ohne Länderpräfix). */ + private static final Map COUNTRY_PATTERNS = Map.ofEntries( + Map.entry("AT", Pattern.compile("U\\d{8}")), + Map.entry("BE", Pattern.compile("[01]\\d{9}")), + Map.entry("BG", Pattern.compile("\\d{9,10}")), + Map.entry("CY", Pattern.compile("\\d{8}[A-Z]")), + Map.entry("CZ", Pattern.compile("\\d{8,10}")), + Map.entry("DE", Pattern.compile("\\d{9}")), + Map.entry("DK", Pattern.compile("\\d{8}")), + Map.entry("EE", Pattern.compile("\\d{9}")), + Map.entry("EL", Pattern.compile("\\d{9}")), + Map.entry("ES", Pattern.compile("[A-Z0-9]\\d{7}[A-Z0-9]")), + Map.entry("FI", Pattern.compile("\\d{8}")), + Map.entry("FR", Pattern.compile("[A-Z0-9]{2}\\d{9}")), + Map.entry("HR", Pattern.compile("\\d{11}")), + Map.entry("HU", Pattern.compile("\\d{8}")), + Map.entry("IE", Pattern.compile("\\d{7}[A-Z]{1,2}|\\d[A-Z0-9+*]\\d{5}[A-Z]")), + Map.entry("IT", Pattern.compile("\\d{11}")), + Map.entry("LT", Pattern.compile("(\\d{9}|\\d{12})")), + Map.entry("LU", Pattern.compile("\\d{8}")), + Map.entry("LV", Pattern.compile("\\d{11}")), + Map.entry("MT", Pattern.compile("\\d{8}")), + Map.entry("NL", Pattern.compile("\\d{9}B\\d{2}")), + Map.entry("PL", Pattern.compile("\\d{10}")), + Map.entry("PT", Pattern.compile("\\d{9}")), + Map.entry("RO", Pattern.compile("\\d{2,10}")), + Map.entry("SE", Pattern.compile("\\d{12}")), + Map.entry("SI", Pattern.compile("\\d{8}")), + Map.entry("SK", Pattern.compile("\\d{10}"))); + + /** Rückfallmuster für Länderkennzeichen ohne hinterlegtes Format. */ + private static final Pattern GENERIC_PATTERN = Pattern.compile("[A-Z]{2}[A-Z0-9]{2,12}"); + + private VatIdUtil() { + } + + /** + * Prüft, ob der Wert eine formal gültige USt-IdNr. ist. Leerzeichen, + * Punkte und Bindestriche werden vor der Prüfung entfernt, + * Kleinbuchstaben akzeptiert. {@code null} oder leer gilt als ungültig. + */ + public static boolean isValid(String vatId) { + String normalized = normalize(vatId); + if (normalized == null || normalized.length() < 4) { + return false; + } + String countryCode = normalized.substring(0, 2); + if (!countryCode.chars().allMatch(Character::isLetter)) { + return false; + } + Pattern pattern = COUNTRY_PATTERNS.get(countryCode); + if (pattern != null) { + return pattern.matcher(normalized.substring(2)).matches(); + } + return GENERIC_PATTERN.matcher(normalized).matches(); + } + + /** Entfernt Trennzeichen und wandelt in Großbuchstaben um. */ + public static String normalize(String vatId) { + if (vatId == null) { + return null; + } + return vatId.replaceAll("[\\s.\\-]", "").toUpperCase(java.util.Locale.ROOT); + } +} diff --git a/backend/src/main/resources/messages_de.properties b/backend/src/main/resources/messages_de.properties index 3cab678..8800716 100644 --- a/backend/src/main/resources/messages_de.properties +++ b/backend/src/main/resources/messages_de.properties @@ -86,6 +86,7 @@ profile.validation.zip.required=Postleitzahl ist erforderlich profile.validation.city.required=Stadt ist erforderlich profile.validation.taxnumber.required=Steuernummer ist erforderlich profile.validation.ustid.required=USt-IdNr. ist erforderlich +profile.validation.ustid.invalid=Ungültiges Format der USt-IdNr. (z. B. DE123456789) profile.validation.firstname.required=Vorname ist erforderlich profile.validation.lastname.required=Nachname ist erforderlich profile.validation.phone.required=Telefonnummer ist erforderlich @@ -699,6 +700,11 @@ createinvoice.button.save=Speichern createinvoice.confirm.save.title=Rechnung speichern createinvoice.confirm.save.message=Diese Rechnung wird dauerhaft gespeichert und kann anschlie\u00dfend nicht mehr ver\u00e4ndert werden. Fortfahren? createinvoice.confirm.save.confirm=Ja, speichern +createinvoice.button.send=Senden +createinvoice.confirm.send.title=Rechnung senden +createinvoice.confirm.send.message=Diese Rechnung wird dauerhaft gespeichert und als E-Rechnung (ZIP) per E-Mail an den Kunden versendet. Fortfahren? +createinvoice.confirm.send.confirm=Ja, senden +createinvoice.notification.sent=Rechnung {0} wurde als E-Rechnung an {1} versendet # Invoices invoices.title=Rechnungen diff --git a/backend/src/main/resources/messages_ee.properties b/backend/src/main/resources/messages_ee.properties index 1876de0..8100b37 100644 --- a/backend/src/main/resources/messages_ee.properties +++ b/backend/src/main/resources/messages_ee.properties @@ -75,6 +75,7 @@ profile.validation.zip.required=Postiindeks on kohustuslik profile.validation.city.required=Linn on kohustuslik profile.validation.taxnumber.required=Maksunumber on kohustuslik profile.validation.ustid.required=KMKR nr on kohustuslik +profile.validation.ustid.invalid=KMKR numbri vorming on vale (nt DE123456789) profile.validation.firstname.required=Eesnimi on kohustuslik profile.validation.lastname.required=Perekonnanimi on kohustuslik profile.validation.phone.required=Telefoninumber on kohustuslik @@ -639,6 +640,11 @@ createinvoice.button.save=Salvesta createinvoice.confirm.save.title=Arve salvestamine createinvoice.confirm.save.message=See arve salvestatakse p\u00fcsivalt ja seda ei saa hiljem muuta. Kas j\u00e4tkata? createinvoice.confirm.save.confirm=Jah, salvesta +createinvoice.button.send=Saada +createinvoice.confirm.send.title=Saada arve +createinvoice.confirm.send.message=Arve salvestatakse jäädavalt ja saadetakse kliendile e-posti teel e-arvena (ZIP). Kas jätkata? +createinvoice.confirm.send.confirm=Jah, saada +createinvoice.notification.sent=Arve {0} saadeti e-arvena aadressile {1} invoices.title=Arved invoices.column.number=Number invoices.column.customer=Klient diff --git a/backend/src/main/resources/messages_en.properties b/backend/src/main/resources/messages_en.properties index 0f393d3..08aa24a 100644 --- a/backend/src/main/resources/messages_en.properties +++ b/backend/src/main/resources/messages_en.properties @@ -86,6 +86,7 @@ profile.validation.zip.required=Postal code is required profile.validation.city.required=City is required profile.validation.taxnumber.required=Tax number is required profile.validation.ustid.required=VAT ID is required +profile.validation.ustid.invalid=Invalid VAT ID format (e.g. DE123456789) profile.validation.firstname.required=First name is required profile.validation.lastname.required=Last name is required profile.validation.phone.required=Phone number is required @@ -699,6 +700,11 @@ createinvoice.button.save=Save createinvoice.confirm.save.title=Save Invoice createinvoice.confirm.save.message=This invoice will be permanently saved and cannot be modified afterwards. Continue? createinvoice.confirm.save.confirm=Yes, save +createinvoice.button.send=Send +createinvoice.confirm.send.title=Send Invoice +createinvoice.confirm.send.message=This invoice will be permanently saved and sent to the customer by email as an e-invoice (ZIP). Continue? +createinvoice.confirm.send.confirm=Yes, send +createinvoice.notification.sent=Invoice {0} has been sent as an e-invoice to {1} # Invoices invoices.title=Invoices diff --git a/backend/src/main/resources/messages_es.properties b/backend/src/main/resources/messages_es.properties index 7dbf33c..779b2bf 100644 --- a/backend/src/main/resources/messages_es.properties +++ b/backend/src/main/resources/messages_es.properties @@ -85,6 +85,7 @@ profile.validation.zip.required=C\u00f3digo postal es obligatorio profile.validation.city.required=Ciudad es obligatorio profile.validation.taxnumber.required=Número fiscal es obligatorio profile.validation.ustid.required=NIF-IVA es obligatorio +profile.validation.ustid.invalid=Formato de NIF-IVA no válido (p. ej. DE123456789) profile.validation.firstname.required=Nombre es obligatorio profile.validation.lastname.required=Apellido es obligatorio profile.validation.phone.required=N\u00famero de tel\u00e9fono es obligatorio @@ -696,6 +697,11 @@ createinvoice.button.save=Guardar createinvoice.confirm.save.title=Guardar factura createinvoice.confirm.save.message=Esta factura se guardar\u00e1 de forma permanente y no podr\u00e1 ser modificada posteriormente. \u00bfDesea continuar? createinvoice.confirm.save.confirm=S\u00ed, guardar +createinvoice.button.send=Enviar +createinvoice.confirm.send.title=Enviar factura +createinvoice.confirm.send.message=La factura se guardará de forma permanente y se enviará al cliente por correo electrónico como factura electrónica (ZIP). ¿Continuar? +createinvoice.confirm.send.confirm=Sí, enviar +createinvoice.notification.sent=La factura {0} se ha enviado como factura electrónica a {1} # Invoices invoices.title=Facturas diff --git a/backend/src/main/resources/messages_fr.properties b/backend/src/main/resources/messages_fr.properties index b0b1ec0..ae4aa40 100644 --- a/backend/src/main/resources/messages_fr.properties +++ b/backend/src/main/resources/messages_fr.properties @@ -85,6 +85,7 @@ profile.validation.zip.required=Le code postal est obligatoire profile.validation.city.required=La ville est obligatoire profile.validation.taxnumber.required=Le numéro fiscal est obligatoire profile.validation.ustid.required=Le n° TVA intracommunautaire est obligatoire +profile.validation.ustid.invalid=Format de numéro de TVA invalide (p. ex. DE123456789) profile.validation.firstname.required=Le pr\u00e9nom est obligatoire profile.validation.lastname.required=Le nom est obligatoire profile.validation.phone.required=Le num\u00e9ro de t\u00e9l\u00e9phone est obligatoire @@ -696,6 +697,11 @@ createinvoice.button.save=Enregistrer createinvoice.confirm.save.title=Enregistrer la facture createinvoice.confirm.save.message=Cette facture sera enregistr\u00e9e de mani\u00e8re permanente et ne pourra plus \u00eatre modifi\u00e9e par la suite. Continuer ? createinvoice.confirm.save.confirm=Oui, enregistrer +createinvoice.button.send=Envoyer +createinvoice.confirm.send.title=Envoyer la facture +createinvoice.confirm.send.message=La facture sera enregistrée définitivement et envoyée au client par e-mail en tant que facture électronique (ZIP). Continuer ? +createinvoice.confirm.send.confirm=Oui, envoyer +createinvoice.notification.sent=La facture {0} a été envoyée en tant que facture électronique à {1} # Invoices invoices.title=Factures diff --git a/backend/src/main/resources/messages_lt.properties b/backend/src/main/resources/messages_lt.properties index 24c8647..26a9747 100644 --- a/backend/src/main/resources/messages_lt.properties +++ b/backend/src/main/resources/messages_lt.properties @@ -85,6 +85,7 @@ profile.validation.zip.required=Pašto kodas yra privalomas profile.validation.city.required=Miestas yra privalomas profile.validation.taxnumber.required=Mokesčių mokėtojo numeris yra privalomas profile.validation.ustid.required=PVM mokėtojo kodas yra privalomas +profile.validation.ustid.invalid=Neteisingas PVM mokėtojo kodo formatas (pvz., DE123456789) profile.validation.firstname.required=Vardas yra privalomas profile.validation.lastname.required=Pavardė yra privaloma profile.validation.phone.required=Telefono numeris yra privalomas @@ -698,6 +699,11 @@ createinvoice.button.save=Išsaugoti createinvoice.confirm.save.title=Išsaugoti sąskaitą createinvoice.confirm.save.message=Ši sąskaita bus išsaugota visam laikui ir vėliau jos nebebus galima keisti. Tęsti? createinvoice.confirm.save.confirm=Taip, išsaugoti +createinvoice.button.send=Siųsti +createinvoice.confirm.send.title=Siųsti sąskaitą +createinvoice.confirm.send.message=Sąskaita bus visam laikui išsaugota ir išsiųsta klientui el. paštu kaip e. sąskaita (ZIP). Tęsti? +createinvoice.confirm.send.confirm=Taip, siųsti +createinvoice.notification.sent=Sąskaita {0} išsiųsta kaip e. sąskaita adresu {1} # Invoices invoices.title=Sąskaitos faktūros diff --git a/backend/src/main/resources/messages_lv.properties b/backend/src/main/resources/messages_lv.properties index 9ac82c9..555018f 100644 --- a/backend/src/main/resources/messages_lv.properties +++ b/backend/src/main/resources/messages_lv.properties @@ -85,6 +85,7 @@ profile.validation.zip.required=Pasta indekss ir obligāts profile.validation.city.required=Pilsēta ir obligāta profile.validation.taxnumber.required=Nodokļu maksātāja numurs ir obligāts profile.validation.ustid.required=PVN numurs ir obligāts +profile.validation.ustid.invalid=Nederīgs PVN numura formāts (piem., DE123456789) profile.validation.firstname.required=Vārds ir obligāts profile.validation.lastname.required=Uzvārds ir obligāts profile.validation.phone.required=Tālruņa numurs ir obligāts @@ -696,6 +697,11 @@ createinvoice.button.save=Saglabāt createinvoice.confirm.save.title=Saglabāt rēķinu createinvoice.confirm.save.message=Šis rēķins tiks neatgriezeniski saglabāts un pēc tam to vairs nevarēs mainīt. Turpināt? createinvoice.confirm.save.confirm=Jā, saglabāt +createinvoice.button.send=Sūtīt +createinvoice.confirm.send.title=Sūtīt rēķinu +createinvoice.confirm.send.message=Rēķins tiks neatgriezeniski saglabāts un nosūtīts klientam pa e-pastu kā e-rēķins (ZIP). Vai turpināt? +createinvoice.confirm.send.confirm=Jā, sūtīt +createinvoice.notification.sent=Rēķins {0} nosūtīts kā e-rēķins uz {1} # Invoices invoices.title=Rēķini diff --git a/backend/src/main/resources/messages_pl.properties b/backend/src/main/resources/messages_pl.properties index 0cfb45b..a2d3d90 100644 --- a/backend/src/main/resources/messages_pl.properties +++ b/backend/src/main/resources/messages_pl.properties @@ -85,6 +85,7 @@ profile.validation.zip.required=Kod pocztowy jest wymagany profile.validation.city.required=Miasto jest wymagane profile.validation.taxnumber.required=Numer podatkowy jest wymagany profile.validation.ustid.required=NIP UE jest wymagany +profile.validation.ustid.invalid=Nieprawidłowy format numeru VAT UE (np. DE123456789) profile.validation.firstname.required=Imi\u0119 jest wymagane profile.validation.lastname.required=Nazwisko jest wymagane profile.validation.phone.required=Numer telefonu jest wymagany @@ -696,6 +697,11 @@ createinvoice.button.save=Zapisz createinvoice.confirm.save.title=Zapisz faktur\u0119 createinvoice.confirm.save.message=Ta faktura zostanie trwale zapisana i nie b\u0119dzie mog\u0142a by\u0107 p\u00f3\u017aniej zmieniona. Kontynuowa\u0107? createinvoice.confirm.save.confirm=Tak, zapisz +createinvoice.button.send=Wyślij +createinvoice.confirm.send.title=Wyślij fakturę +createinvoice.confirm.send.message=Faktura zostanie trwale zapisana i wysłana do klienta e-mailem jako e-faktura (ZIP). Kontynuować? +createinvoice.confirm.send.confirm=Tak, wyślij +createinvoice.notification.sent=Faktura {0} została wysłana jako e-faktura na adres {1} # Invoices invoices.title=Faktury diff --git a/backend/src/main/resources/messages_ru.properties b/backend/src/main/resources/messages_ru.properties index 4b16624..46a1708 100644 --- a/backend/src/main/resources/messages_ru.properties +++ b/backend/src/main/resources/messages_ru.properties @@ -85,6 +85,7 @@ profile.validation.zip.required=Почтовый индекс обязателе profile.validation.city.required=Город обязателен profile.validation.taxnumber.required=Налоговый номер обязателен profile.validation.ustid.required=Идентификатор плательщика НДС обязателен +profile.validation.ustid.invalid=Неверный формат идентификационного номера НДС (напр. DE123456789) profile.validation.firstname.required=Имя обязательно profile.validation.lastname.required=Фамилия обязательна profile.validation.phone.required=Номер телефона обязателен @@ -696,6 +697,11 @@ createinvoice.button.save=Сохранить createinvoice.confirm.save.title=Сохранить счёт createinvoice.confirm.save.message=Этот счёт будет сохранён навсегда и не может быть изменён впоследствии. Продолжить? createinvoice.confirm.save.confirm=Да, сохранить +createinvoice.button.send=Отправить +createinvoice.confirm.send.title=Отправить счёт +createinvoice.confirm.send.message=Счёт будет окончательно сохранён и отправлен клиенту по электронной почте как электронный счёт (ZIP). Продолжить? +createinvoice.confirm.send.confirm=Да, отправить +createinvoice.notification.sent=Счёт {0} отправлен как электронный счёт на {1} # Invoices invoices.title=Счета diff --git a/backend/src/main/resources/messages_tr.properties b/backend/src/main/resources/messages_tr.properties index e339c9c..e72de9c 100644 --- a/backend/src/main/resources/messages_tr.properties +++ b/backend/src/main/resources/messages_tr.properties @@ -85,6 +85,7 @@ profile.validation.zip.required=Posta kodu gereklidir profile.validation.city.required=\u015eehir gereklidir profile.validation.taxnumber.required=Vergi numarası gereklidir profile.validation.ustid.required=KDV kimlik no gereklidir +profile.validation.ustid.invalid=Geçersiz KDV kimlik numarası biçimi (örn. DE123456789) profile.validation.firstname.required=Ad gereklidir profile.validation.lastname.required=Soyad gereklidir profile.validation.phone.required=Telefon numaras\u0131 gereklidir @@ -696,6 +697,11 @@ createinvoice.button.save=Kaydet createinvoice.confirm.save.title=Faturay\u0131 Kaydet createinvoice.confirm.save.message=Bu fatura kal\u0131c\u0131 olarak kaydedilecek ve sonras\u0131nda de\u011fi\u015ftirilemeyecektir. Devam edilsin mi? createinvoice.confirm.save.confirm=Evet, kaydet +createinvoice.button.send=Gönder +createinvoice.confirm.send.title=Faturayı gönder +createinvoice.confirm.send.message=Fatura kalıcı olarak kaydedilecek ve müşteriye e-posta ile e-fatura (ZIP) olarak gönderilecektir. Devam edilsin mi? +createinvoice.confirm.send.confirm=Evet, gönder +createinvoice.notification.sent=Fatura {0}, e-fatura olarak {1} adresine gönderildi # Invoices invoices.title=Faturalar diff --git a/backend/src/test/java/de/assecutor/votianlt/util/VatIdUtilTest.java b/backend/src/test/java/de/assecutor/votianlt/util/VatIdUtilTest.java new file mode 100644 index 0000000..41b676a --- /dev/null +++ b/backend/src/test/java/de/assecutor/votianlt/util/VatIdUtilTest.java @@ -0,0 +1,45 @@ +package de.assecutor.votianlt.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class VatIdUtilTest { + + @ParameterizedTest + @ValueSource(strings = { "DE123456789", "ATU12345678", "NL123456789B01", "FRXX123456789", "PL1234567890", + "IT12345678901", "BE0123456789", "SE123456789012", "IE1234567AB" }) + void validVatIdsAreAccepted(String vatId) { + assertTrue(VatIdUtil.isValid(vatId), vatId + " sollte gültig sein"); + } + + @Test + void separatorsAndLowercaseAreNormalized() { + assertTrue(VatIdUtil.isValid("de 123.456-789")); + assertEquals("DE123456789", VatIdUtil.normalize("de 123.456-789")); + } + + @ParameterizedTest + @ValueSource(strings = { "DE12345678", "DE1234567890", "DE12345678A", "ATU1234567", "NL123456789A01", + "123456789", "D123456789", "XX", "DE" }) + void invalidVatIdsAreRejected(String vatId) { + assertFalse(VatIdUtil.isValid(vatId), vatId + " sollte ungültig sein"); + } + + @Test + void nullAndBlankAreInvalid() { + assertFalse(VatIdUtil.isValid(null)); + assertFalse(VatIdUtil.isValid("")); + assertFalse(VatIdUtil.isValid(" ")); + } + + @Test + void unknownCountryFallsBackToGenericPattern() { + assertTrue(VatIdUtil.isValid("XI123456789")); + assertFalse(VatIdUtil.isValid("XI1")); + } +}