Files
finance/backend/app/categorization/mcc.py
T
k1nq 63d065011b Update manifest.json and infra README
- Updated timestamps and AST hashes in manifest.json for various backend and frontend files to reflect recent changes.
- Added new files related to MCC categories and optional hold reasons in the manifest.
- Modified the README in the infra directory to correct the linting tool from 'mypy' to 'ty' for type checking in the backend project.
2026-09-23 10:57:27 +05:00

380 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""MCC catalog backed by the iso18245 library with Russian labels.
The global dictionary seeds the ``mcc_codes`` table; the curated default
categories below pre-create the 20 most popular budget categories with their
associated MCC codes (one code may belong to exactly one category per space).
Codes outside the curated categories resolve to the system fallback category
«Остальное» without any manual category creation.
"""
from collections.abc import Iterator
from functools import lru_cache
from typing import NamedTuple
import iso18245
MCC_DICTIONARY_VERSION = 1
OTHERS_SYSTEM_KEY = "others"
OTHERS_CATEGORY_NAME = "Остальное"
_DESCRIPTION_PRIORITY = (
"iso_description",
"visa_description",
"usda_description",
"alipay_description",
"mastercard_description",
"amex_description",
"stripe_description",
)
class DefaultCategory(NamedTuple):
system_key: str
name: str
mcc_codes: tuple[int, ...]
_RU_TITLES: dict[int, str] = {
# Продукты и супермаркеты
5411: "Продуктовые магазины и супермаркеты",
5422: "Мясные и замороженные продукты",
5441: "Кондитерские магазины",
5451: "Молочные продукты",
5462: "Пекарни и булочные",
5499: "Прочие продуктовые магазины",
5300: "Оптовые магазины-склады",
# Кафе и рестораны
5811: "Кейтеринг и обслуживание мероприятий",
5812: "Рестораны и кафе",
5813: "Бары и ночные клубы",
5814: "Фастфуд",
5817: "Цифровые товары — приложения",
5818: "Цифровые товары — крупные покупки",
# Транспорт
4111: "Городской и пригородный транспорт",
4112: "Пассажирские железные дороги",
4121: "Такси и лимузины",
4131: "Автобусные линии",
4784: "Платные дороги и мосты",
4789: "Транспортные услуги (прочие)",
4411: "Круизные линии и пароходства",
# Топливо
5541: "АЗС (автозаправочные станции)",
5542: "Автоматические АЗС",
5172: "Нефть и нефтепродукты",
# Здоровье и аптеки
5912: "Аптеки",
5122: "Лекарственные препараты",
8099: "Медицинские услуги (прочие)",
8011: "Врачи и клиники",
8021: "Стоматологи и ортодонты",
8041: "Хиропрактики",
8042: "Офтальмологи и оптометристы",
8049: "Подологи",
8062: "Больницы",
8071: "Медицинские и стоматологические лаборатории",
5975: "Слуховые аппараты",
5976: "Ортопедические товары и протезы",
# Электроника
5732: "Магазины электроники",
5734: "Магазины компьютерного ПО",
5045: "Компьютеры и периферия",
5046: "Коммерческое оборудование",
7372: "Программирование и ИТ-услуги",
7379: "Обслуживание и ремонт компьютеров",
# Одежда и обувь
5611: "Мужская одежда",
5621: "Женская одежда",
5631: "Женские аксессуары",
5651: "Одежда для всей семьи",
5655: "Спортивная одежда",
5661: "Обувные магазины",
5691: "Одежда (мужская и женская)",
5697: "Ателье и ремонт одежды",
5681: "Меховые салоны",
5949: "Ткани и швейные товары",
5699: "Прочие магазины одежды и аксессуаров",
# Красота и уход
7230: "Парикмахерские и салоны красоты",
7297: "Массажные салоны",
5977: "Магазины косметики",
# Развлечения
7832: "Кинотеатры",
7841: "Прокат видео и стриминг",
7922: "Театры и билетные агентства",
7933: "Боулинг",
7991: "Туристические достопримечательности",
7994: "Игровые автоматы и залы",
7996: "Парки развлечений и цирки",
7997: "Спортивные и членские клубы",
7999: "Развлекательные услуги (прочие)",
7992: "Гольф-клубы",
7998: "Океанариумы и дельфинарии",
7995: "Ставки и лотереи",
# Спорт
5941: "Магазины спортивных товаров",
7941: "Профессиональный спорт и спортивные клубы",
# Путешествия
7011: "Отели и курорты",
7012: "Таймшер",
7032: "Спортивные и рекреационные лагеря",
7033: "Кемпинги и трейлерные парки",
# Дом и ремонт
5211: "Стройматериалы и пиломатериалы",
5251: "Хозяйственные магазины",
5261: "Садовые товары и питомники",
5712: "Мебель и предметы интерьера",
5713: "Напольные покрытия",
5714: "Шторы и обивка",
5718: "Камины и аксессуары",
5719: "Прочие товары для дома",
7622: "Ремонт электроники",
7623: "Ремонт кондиционеров и холодильников",
7629: "Ремонт электроприборов",
7641: "Ремонт и перетяжка мебели",
7692: "Сварочные работы",
7699: "Прочие ремонтные услуги",
1520: "Генеральные подрядчики",
1711: "Отопление, сантехника и кондиционирование",
1731: "Электромонтажные работы",
1740: "Каменные и штукатурные работы",
1750: "Плотницкие работы",
1761: "Кровельные работы",
1771: "Бетонные работы",
1799: "Прочие строительные подрядчики",
5198: "Краски и лаки",
# Связь и интернет
4812: "Продажа телефонов и телеком-оборудования",
4814: "Телекоммуникационные услуги",
4815: "Ежемесячная плата за телефон",
4816: "Компьютерные сети и интернет-услуги",
4821: "Телеграфные услуги",
4899: "Кабельное телевидение",
# Коммунальные услуги
4900: "Коммунальные услуги (электричество, газ, вода)",
# Образование
8211: "Школы",
8220: "Вузы и колледжи",
8241: "Заочные школы",
8244: "Бизнес-школы",
8249: "Профессиональные и технические училища",
8299: "Образовательные услуги (прочие)",
5943: "Канцелярские и школьные товары",
# Дети
5945: "Детские игрушки и игры",
5641: "Детская одежда",
# Питомцы
742: "Ветеринарные услуги",
5995: "Зоомагазины",
# Подарки и хобби
5944: "Ювелирные магазины и часы",
5946: "Фототовары",
5971: "Художественные салоны и галереи",
5947: "Подарки и сувениры",
5950: "Стекло и хрусталь",
5931: "Комиссионные и секонд-хенды",
5942: "Книжные магазины",
5992: "Цветочные магазины",
# Авто
7531: "Кузовной ремонт",
7534: "Шиномонтаж и ремонт шин",
7535: "Покраска автомобилей",
7538: "Автосервисы (не дилеры)",
7542: "Автомойки",
7549: "Эвакуация автомобилей",
7523: "Парковки и гаражи",
7519: "Прокат домов на колёсах",
# Финансы и страхование
6010: "Снятие наличных (через оператора)",
6011: "Снятие наличных в банкоматах",
6012: "Финансовые учреждения — товары и услуги",
6050: "Квазиналичные операции",
6051: "Иностранная валюта и денежные переводы",
6211: "Ценные бумаги и брокеры",
6300: "Страхование",
6381: "Страховые премии",
6399: "Страхование (прочее)",
6513: "Риелторы и управление недвижимостью",
# Прочие популярные подписи без категории
5309: "Магазины беспошлинной торговли",
5310: "Магазины скидок",
5331: "Универсальные магазины",
5399: "Прочие товары общего назначения",
5722: "Магазины бытовой техники",
5994: "Газетные киоски",
5970: "Товары для творчества",
5999: "Прочие специализированные магазины",
5111: "Канцелярия и офисные принадлежности",
5192: "Книги и периодика",
5193: "Товары для цветоводства",
8111: "Юридические услуги",
7276: "Подготовка налоговых деклараций",
8734: "Испытательные лаборатории",
2741: "Издательские и полиграфические услуги",
8911: "Архитектурные и инженерные услуги",
}
_AIRLINE_MCC_RANGE = range(3000, 3351)
_CAR_RENTAL_MCC_RANGE = range(3351, 3501)
_DEFAULT_CATEGORY_KEYS = (
("groceries", "Продукты и супермаркеты", (5411, 5422, 5441, 5451, 5462, 5499, 5300)),
("cafes", "Кафе и рестораны", (5811, 5812, 5813, 5814)),
("transport", "Транспорт", (4111, 4112, 4121, 4131, 4784, 4789, 4411)),
("fuel", "Топливо и АЗС", (5541, 5542, 5172)),
(
"health",
"Здоровье и аптеки",
(5912, 5122, 8099, 8011, 8021, 8041, 8042, 8049, 8062, 8071, 5975, 5976),
),
("electronics", "Электроника", (5732, 5734, 5045, 5046, 7372, 7379)),
(
"clothing",
"Одежда и обувь",
(5611, 5621, 5631, 5651, 5655, 5661, 5691, 5697, 5681, 5949, 5699),
),
("beauty", "Красота и уход", (7230, 7297, 5977)),
(
"entertainment",
"Развлечения",
(7832, 7841, 7922, 7933, 7991, 7994, 7996, 7997, 7999, 7992, 7998, 7995),
),
("sport", "Спорт и фитнес", (5941, 7941)),
(
"travel",
"Путешествия",
(7011, 7012, 7032, 7033, *_AIRLINE_MCC_RANGE, *_CAR_RENTAL_MCC_RANGE),
),
(
"home",
"Дом и ремонт",
(
5211,
5251,
5261,
5712,
5713,
5714,
5718,
5719,
7622,
7623,
7629,
7641,
7692,
7699,
1520,
1711,
1731,
1740,
1750,
1761,
1771,
1799,
5198,
),
),
("telecom", "Связь и интернет", (4812, 4814, 4815, 4816, 4821, 4899)),
("utilities", "Коммунальные услуги", (4900,)),
("education", "Образование", (8211, 8220, 8241, 8244, 8249, 8299, 5943)),
("kids", "Дети", (5945, 5641)),
("pets", "Питомцы", (742, 5995)),
("gifts", "Подарки и хобби", (5944, 5946, 5971, 5947, 5950, 5931, 5942, 5992)),
("auto", "Авто", (7531, 7534, 7535, 7538, 7542, 7549, 7523, 7519)),
(
"finance",
"Финансы и страхование",
(6010, 6011, 6012, 6050, 6051, 6211, 6300, 6381, 6399, 6513),
),
)
_TRANSLIT_MAP = {
"a": "а",
"b": "б",
"c": "к",
"d": "д",
"e": "е",
"f": "ф",
"g": "г",
"h": "х",
"i": "и",
"j": "дж",
"k": "к",
"l": "л",
"m": "м",
"n": "н",
"o": "о",
"p": "п",
"q": "к",
"r": "р",
"s": "с",
"t": "т",
"u": "у",
"v": "в",
"w": "в",
"x": "кс",
"y": "й",
"z": "з",
}
@lru_cache(maxsize=1)
def _english_titles() -> dict[int, str]:
titles: dict[int, str] = {}
for entry in iso18245.get_all_mccs():
for field in _DESCRIPTION_PRIORITY:
value = getattr(entry, field)
if value:
titles[int(entry.mcc)] = value
break
else:
titles[int(entry.mcc)] = entry.range.description
return titles
@lru_cache(maxsize=1)
def _default_categories() -> tuple[DefaultCategory, ...]:
known = _english_titles()
result = []
for key, name, codes in _DEFAULT_CATEGORY_KEYS:
result.append(DefaultCategory(key, name, tuple(c for c in codes if c in known)))
return tuple(result)
def english_title(code: int) -> str | None:
return _english_titles().get(code)
def transliterate(text: str) -> str:
return "".join(_TRANSLIT_MAP.get(ch.lower(), ch) for ch in text)
def russian_title(code: int) -> str | None:
"""Curated Russian translation, else a Cyrillic transcription of the label."""
curated = _RU_TITLES.get(code)
if curated is not None:
return curated
english = english_title(code)
if english is None:
return None
return transliterate(english).capitalize()
def iter_mcc_dictionary() -> Iterator[tuple[int, str]]:
"""Yield ``(code, russian_title)`` for every MCC known to iso18245."""
for code, english in sorted(_english_titles().items()):
yield code, _RU_TITLES.get(code, transliterate(english).capitalize())
def default_categories() -> tuple[DefaultCategory, ...]:
"""The 20 pre-created popular categories with validated MCC code lists."""
return _default_categories()
def default_category_system_keys() -> tuple[str, ...]:
return tuple(item.system_key for item in default_categories()) + (OTHERS_SYSTEM_KEY,)
def is_budgetable_category_key(key: str | None) -> bool:
return key is None or key in default_category_system_keys()