diff --git a/.gitignore b/.gitignore
index 894a44c..78a2672 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,6 +55,8 @@ coverage.xml
*.log
local_settings.py
db.sqlite3
+*.sqlite3
+*.db
# Flask stuff:
instance/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4620dc3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,47 @@
+# IBAN Desktop Checker (Tkinter)
+
+Десктопное приложение на Python + Tkinter для:
+
+- валидации IBAN (RU/BY, длина 33, MOD-97 ISO 7064),
+- извлечения БИК (символы 5–13),
+- поиска реквизитов банка по БИК в локальной SQLite базе,
+- поиска SWIFT по таблице `bic_to_swift` (с поддержкой ручного ввода),
+- вывода ссылки на сайт банка (из данных ЦБ, либо через поисковую ссылку).
+
+## Что делает приложение
+
+1. Удаляет пробелы из IBAN.
+2. Проверяет:
+ - длину 33,
+ - префикс `RU` или `BY`,
+ - контрольную сумму MOD-97.
+3. Извлекает БИК из позиции 5..13.
+4. При первом запуске загружает XML-справочник с официального сайта ЦБ РФ:
+ - `https://www.cbr.ru/scripts/XML_bic2.asp`
+ - (резервно) `https://www.cbr.ru/scripts/XML_bic.asp`
+5. Импортирует данные в SQLite БД (`data/banks.sqlite3`) в таблицы:
+ - `banks(bic, bank_name, corr_account, address, inn, kpp, ogrn)`
+ - `bic_to_swift(bic, swift)`
+6. Выводит реквизиты строго по списку:
+ - Наименование банка
+ - Адрес
+ - ИНН
+ - ОГРН
+ - SWIFT
+ - КПП
+ - Данные корреспондентских счетов
+ - Сайт банка
+
+## Установка и запуск
+
+```bash
+python3 -m venv .venv
+source .venv/bin/activate
+pip install -r requirements.txt
+python3 iban_desktop_app.py
+```
+
+## Примечания
+
+- Поля `КПП` и `Данные корреспондентских счетов` в открытом XML-справочнике ЦБ могут отсутствовать для части записей — в интерфейсе будет показано `Не найдено`.
+- Таблица `bic_to_swift` предзаполнена для ряда крупных банков и может вручную расширяться через форму в приложении.
diff --git a/iban_desktop_app.py b/iban_desktop_app.py
new file mode 100644
index 0000000..cc22949
--- /dev/null
+++ b/iban_desktop_app.py
@@ -0,0 +1,575 @@
+#!/usr/bin/env python3
+"""Desktop IBAN checker with Tkinter + SQLite."""
+
+from __future__ import annotations
+
+import datetime as dt
+import html
+import re
+import sqlite3
+import xml.etree.ElementTree as ET
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+from urllib.parse import quote_plus
+
+import requests
+
+try:
+ import tkinter as tk
+ from tkinter import messagebox, ttk
+except ModuleNotFoundError: # pragma: no cover - depends on system package
+ tk = None
+ messagebox = None
+ ttk = None
+
+
+CBR_BIC_XML_URLS = (
+ "https://www.cbr.ru/scripts/XML_bic2.asp",
+ "https://www.cbr.ru/scripts/XML_bic.asp",
+)
+CBR_FOINFO_BY_OGRN_URL = "https://www.cbr.ru/finorg/foinfo/?ogrn={ogrn}"
+
+DATABASE_PATH = Path(__file__).resolve().parent / "data" / "banks.sqlite3"
+
+DEFAULT_SWIFT_MAPPINGS: dict[str, str] = {
+ "044525225": "SABRRUMM", # Сбербанк
+ "044525187": "VTBRRUMM", # ВТБ
+ "044525823": "GAZPRUMM", # Газпромбанк
+ "044525593": "ALFARUMM", # Альфа-Банк
+ "044525700": "RZBMRUMM", # Райффайзенбанк
+ "044525111": "RUAGRUMM", # Россельхозбанк
+ "044525545": "IMBKRUMM", # ЮниКредит Банк
+ "044525974": "TICSRUMM", # ТБанк
+}
+
+RE_TAG = re.compile(r"<[^>]+>")
+RE_SPACE = re.compile(r"\s+")
+RE_COINF_ITEM = re.compile(
+ r'
\s*'
+ r'
]*>(.*?)
\s*'
+ r"
",
+ flags=re.IGNORECASE | re.DOTALL,
+)
+
+
+class ValidationError(ValueError):
+ """Raised when IBAN validation fails."""
+
+
+@dataclass(frozen=True)
+class IbanValidationResult:
+ iban_clean: str
+ bic: str
+
+
+def clean_text(value: str) -> str:
+ """Remove HTML tags + normalize whitespace."""
+ no_tags = RE_TAG.sub(" ", value)
+ unescaped = html.unescape(no_tags)
+ return RE_SPACE.sub(" ", unescaped).strip()
+
+
+def iban_mod97(iban: str) -> int:
+ """Calculate MOD-97 remainder according to ISO 7064."""
+ rearranged = iban[4:] + iban[:4]
+ remainder = 0
+
+ for char in rearranged:
+ if char.isdigit():
+ converted = char
+ else:
+ converted = str(ord(char) - 55) # A=10, B=11 ... Z=35
+
+ for digit in converted:
+ remainder = (remainder * 10 + int(digit)) % 97
+
+ return remainder
+
+
+def validate_iban(raw_iban: str) -> IbanValidationResult:
+ """Validate IBAN and extract BIC from positions 5..13."""
+ iban = "".join(raw_iban.split()).upper()
+
+ if not iban:
+ raise ValidationError("Введите IBAN.")
+ if len(iban) != 33:
+ raise ValidationError("Некорректная длина IBAN: должна быть 33 символа.")
+ if not iban.startswith(("RU", "BY")):
+ raise ValidationError("Префикс IBAN должен быть 'RU' или 'BY'.")
+ if not iban.isalnum():
+ raise ValidationError("IBAN может содержать только буквы и цифры.")
+ if iban_mod97(iban) != 1:
+ raise ValidationError("Контрольная сумма IBAN (MOD-97) не прошла проверку.")
+
+ bic = iban[4:13]
+ if not bic.isdigit():
+ raise ValidationError("Извлеченный БИК (символы 5-13) должен содержать только цифры.")
+
+ return IbanValidationResult(iban_clean=iban, bic=bic)
+
+
+class BankRepository:
+ """SQLite storage for bank info and BIC->SWIFT mapping."""
+
+ def __init__(self, db_path: Path) -> None:
+ self.db_path = db_path
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
+ self.conn = sqlite3.connect(self.db_path)
+ self.conn.row_factory = sqlite3.Row
+ self._init_schema()
+
+ def _init_schema(self) -> None:
+ with self.conn:
+ self.conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS banks (
+ bic TEXT PRIMARY KEY,
+ bank_name TEXT NOT NULL,
+ corr_account TEXT,
+ address TEXT,
+ inn TEXT,
+ kpp TEXT,
+ ogrn TEXT
+ )
+ """
+ )
+ self.conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS bic_to_swift (
+ bic TEXT PRIMARY KEY,
+ swift TEXT NOT NULL
+ )
+ """
+ )
+ self.conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS metadata (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+ )
+ """
+ )
+
+ def close(self) -> None:
+ self.conn.close()
+
+ def banks_count(self) -> int:
+ row = self.conn.execute("SELECT COUNT(*) AS count FROM banks").fetchone()
+ return int(row["count"]) if row else 0
+
+ def replace_banks(self, rows: list[dict[str, str | None]]) -> None:
+ with self.conn:
+ self.conn.execute("DELETE FROM banks")
+ self.conn.executemany(
+ """
+ INSERT INTO banks (bic, bank_name, corr_account, address, inn, kpp, ogrn)
+ VALUES (:bic, :bank_name, :corr_account, :address, :inn, :kpp, :ogrn)
+ """,
+ rows,
+ )
+
+ def seed_swift(self, mappings: dict[str, str]) -> None:
+ with self.conn:
+ self.conn.executemany(
+ """
+ INSERT INTO bic_to_swift (bic, swift)
+ VALUES (?, ?)
+ ON CONFLICT(bic) DO NOTHING
+ """,
+ [(bic, swift) for bic, swift in mappings.items()],
+ )
+
+ def upsert_swift(self, bic: str, swift: str) -> None:
+ with self.conn:
+ self.conn.execute(
+ """
+ INSERT INTO bic_to_swift (bic, swift)
+ VALUES (?, ?)
+ ON CONFLICT(bic) DO UPDATE SET swift = excluded.swift
+ """,
+ (bic, swift),
+ )
+
+ def get_swift(self, bic: str) -> str | None:
+ row = self.conn.execute(
+ "SELECT swift FROM bic_to_swift WHERE bic = ?",
+ (bic,),
+ ).fetchone()
+ return row["swift"] if row else None
+
+ def get_bank_by_bic(self, bic: str) -> dict[str, Any] | None:
+ row = self.conn.execute(
+ """
+ SELECT bic, bank_name, corr_account, address, inn, kpp, ogrn
+ FROM banks
+ WHERE bic = ?
+ """,
+ (bic,),
+ ).fetchone()
+ if row is None:
+ return None
+ return dict(row)
+
+ def update_bank_fields(
+ self,
+ bic: str,
+ bank_name: str | None = None,
+ corr_account: str | None = None,
+ address: str | None = None,
+ inn: str | None = None,
+ kpp: str | None = None,
+ ogrn: str | None = None,
+ ) -> None:
+ updates: dict[str, str] = {}
+ for key, value in {
+ "bank_name": bank_name,
+ "corr_account": corr_account,
+ "address": address,
+ "inn": inn,
+ "kpp": kpp,
+ "ogrn": ogrn,
+ }.items():
+ if value is not None and str(value).strip():
+ updates[key] = str(value).strip()
+
+ if not updates:
+ return
+
+ assignment = ", ".join(f"{column} = ?" for column in updates)
+ params = list(updates.values()) + [bic]
+
+ with self.conn:
+ self.conn.execute(
+ f"UPDATE banks SET {assignment} WHERE bic = ?",
+ params,
+ )
+
+ def set_metadata(self, key: str, value: str) -> None:
+ with self.conn:
+ self.conn.execute(
+ """
+ INSERT INTO metadata (key, value)
+ VALUES (?, ?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
+ """,
+ (key, value),
+ )
+
+ def get_metadata(self, key: str) -> str | None:
+ row = self.conn.execute(
+ "SELECT value FROM metadata WHERE key = ?",
+ (key,),
+ ).fetchone()
+ return row["value"] if row else None
+
+
+class CbrDirectoryClient:
+ """Work with official CBR XML + details pages."""
+
+ def __init__(self, timeout_seconds: int = 30) -> None:
+ self.timeout_seconds = timeout_seconds
+
+ def download_bic_directory(self) -> tuple[list[dict[str, str | None]], str]:
+ last_error: Exception | None = None
+
+ for url in CBR_BIC_XML_URLS:
+ try:
+ response = requests.get(url, timeout=self.timeout_seconds)
+ response.raise_for_status()
+ rows = self._parse_bic_xml(response.content)
+ if rows:
+ return rows, url
+ except Exception as exc: # pragma: no cover - network branch
+ last_error = exc
+
+ if last_error is not None:
+ raise RuntimeError(f"Не удалось загрузить XML справочник БИК: {last_error}") from last_error
+ raise RuntimeError("Не удалось загрузить XML справочник БИК.")
+
+ @staticmethod
+ def _parse_bic_xml(xml_bytes: bytes) -> list[dict[str, str | None]]:
+ root = ET.fromstring(xml_bytes)
+ rows: list[dict[str, str | None]] = []
+
+ for record in root.findall(".//Record"):
+ fields: dict[str, str] = {}
+ for child in list(record):
+ key = child.tag.strip().lower()
+ value = (child.text or "").strip()
+ fields[key] = value
+
+ bic = fields.get("bic", "")
+ bank_name = fields.get("shortname", "") or fields.get("name", "")
+ reg_num = fields.get("regnum", "")
+
+ if not (bic and bank_name):
+ continue
+
+ ogrn = reg_num if reg_num.isdigit() and len(reg_num) == 13 else None
+ rows.append(
+ {
+ "bic": bic,
+ "bank_name": bank_name,
+ "corr_account": None,
+ "address": None,
+ "inn": None,
+ "kpp": None,
+ "ogrn": ogrn,
+ }
+ )
+
+ return rows
+
+ def fetch_details_by_ogrn(self, ogrn: str) -> dict[str, str]:
+ if not (ogrn and ogrn.isdigit()):
+ return {}
+
+ url = CBR_FOINFO_BY_OGRN_URL.format(ogrn=ogrn)
+ response = requests.get(url, timeout=self.timeout_seconds)
+ response.raise_for_status()
+ page = response.text
+
+ parsed: dict[str, str] = {}
+ for title_html, value_html in RE_COINF_ITEM.findall(page):
+ title = clean_text(title_html)
+ value = clean_text(value_html)
+ if title and value:
+ parsed[title] = value
+
+ if title == "Интернет-ресурсы":
+ link_match = re.search(r'href="([^"]+)"', value_html, flags=re.IGNORECASE)
+ if link_match:
+ parsed["site_url"] = html.unescape(link_match.group(1)).strip()
+
+ mapped = {
+ "bank_name": parsed.get("Полное (фирменное) наименование")
+ or parsed.get("Сокращенное (фирменное) наименование")
+ or "",
+ "address": parsed.get("Адрес в пределах места нахождения", ""),
+ "inn": parsed.get("ИНН", ""),
+ "kpp": parsed.get("КПП", ""),
+ "ogrn": parsed.get("ОГРН", ogrn),
+ "corr_account": parsed.get("Корреспондентский счет", "")
+ or parsed.get("Корреспондентский счёт", ""),
+ "site_url": parsed.get("site_url", ""),
+ }
+ return {k: v for k, v in mapped.items() if v}
+
+
+class IbanDesktopApp:
+ """Main Tkinter UI."""
+
+ def __init__(self, root: tk.Tk) -> None:
+ self.root = root
+ self.root.title("IBAN / БИК / SWIFT - Проверка реквизитов")
+ self.root.geometry("860x560")
+
+ self.repo = BankRepository(DATABASE_PATH)
+ self.cbr_client = CbrDirectoryClient()
+
+ self.status_var = tk.StringVar(value="Инициализация...")
+ self.iban_var = tk.StringVar()
+ self.swift_bic_var = tk.StringVar()
+ self.swift_code_var = tk.StringVar()
+
+ self._build_ui()
+ self._initialize_data()
+
+ self.root.protocol("WM_DELETE_WINDOW", self._on_close)
+
+ def _build_ui(self) -> None:
+ container = ttk.Frame(self.root, padding=12)
+ container.pack(fill=tk.BOTH, expand=True)
+
+ title = ttk.Label(
+ container,
+ text="Проверка IBAN и поиск реквизитов банка по БИК",
+ font=("Arial", 14, "bold"),
+ )
+ title.pack(anchor="w", pady=(0, 12))
+
+ input_frame = ttk.Frame(container)
+ input_frame.pack(fill=tk.X, pady=(0, 10))
+
+ ttk.Label(input_frame, text="IBAN:").pack(side=tk.LEFT)
+ iban_entry = ttk.Entry(input_frame, textvariable=self.iban_var, width=48)
+ iban_entry.pack(side=tk.LEFT, padx=8)
+ iban_entry.focus_set()
+
+ check_button = ttk.Button(input_frame, text="Проверить", command=self.on_check_clicked)
+ check_button.pack(side=tk.LEFT)
+
+ swift_frame = ttk.LabelFrame(container, text="Ручной ввод SWIFT")
+ swift_frame.pack(fill=tk.X, pady=(0, 10))
+
+ ttk.Label(swift_frame, text="БИК").grid(row=0, column=0, padx=8, pady=8, sticky="w")
+ ttk.Entry(swift_frame, textvariable=self.swift_bic_var, width=16).grid(
+ row=0, column=1, padx=8, pady=8, sticky="w"
+ )
+ ttk.Label(swift_frame, text="SWIFT").grid(row=0, column=2, padx=8, pady=8, sticky="w")
+ ttk.Entry(swift_frame, textvariable=self.swift_code_var, width=16).grid(
+ row=0, column=3, padx=8, pady=8, sticky="w"
+ )
+ ttk.Button(swift_frame, text="Сохранить", command=self.on_save_swift_clicked).grid(
+ row=0, column=4, padx=8, pady=8, sticky="w"
+ )
+
+ output_frame = ttk.LabelFrame(container, text="Результат")
+ output_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
+
+ self.output_text = tk.Text(output_frame, height=16, wrap=tk.WORD)
+ self.output_text.pack(fill=tk.BOTH, expand=True, padx=8, pady=8)
+ self.output_text.configure(state=tk.DISABLED)
+
+ status = ttk.Label(
+ container,
+ textvariable=self.status_var,
+ foreground="#1c4d99",
+ )
+ status.pack(anchor="w")
+
+ def _initialize_data(self) -> None:
+ self.repo.seed_swift(DEFAULT_SWIFT_MAPPINGS)
+
+ if self.repo.banks_count() > 0:
+ source = self.repo.get_metadata("bic_source_url") or "неизвестно"
+ loaded_at = self.repo.get_metadata("bic_loaded_at") or "неизвестно"
+ self.status_var.set(
+ f"Справочник БИК уже загружен ({self.repo.banks_count()} записей). "
+ f"Источник: {source}. Загрузка: {loaded_at}"
+ )
+ return
+
+ self.status_var.set("Первая загрузка справочника БИК из XML ЦБ...")
+ self.root.update_idletasks()
+
+ try:
+ rows, source_url = self.cbr_client.download_bic_directory()
+ self.repo.replace_banks(rows)
+ self.repo.set_metadata("bic_source_url", source_url)
+ self.repo.set_metadata("bic_loaded_at", dt.datetime.now().isoformat(timespec="seconds"))
+ self.status_var.set(
+ f"Справочник БИК загружен: {len(rows)} записей (источник: {source_url})."
+ )
+ except Exception as exc:
+ self.status_var.set("Ошибка загрузки справочника БИК.")
+ messagebox.showwarning(
+ "Предупреждение",
+ f"Не удалось загрузить справочник БИК с сайта ЦБ.\n{exc}",
+ )
+
+ def on_check_clicked(self) -> None:
+ try:
+ validation = validate_iban(self.iban_var.get())
+ except ValidationError as exc:
+ self._set_output(str(exc))
+ return
+
+ self.swift_bic_var.set(validation.bic)
+
+ bank = self.repo.get_bank_by_bic(validation.bic)
+ if bank is None:
+ self._set_output(f"БИК {validation.bic} отсутствует в справочнике.")
+ return
+
+ details_site_url = ""
+ if not bank.get("inn") and bank.get("ogrn"):
+ try:
+ details = self.cbr_client.fetch_details_by_ogrn(str(bank["ogrn"]))
+ except Exception:
+ details = {}
+
+ if details:
+ self.repo.update_bank_fields(
+ validation.bic,
+ bank_name=details.get("bank_name"),
+ corr_account=details.get("corr_account"),
+ address=details.get("address"),
+ inn=details.get("inn"),
+ kpp=details.get("kpp"),
+ ogrn=details.get("ogrn"),
+ )
+ details_site_url = details.get("site_url", "")
+ bank = self.repo.get_bank_by_bic(validation.bic) or bank
+
+ swift = self.repo.get_swift(validation.bic)
+ if not swift:
+ swift = "Не найдено"
+
+ bank_site = details_site_url or self._build_search_link(str(bank["bank_name"]))
+
+ self._set_output(
+ "\n".join(
+ [
+ f"Наименование банка: {self._value_or_not_found(bank.get('bank_name'))}",
+ f"Адрес: {self._value_or_not_found(bank.get('address'))}",
+ f"ИНН: {self._value_or_not_found(bank.get('inn'))}",
+ f"ОГРН: {self._value_or_not_found(bank.get('ogrn'))}",
+ f"SWIFT: {swift}",
+ f"КПП: {self._value_or_not_found(bank.get('kpp'))}",
+ "Данные корреспондентских счетов: "
+ f"{self._value_or_not_found(bank.get('corr_account'))}",
+ f"Сайт банка: {bank_site}",
+ ]
+ )
+ )
+ self.status_var.set(f"Проверка выполнена. Извлеченный БИК: {validation.bic}")
+
+ def on_save_swift_clicked(self) -> None:
+ bic = self.swift_bic_var.get().strip()
+ swift = self.swift_code_var.get().strip().upper()
+
+ if not re.fullmatch(r"\d{9}", bic):
+ messagebox.showerror("Ошибка", "БИК для SWIFT должен состоять из 9 цифр.")
+ return
+ if not re.fullmatch(r"[A-Z0-9]{8}([A-Z0-9]{3})?", swift):
+ messagebox.showerror(
+ "Ошибка",
+ "SWIFT должен быть длиной 8 или 11 символов (латиница/цифры).",
+ )
+ return
+
+ self.repo.upsert_swift(bic=bic, swift=swift)
+ self.status_var.set(f"SWIFT для БИК {bic} сохранен.")
+ messagebox.showinfo("Готово", f"SWIFT {swift} сохранен для БИК {bic}.")
+
+ @staticmethod
+ def _value_or_not_found(value: Any) -> str:
+ if value is None:
+ return "Не найдено"
+ text = str(value).strip()
+ return text if text else "Не найдено"
+
+ @staticmethod
+ def _build_search_link(bank_name: str) -> str:
+ if not bank_name:
+ return "Не найдено"
+ query = quote_plus(f"{bank_name} официальный сайт")
+ return f"https://www.google.com/search?q={query}"
+
+ def _set_output(self, text: str) -> None:
+ self.output_text.configure(state=tk.NORMAL)
+ self.output_text.delete("1.0", tk.END)
+ self.output_text.insert(tk.END, text)
+ self.output_text.configure(state=tk.DISABLED)
+
+ def _on_close(self) -> None:
+ self.repo.close()
+ self.root.destroy()
+
+
+def main() -> None:
+ if tk is None or ttk is None or messagebox is None:
+ raise SystemExit(
+ "Tkinter не установлен в системе. "
+ "Установите пакет python3-tk и запустите приложение повторно."
+ )
+ root = tk.Tk()
+ IbanDesktopApp(root)
+ root.mainloop()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..f229360
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1 @@
+requests