Bankverbindung (Bank, IBAN, BIC) im Adressbuch, IBAN/BIC über den Rechnungssteller statt Freitext

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 14:07:56 +02:00
co-authored by Claude Fable 5
parent 722699f5d0
commit 48a5831600
4 changed files with 171 additions and 85 deletions
@@ -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;
}
@@ -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();
}
}
@@ -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<AddressType> 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);
@@ -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<String> 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()
""
);
}
}