From 48a583160019e1cf5e5cb1930a775d329f92786d Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Thu, 16 Jul 2026 14:07:56 +0200 Subject: [PATCH] =?UTF-8?q?Bankverbindung=20(Bank,=20IBAN,=20BIC)=20im=20A?= =?UTF-8?q?dressbuch,=20IBAN/BIC=20=C3=BCber=20den=20Rechnungssteller=20st?= =?UTF-8?q?att=20Freitext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IBAN-Prüfung (ISO 13616) in eigenen IbanValidator ausgelagert; Käuferreferenz und dev-Testdaten aus der Rechnungsmaske entfernt, Entwurf-Speichern nur bei gefüllten Pflichtfeldern. Co-Authored-By: Claude Fable 5 --- .../de/assecutor/pdftool/address/Address.java | 29 +++ .../pdftool/invoice/IbanValidator.java | 29 +++ .../assecutor/pdftool/ui/AddressBookView.java | 29 ++- .../de/assecutor/pdftool/ui/MainView.java | 169 +++++++++--------- 4 files changed, 171 insertions(+), 85 deletions(-) create mode 100644 src/main/java/de/assecutor/pdftool/invoice/IbanValidator.java diff --git a/src/main/java/de/assecutor/pdftool/address/Address.java b/src/main/java/de/assecutor/pdftool/address/Address.java index 103d2f7..1b39fc1 100644 --- a/src/main/java/de/assecutor/pdftool/address/Address.java +++ b/src/main/java/de/assecutor/pdftool/address/Address.java @@ -26,6 +26,11 @@ public class Address { private String email; private String phone; + // Bankverbindung (v. a. für Rechnungssteller relevant) + private String bankName; + private String iban; + private String bic; + private LocalDateTime createdAt; private LocalDateTime updatedAt; @@ -114,6 +119,30 @@ public class Address { this.phone = phone; } + public String getBankName() { + return bankName; + } + + public void setBankName(String bankName) { + this.bankName = bankName; + } + + public String getIban() { + return iban; + } + + public void setIban(String iban) { + this.iban = iban; + } + + public String getBic() { + return bic; + } + + public void setBic(String bic) { + this.bic = bic; + } + public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/src/main/java/de/assecutor/pdftool/invoice/IbanValidator.java b/src/main/java/de/assecutor/pdftool/invoice/IbanValidator.java new file mode 100644 index 0000000..52c9214 --- /dev/null +++ b/src/main/java/de/assecutor/pdftool/invoice/IbanValidator.java @@ -0,0 +1,29 @@ +package de.assecutor.pdftool.invoice; + +/** + * Prüft IBANs nach ISO 13616 (MOD 97-10). + */ +public final class IbanValidator { + + private IbanValidator() { + } + + public static boolean isValid(String value) { + String normalized = normalize(value); + if (!normalized.matches("[A-Z]{2}\\d{2}[A-Z0-9]{11,30}")) { + return false; + } + String rearranged = normalized.substring(4) + normalized.substring(0, 4); + int mod = 0; + for (char c : rearranged.toCharArray()) { + int digit = Character.isDigit(c) ? c - '0' : c - 'A' + 10; + mod = digit < 10 ? (mod * 10 + digit) % 97 : (mod * 100 + digit) % 97; + } + return mod == 1; + } + + /** Entfernt Leerzeichen und wandelt in Großbuchstaben um. */ + public static String normalize(String value) { + return value.replaceAll("\\s", "").toUpperCase(); + } +} diff --git a/src/main/java/de/assecutor/pdftool/ui/AddressBookView.java b/src/main/java/de/assecutor/pdftool/ui/AddressBookView.java index 3258321..c7187c7 100644 --- a/src/main/java/de/assecutor/pdftool/ui/AddressBookView.java +++ b/src/main/java/de/assecutor/pdftool/ui/AddressBookView.java @@ -20,6 +20,7 @@ import com.vaadin.flow.router.Route; import de.assecutor.pdftool.address.Address; import de.assecutor.pdftool.address.AddressRepository; import de.assecutor.pdftool.address.AddressType; +import de.assecutor.pdftool.invoice.IbanValidator; import de.assecutor.pdftool.invoice.VatIdValidator; import org.springframework.data.domain.Sort; @@ -122,7 +123,7 @@ public class AddressBookView extends VerticalLayout { Address target = address == null ? new Address() : address; Dialog dialog = new Dialog(); - dialog.setHeaderTitle(isNew ? "Neue Adresse" : "Adresse bearbeiten"); + dialog.setHeaderTitle(isNew ? "Neues Unternehmen" : "Unternehmen bearbeiten"); ComboBox type = new ComboBox<>("Typ"); type.setItems(AddressType.values()); @@ -154,7 +155,17 @@ public class AddressBookView extends VerticalLayout { TextField phone = new TextField("Telefon"); phone.setValue(target.getPhone() != null ? target.getPhone() : ""); - FormLayout form = new FormLayout(type, name, street, zip, city, countryCode, vatId, email, phone); + // Bankverbindung (optional) + TextField bankName = new TextField("Bank"); + bankName.setValue(target.getBankName() != null ? target.getBankName() : ""); + TextField iban = new TextField("IBAN"); + iban.setValue(target.getIban() != null ? target.getIban() : ""); + iban.setHelperText("z. B. DE02 1203 0000 0000 2020 51"); + TextField bic = new TextField("BIC"); + bic.setValue(target.getBic() != null ? target.getBic() : ""); + + FormLayout form = new FormLayout(type, name, street, zip, city, countryCode, vatId, email, phone, + bankName, iban, bic); form.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("500px", 2)); form.setWidth("560px"); dialog.add(form); @@ -189,6 +200,17 @@ public class AddressBookView extends VerticalLayout { email.setInvalid(true); errors.add("E-Mail-Adresse ist ungültig."); } + if (!iban.getValue().isBlank() && !IbanValidator.isValid(iban.getValue())) { + iban.setErrorMessage("IBAN ist ungültig (Prüfsumme)."); + iban.setInvalid(true); + errors.add("IBAN ist ungültig (Prüfsumme)."); + } + if (!bic.getValue().isBlank() + && !bic.getValue().trim().matches("[A-Za-z]{6}[A-Za-z0-9]{2}([A-Za-z0-9]{3})?")) { + bic.setErrorMessage("BIC ist ungültig (8 oder 11 Stellen)."); + bic.setInvalid(true); + errors.add("BIC ist ungültig (8 oder 11 Stellen)."); + } if (!errors.isEmpty()) { Notification.show(String.join("\n", errors), 5000, Notification.Position.MIDDLE) .addThemeVariants(NotificationVariant.LUMO_ERROR); @@ -204,6 +226,9 @@ public class AddressBookView extends VerticalLayout { target.setVatId(vatId.getValue().isBlank() ? "" : VatIdValidator.normalize(vatId.getValue())); target.setEmail(email.getValue().trim()); target.setPhone(phone.getValue().trim()); + target.setBankName(bankName.getValue().trim()); + target.setIban(iban.getValue().isBlank() ? "" : IbanValidator.normalize(iban.getValue())); + target.setBic(bic.getValue().trim().toUpperCase()); target.touch(); repository.save(target); diff --git a/src/main/java/de/assecutor/pdftool/ui/MainView.java b/src/main/java/de/assecutor/pdftool/ui/MainView.java index be839bd..af1b331 100644 --- a/src/main/java/de/assecutor/pdftool/ui/MainView.java +++ b/src/main/java/de/assecutor/pdftool/ui/MainView.java @@ -27,12 +27,11 @@ import com.vaadin.flow.component.upload.receivers.MemoryBuffer; import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; import com.vaadin.flow.server.StreamResource; -import org.springframework.core.env.Environment; -import org.springframework.core.env.Profiles; import de.assecutor.pdftool.address.Address; import de.assecutor.pdftool.address.AddressRepository; import de.assecutor.pdftool.address.AddressType; import de.assecutor.pdftool.invoice.CapturedInvoiceData; +import de.assecutor.pdftool.invoice.IbanValidator; import de.assecutor.pdftool.invoice.InvoiceDraft; import de.assecutor.pdftool.invoice.InvoiceDraftService; import de.assecutor.pdftool.invoice.InvoiceMetadata; @@ -107,9 +106,6 @@ public class MainView extends VerticalLayout { private final DatePicker dueDate = new DatePicker("Fälligkeitsdatum"); private final TextField currency = new TextField("Währung"); private final TextField paymentTerms = new TextField("Zahlungsbedingungen"); - private final TextField buyerReference = new TextField("Käuferreferenz / Leitweg-ID"); - private final TextField iban = new TextField("IBAN (Rechnungssteller)"); - private final TextField bic = new TextField("BIC"); private final PartyForm sender = new PartyForm(true); private final PartyForm recipient = new PartyForm(false); @@ -128,8 +124,7 @@ public class MainView extends VerticalLayout { public MainView(ZugferdService zugferdService, CapturedInvoiceData capturedInvoiceData, InvoiceTemplateService invoiceTemplateService, TemplatePdfService templatePdfService, - AddressRepository addressRepository, InvoiceDraftService invoiceDraftService, - Environment environment) { + AddressRepository addressRepository, InvoiceDraftService invoiceDraftService) { this.zugferdService = zugferdService; this.capturedInvoiceData = capturedInvoiceData; this.invoiceTemplateService = invoiceTemplateService; @@ -209,10 +204,8 @@ public class MainView extends VerticalLayout { currency.setPattern("[A-Za-z]{3}"); currency.setErrorMessage("Währung als dreistelliger ISO-Code, z. B. EUR."); currency.setValue("EUR"); - buyerReference.setHelperText("BT-10 — für Rechnungen an Behörden erforderlich"); - iban.setHelperText("Für die Zahlungsangaben (BG-16) empfohlen"); FormLayout invoiceForm = new FormLayout(invoiceNumber, issueDate, deliveryDate, dueDate, currency, - paymentTerms, buyerReference, iban, bic); + paymentTerms); invoiceForm.setResponsiveSteps(new FormLayout.ResponsiveStep("0", 1), new FormLayout.ResponsiveStep("600px", 3)); add(new H3("2. Rechnungsdaten"), invoiceForm); @@ -240,10 +233,6 @@ public class MainView extends VerticalLayout { registerDraftDirtyTracking(); updateSaveDraftEnabled(); - - if (environment.acceptsProfiles(Profiles.of("dev"))) { - prefillDevData(); - } } @Override @@ -325,9 +314,9 @@ public class MainView extends VerticalLayout { issueDate.getValue(), deliveryDate.getValue(), dueDate.getValue(), currency.getValue().trim(), paymentTerms.getValue(), - buyerReference.getValue().trim(), - iban.getValue().trim(), - bic.getValue().trim(), + "", + sender.ibanNormalized(), + sender.bicNormalized(), sender.toParty(), recipient.toParty(), List.copyOf(lineItems), LocalDateTime.now()); @@ -358,10 +347,8 @@ public class MainView extends VerticalLayout { dueDate.setValue(draft.dueDate()); currency.setValue(nullToEmpty(draft.currency())); paymentTerms.setValue(nullToEmpty(draft.paymentTerms())); - buyerReference.setValue(nullToEmpty(draft.buyerReference())); - iban.setValue(nullToEmpty(draft.iban())); - bic.setValue(nullToEmpty(draft.bic())); applyParty(sender, draft.sender()); + sender.prefillBank(nullToEmpty(draft.iban()), nullToEmpty(draft.bic())); applyParty(recipient, draft.recipient()); lineItems.clear(); if (draft.items() != null) { @@ -382,7 +369,7 @@ public class MainView extends VerticalLayout { form.prefill(nullToEmpty(party.name()), nullToEmpty(party.street()), nullToEmpty(party.zip()), nullToEmpty(party.city()), nullToEmpty(party.countryCode()), nullToEmpty(party.vatId()), - nullToEmpty(party.email()), nullToEmpty(party.phone())); + nullToEmpty(party.email())); } /** Konfiguriert eine Adressbuch-Auswahl, die bei Auswahl das zugehörige Adressformular vorbelegt. */ @@ -397,7 +384,8 @@ public class MainView extends VerticalLayout { form.prefill(nullToEmpty(address.getName()), nullToEmpty(address.getStreet()), nullToEmpty(address.getZip()), nullToEmpty(address.getCity()), nullToEmpty(address.getCountryCode()), nullToEmpty(address.getVatId()), - nullToEmpty(address.getEmail()), nullToEmpty(address.getPhone())); + nullToEmpty(address.getEmail())); + form.prefillBank(nullToEmpty(address.getIban()), nullToEmpty(address.getBic())); } }); } @@ -415,22 +403,6 @@ public class MainView extends VerticalLayout { return List.copyOf(lineItems); } - /** Belegt das Formular im dev-Profil mit Testdaten vor, um manuelles Tippen zu sparen. */ - private void prefillDevData() { - invoiceNumber.setValue("11111"); - deliveryDate.setValue(LocalDate.now()); - dueDate.setValue(LocalDate.now().plusDays(14)); - paymentTerms.setValue("14 Tage"); - buyerReference.setValue("11111"); - iban.setValue("DE64 6001 0070 0379 0907 00"); - bic.setValue("PBNKDEFFXXX"); - // Rechnungssteller und -empfänger bleiben leer und werden über die - // Adressbuch-Auswahl befüllt. - lineItems.add(new InvoiceMetadata.LineItem("sdf", BigDecimal.ONE, new BigDecimal("100"), - new BigDecimal("19"))); - refreshItemsGrid(); - } - /** Tabelle der Rechnungspositionen mit Bearbeiten-/Löschen-Icons pro Zeile. */ private void configureItemsGrid() { itemsGrid.addColumn(item -> item.description()).setHeader("Beschreibung").setFlexGrow(1); @@ -471,20 +443,28 @@ public class MainView extends VerticalLayout { dueDate.addValueChangeListener(event -> updateSaveDraftEnabled()); currency.addValueChangeListener(event -> updateSaveDraftEnabled()); paymentTerms.addValueChangeListener(event -> updateSaveDraftEnabled()); - buyerReference.addValueChangeListener(event -> updateSaveDraftEnabled()); - iban.addValueChangeListener(event -> updateSaveDraftEnabled()); - bic.addValueChangeListener(event -> updateSaveDraftEnabled()); templateSelect.addValueChangeListener(event -> updateSaveDraftEnabled()); sender.onAnyChange(this::updateSaveDraftEnabled); recipient.onAnyChange(this::updateSaveDraftEnabled); } /** - * Der Speichern-Button ist nur aktiv, wenn keine Vorbelegung geladen wurde - * oder sich die Eingaben seit dem Laden bzw. Speichern geändert haben. + * Der Speichern-Button ist nur aktiv, wenn alle Pflichtfelder gefüllt sind + * und keine Vorbelegung geladen wurde oder sich die Eingaben seit dem Laden + * bzw. Speichern geändert haben. */ private void updateSaveDraftEnabled() { - saveDraftButton.setEnabled(loadedDraftState == null || !comparableState().equals(loadedDraftState)); + boolean changed = loadedDraftState == null || !comparableState().equals(loadedDraftState); + saveDraftButton.setEnabled(changed && requiredFieldsFilled()); + } + + /** @return true, wenn alle Pflichtfelder der Maske gefüllt sind. */ + private boolean requiredFieldsFilled() { + return !invoiceNumber.getValue().isBlank() + && issueDate.getValue() != null + && !currency.getValue().isBlank() + && sender.requiredFieldsFilled() + && recipient.requiredFieldsFilled(); } /** Der aktuelle Formularstand ohne Name und Zeitstempel, zum Vergleich mit der geladenen Vorbelegung. */ @@ -495,9 +475,9 @@ public class MainView extends VerticalLayout { issueDate.getValue(), deliveryDate.getValue(), dueDate.getValue(), currency.getValue().trim(), paymentTerms.getValue(), - buyerReference.getValue().trim(), - iban.getValue().trim(), - bic.getValue().trim(), + "", + sender.ibanNormalized(), + sender.bicNormalized(), sender.toParty(), recipient.toParty(), List.copyOf(lineItems), null); @@ -694,16 +674,6 @@ public class MainView extends VerticalLayout { && !currency.getValue().trim().matches("[A-Za-z]{3}")) { markInvalid(currency, "Währung als dreistelliger ISO-Code, z. B. EUR.", errors); } - if (!iban.isEmpty() && !isValidIban(iban.getValue())) { - markInvalid(iban, "IBAN ist ungültig (Prüfsumme).", errors); - } else { - iban.setInvalid(false); - } - if (!bic.isEmpty() && !bic.getValue().trim().matches("[A-Za-z]{6}[A-Za-z0-9]{2}([A-Za-z0-9]{3})?")) { - markInvalid(bic, "BIC muss 8 oder 11 Stellen haben, z. B. NOLADE21RZB.", errors); - } else { - bic.setInvalid(false); - } errors.addAll(sender.validateFields("Rechnungssteller")); errors.addAll(recipient.validateFields("Rechnungsempfänger")); if (lineItems.isEmpty()) { @@ -720,9 +690,9 @@ public class MainView extends VerticalLayout { dueDate.getValue(), currency.getValue().trim().toUpperCase(), paymentTerms.getValue(), - buyerReference.getValue().trim(), - iban.getValue().replaceAll("\\s", "").toUpperCase(), - bic.getValue().trim().toUpperCase(), + "", + sender.ibanNormalized(), + sender.bicNormalized(), sender.toParty(), recipient.toParty(), lineItems @@ -760,21 +730,6 @@ public class MainView extends VerticalLayout { errors.add(message); } - /** IBAN-Prüfung nach ISO 13616 (MOD 97-10). */ - private static boolean isValidIban(String value) { - String normalized = value.replaceAll("\\s", "").toUpperCase(); - if (!normalized.matches("[A-Z]{2}\\d{2}[A-Z0-9]{11,30}")) { - return false; - } - String rearranged = normalized.substring(4) + normalized.substring(0, 4); - int mod = 0; - for (char c : rearranged.toCharArray()) { - int digit = Character.isDigit(c) ? c - '0' : c - 'A' + 10; - mod = digit < 10 ? (mod * 10 + digit) % 97 : (mod * 100 + digit) % 97; - } - return mod == 1; - } - private void showErrors(List messages) { Notification notification = new Notification(); notification.addThemeVariants(NotificationVariant.LUMO_ERROR); @@ -802,7 +757,10 @@ public class MainView extends VerticalLayout { private final TextField countryCode = new TextField("Land (ISO-Code, z.B. DE)"); private final TextField vatId = new TextField("USt-IdNr."); private final EmailField email = new EmailField("E-Mail"); - private final TextField phone = new TextField("Telefon"); + + // Bankverbindung; nur beim Rechnungssteller sichtbar und Pflicht + private final TextField iban = new TextField("IBAN"); + private final TextField bic = new TextField("BIC"); PartyForm(boolean vatRequired) { this.vatRequired = vatRequired; @@ -824,15 +782,35 @@ public class MainView extends VerticalLayout { email.setErrorMessage("Gültige E-Mail-Adresse angeben."); // E-Mail direkt beim Verlassen des Feldes prüfen email.addValueChangeListener(event -> checkEmail()); + add(name, street, zip, city, countryCode, vatId, email); if (vatRequired) { - phone.setHelperText("Für den Verkäufer-Kontakt (BR-DE-6) empfohlen"); + iban.setRequired(true); + iban.setHelperText("Für die Zahlungsangaben (BG-16)"); + bic.setRequired(true); + bic.setHelperText("8 oder 11 Stellen, z. B. NOLADE21RZB"); + add(iban, bic); } - add(name, street, zip, city, countryCode, vatId, email, phone); setResponsiveSteps(new ResponsiveStep("0", 1), new ResponsiveStep("600px", 3)); } + /** Belegt die Bankverbindung vor, z. B. aus dem Adressbuch. */ + void prefillBank(String ibanValue, String bicValue) { + iban.setValue(ibanValue); + bic.setValue(bicValue); + } + + /** IBAN ohne Leerzeichen in Großschreibung. */ + String ibanNormalized() { + return iban.getValue().replaceAll("\\s", "").toUpperCase(); + } + + /** BIC ohne führende/abschließende Leerzeichen in Großschreibung. */ + String bicNormalized() { + return bic.getValue().trim().toUpperCase(); + } + void prefill(String nameValue, String streetValue, String zipValue, String cityValue, - String countryValue, String vatIdValue, String emailValue, String phoneValue) { + String countryValue, String vatIdValue, String emailValue) { name.setValue(nameValue); street.setValue(streetValue); zip.setValue(zipValue); @@ -840,7 +818,18 @@ public class MainView extends VerticalLayout { countryCode.setValue(countryValue); vatId.setValue(vatIdValue); email.setValue(emailValue); - phone.setValue(phoneValue); + } + + /** @return true, wenn alle Pflichtfelder des Adressformulars gefüllt sind. */ + boolean requiredFieldsFilled() { + return !name.getValue().isBlank() + && !street.getValue().isBlank() + && !zip.getValue().isBlank() + && !city.getValue().isBlank() + && !countryCode.getValue().isBlank() + && (!vatRequired || !vatId.getValue().isBlank()) + && !email.getValue().isBlank() + && (!vatRequired || (!iban.getValue().isBlank() && !bic.getValue().isBlank())); } /** Meldet jede Wertänderung eines Feldes, z. B. für die Dirty-Erkennung. */ @@ -852,12 +841,14 @@ public class MainView extends VerticalLayout { countryCode.addValueChangeListener(event -> listener.run()); vatId.addValueChangeListener(event -> listener.run()); email.addValueChangeListener(event -> listener.run()); - phone.addValueChangeListener(event -> listener.run()); + iban.addValueChangeListener(event -> listener.run()); + bic.addValueChangeListener(event -> listener.run()); } /** Leert alle Felder; das Land behält den Standardwert "DE". */ void clearFields() { - prefill("", "", "", "", "DE", "", "", ""); + prefill("", "", "", "", "DE", "", ""); + prefillBank("", ""); name.setInvalid(false); street.setInvalid(false); zip.setInvalid(false); @@ -865,6 +856,8 @@ public class MainView extends VerticalLayout { countryCode.setInvalid(false); vatId.setInvalid(false); email.setInvalid(false); + iban.setInvalid(false); + bic.setInvalid(false); } /** @return true, wenn die E-Mail-Adresse gültig (oder leer) ist. */ @@ -915,6 +908,16 @@ public class MainView extends VerticalLayout { } else if (!checkEmail()) { errors.add(partyLabel + ": E-Mail-Adresse ist ungültig."); } + if (vatRequired) { + if (requireFilled(iban, partyLabel + ": IBAN ist ein Pflichtfeld.", errors) + && !IbanValidator.isValid(iban.getValue())) { + markInvalid(iban, partyLabel + ": IBAN ist ungültig (Prüfsumme).", errors); + } + if (requireFilled(bic, partyLabel + ": BIC ist ein Pflichtfeld.", errors) + && !bic.getValue().trim().matches("[A-Za-z]{6}[A-Za-z0-9]{2}([A-Za-z0-9]{3})?")) { + markInvalid(bic, partyLabel + ": BIC muss 8 oder 11 Stellen haben, z. B. NOLADE21RZB.", errors); + } + } return errors; } @@ -927,7 +930,7 @@ public class MainView extends VerticalLayout { countryCode.getValue().trim().toUpperCase(), vatId.isEmpty() ? "" : VatIdValidator.normalize(vatId.getValue()), email.getValue().trim(), - phone.getValue().trim() + "" ); } }