IBAN und BIC Validator (3)

„Nicht mit Erfindungen, sondern mit Verbesserungen macht man Vermögen.“

Henry Ford

Nachdem im vorherigen Beitrag zum Thema, die Prüfung der deutschen IBAN und BIC verbessert wurde, rundet dieser Beitrag die Validierung ab.

Der deutsche BIC wird gegen Einträge in der BLZ.txt geprüft. Für andere Länder steht eine solche Datei vermutlich nicht bereit. Daher liefert die bisherige Implementierung für syntaktisch korrekte BIC den Wert true zurück. Wie bereits erwähnt ist dies ein wenig kontraintuitiv, daher wird das Verhalten an dieser Stelle konfigurierbar gestaltet.

@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = {})
public @interface BIC {
    String message() default "{de.schegge.bank.validator.BIC.message}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    boolean lenient() default false;
}

Die Validierungsannotation BIC erhält einen optionales Attribut lenient. Wird dieses Attribute auf true gesetzt, dann wird das bisherige Verhalten beibehalten. Standardmäßig ist jetzt aber der Wert false gesetzt. Dies bedeutet, ohne einen BankService für die entsprechende Sprache, ist die Validierung nicht mehr erfolgreich.

@Data
private MemberDto {
  // ...
  @BIC
  private String notLenientBic;

  @BIC(lenient=true)
  private String lenientBic;
}

Im obigen Beispiel kann die notLenientBic nur erfolgreich validiert werden, wenn es sich um einen deutsche BIC handelt. Andere Sprachen liefern nur true zurück, wenn jemand einen entsprechenden BankService bereitstellt.

Die Änderungen an der Implementierung sind recht einfach, weil in der letzten Zeile, statt true, nur der aktuelle lenient Wert der Annotation zurückgegeben werden muss.

public class BicValidator implements ConstraintValidator<BIC, String> {
    private static final Pattern PATTERN = Pattern.compile("([A-Z0-9]{4})([A-Z]{2})([A-Z0-9]{2})([A-Z0-9]{3})?");

    private boolean lenient;

    @Override
    public void initialize(BIC constraintAnnotation) {
        this.lenient = constraintAnnotation.lenient();
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
        if (value == null) {
            return true;
        }
        Matcher matcher = PATTERN.matcher(value);
        if (!matcher.matches()) {
            return false;
        }
        Optional<BankService> bankService = BankService.byCountry(matcher.group(2));
        return bankService.map(service -> !service.byBankIdentifierCode(value).isEmpty()).orElseGet(() -> lenient);
    }
}

Ähnliches soll auch für die IBAN möglich sein. Zum einen erhält die Annotation IBAN ein lenient Attribut um das Validierungsverhalten zu ändern. Zum anderen kann über das Attribut type entschieden werden, ob eine IBAN gegen alle unterstützten Länder, den Ländern im SEPA-Raum oder denen außerhalb des SEPA-Raums geprüft werden soll. Teilweise IBAN-Länder (Experimentell) wie Algerien werden momentan noch nicht unterstützt.

@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = {})
public @interface IBAN {
    enum IbanType {
        ALL, SEPA, NO_SEPA
    }

    String message() default "{de.schegge.bank.validator.IBAN.message}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    boolean lenient() default false;

    IbanType type() default IbanType.ALL;
}

Im folgenden Beispiel sind drei Anwendungsfälle der Annotation dargestellt. Die ersten beiden Fälle erlauben unbekannte Ländercodes. Jemand könnte also eine IBAN für Mordor mit dem unbekannten Länderkürzel MD erzeugen und sie würde bei korrekter Prüfsumme die Validierung bestehen. Im zweiten Fall würde die Validierung bei einem bekannten Ländercode aber nur gelingen, wenn es ein SEPA Land wie Deutschland oder Frankreich ist. Der Ländercode von Kuwait würde abgelehnt. Die beiden letzten Annotationen arbeiten ähnlich, aber sie erlauben keine Validierung bei unbekannten Ländercodes.

@Data
private MemberDto {
  // ...
  @IBAN(lenient=true)
  private String lenientIban;
  @IBAN(lenient=true,type=IbanType=SEPA)
  private String lenientSepaIban;
  @IBAN(type=IbanType=SEPA)
  private String nonLenientIban;
  @IBAN
  private String nonLenientSepaIban;
}

Die Implementierung ist wegen der Überprüfung der Ländercodes etwas aufwändiger. Der Großteil der Arbeit ist aber in die Hilfsklasse IbanLocale ausgegliedert.

In der isValid Methode des IbanValidators, wird ein IbanLocale für die aktuelle Sprache geholt und damit einige Prüfungen auf der IBAN durchgeführt.

@Override
public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
    if (value == null) {
        return true;
    }
    Matcher matcher = PATTERN.matcher(value);
    if (!matcher.matches()) {
        return false;
    }
    String countryCode = matcher.group(1);
    String basicBankAccountNumber = matcher.group(3);

    if (isValidChecksum(matcher, countryCode, basicBankAccountNumber)) {
        return false;
    }

    IbanLocale ibanLocale = IbanLocale.byCode(countryCode);
    if (ibanLocale == null) {
        return lenient;
    }
    if (type == IbanType.SEPA && !IbanLocale.allSepaIbanLocales().contains(ibanLocale)) {
        return false;
    }
    if (type == IbanType.NO_SEPA && IbanLocale.allSepaIbanLocales().contains(ibanLocale)) {
        return false;
    }
    if (basicBankAccountNumber.length() != ibanLocale.getLength() - 4) {
        return false;
    }
    Optional<BankService> bankService = BankService.byCountry(countryCode);
    if (bankService.isEmpty()) {
        return lenient;
    }
    return bankService.map(x -> x.byBasicBankAcountNumber(basicBankAccountNumber)).isPresent();
    }

Für IbanType.SEPA wird geschaut, ob der IbanLocale in der entsprechenden Gruppe enthalten ist. Für IbanType.NO_SEPA wird das Gegenteil geprüft. Zusätzlich wird geprüft, ob die aktuelle Länge der IBAN mit der im IbanLocale hinterlegten nationalen Länge übereinstimmt. Bislang war es noch immer möglich, eine deutsche IBAN mit 23 Stellen erfolgreich zu validieren.

Die Klasse IbanLocale ist der Standardklasse Locale nachempfunden. In ihr sind einige IBAN spezifische nationale Daten hinterlegt. Insbesondere die Länge der IBAN und die Zugehörigkeit des Landes zum SEPA-Raum.

public class IbanLocale {
    private static final Map<String, IbanLocale> IBAN_LOCALES = new HashMap<>();

    private static final Set<IbanLocale> SEPA = new HashSet<>();
    public static final IbanLocale GERMANY;

    static {
        GERMANY = createConstant("DE", true, 22);
        try {
            Properties properties = new Properties();
            properties.load(IbanLocale.class.getResourceAsStream("ibanLocales.properties"));
            properties.forEach((code, value) -> createConstant(String.valueOf(code), String.valueOf(value)));
            IBAN_LOCALES.values().stream().filter(IbanLocale::isSepa).forEach(SEPA::add);
        } catch (RuntimeException | IOException e) {
            throw new ExceptionInInitializerError("cannot find iban locales");
        }
    }

    private static void createConstant(String code, String value) {
        String[] parts = value.split(",");
        createConstant(code, "true".equals(parts[0]), Integer.parseInt(parts[1]));
    }

    private static IbanLocale createConstant(String country, boolean sepa, int length) {
        return IBAN_LOCALES.computeIfAbsent(country, c -> new IbanLocale(country, sepa, length));
    }

    public static IbanLocale byCode(String country) {
        return IBAN_LOCALES.get(country);
    }

    public static Set<IbanLocale> allIbanLocales() {
        return Set.copyOf(IBAN_LOCALES.values());
    }

    public static Set<IbanLocale> allSepaIbanLocales() {
        return Set.copyOf(SEPA);
    }

    private final String country;
    private final boolean sepa;
    private final int length;

    public IbanLocale(String country, boolean sepa, int length) {
        this.country = country;
        this.sepa = sepa;
        this.length = length;
    }

    public String getCountry() {
        return country;
    }

    public boolean isSepa() {
        return sepa;
    }

    public int getLength() {
        return length;
    }

    @Override
    public String toString() {
        return "iban country-code=" + country + ", sepa=" + sepa + ", length=" + length;
    }
}

Im static Block der Klasse wird die Konstante GERMANY mit dem IbanLocale für Deutschland initialisiert und alle anderen IbanLocale Instanzen aus einer Property Datei initialisiert. Mit der statischen Methode byCode kann auf das IbanLocale für einen hinterlegten Ländercode zugegriffen werden. Über entsprechende Getter-Methoden der Instanz kann dann auf die jeweilige nationale Konfiguration zugegriffen werden.

Interessierte Leser können die Bibliothek gerne ausprobieren. Sie ist auf Maven Central zu finden.

<dependency>
 <groupId>de.schegge</groupId>
  <artifactId>bank-account-validator</artifactId>
  <version>0.1.0</version>
</dependency>
Hinweis

Am Ende noch einmal der wichtige Hinweis. Eine validierte IBAN ist keine verifizierte IBAN. Eine Verifizierung kann nur die entsprechende Bank oder ein ermächtigter Finanzdienstleister durchführen. Eine valide IBAN mit korrekter Länderkennung und Bankleitzahl kann jede Person erstellen. Dafür sind nur Kenntnisse aus den öffentlichen Quellen notwendig, die auch Grundlage für diese Beiträge waren.

Leave a Comment