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