From 63d065011b91bc99763427a81d613d7ce3f5428d Mon Sep 17 00:00:00 2001 From: k1nq Date: Wed, 23 Sep 2026 10:57:27 +0500 Subject: [PATCH] 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. --- .gitea/workflows/ci.yml | 4 +- README.md | 2 +- .../versions/20260923_0004_mcc_categories.py | 149 + .../20260923_0005_optional_hold_reason.py | 48 + backend/app/budgeting/router.py | 60 +- backend/app/budgeting/service.py | 6 +- backend/app/categorization/mcc.py | 379 + backend/app/categorization/models.py | 28 + backend/app/categorization/service.py | 153 +- backend/app/db/models.py | 2 + backend/app/imports/adapters/alfa_xlsx.py | 6 +- backend/app/imports/models.py | 7 +- backend/app/imports/router.py | 7 +- backend/app/imports/service.py | 52 +- backend/app/ledger/service.py | 3 +- backend/app/spaces/service.py | 2 + backend/pyproject.toml | 15 +- backend/tests/test_budget.py | 55 + backend/tests/test_budget_savings.py | 3 +- backend/tests/test_category_definitions.py | 51 +- backend/tests/test_import_holds.py | 108 +- backend/tests/test_imports.py | 45 +- backend/tests/test_savings_migration.py | 37 +- backend/uv.lock | 257 +- frontend/src/api/types.ts | 8 +- .../budget/CategoryManagerModal.tsx | 127 +- frontend/src/hooks/useBudget.ts | 2 + frontend/src/i18n/locales/en.json | 26 +- frontend/src/i18n/locales/ru.json | 26 +- frontend/src/pages/ImportDetailPage.tsx | 47 +- frontend/src/test/category-manager.test.tsx | 82 + frontend/src/test/import-confirm.test.tsx | 134 + .../src/test/import-hold-resolution.test.tsx | 37 +- graphify-out/.graphify_labels.json | 38 +- graphify-out/.graphify_labels.json.sig | 2 +- graphify-out/GRAPH_REPORT.md | 254 +- graphify-out/graph.html | 8 +- graphify-out/graph.json | 16347 +++++++++------- graphify-out/manifest.json | 208 +- infra/README.md | 2 +- 40 files changed, 11352 insertions(+), 7475 deletions(-) create mode 100644 backend/alembic/versions/20260923_0004_mcc_categories.py create mode 100644 backend/alembic/versions/20260923_0005_optional_hold_reason.py create mode 100644 backend/app/categorization/mcc.py create mode 100644 frontend/src/test/import-confirm.test.tsx diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 085c500..49eda3b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -83,8 +83,8 @@ jobs: run: uv run ruff check . - name: Format (ruff format) run: uv run ruff format --check . - - name: Type check (mypy) - run: uv run mypy . + - name: Type check (ty) + run: uv run ty check - name: Create restricted app role and migrate run: | uv run python -c "import os; from psycopg import sql; from sqlalchemy import create_engine,text; e=create_engine(os.environ['DATABASE_URL']); c=e.connect(); raw=c.connection.driver_connection; role=sql.SQL('CREATE ROLE finance_app_test LOGIN PASSWORD {} NOSUPERUSER NOBYPASSRLS').format(sql.Literal(os.environ['CI_RLS_DATABASE_PASSWORD'])).as_string(raw); c.exec_driver_sql(role); c.execute(text('GRANT CONNECT ON DATABASE finance_test TO finance_app_test')); c.execute(text('GRANT USAGE ON SCHEMA public TO finance_app_test')); c.execute(text('ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO finance_app_test')); c.commit(); c.close(); e.dispose()" diff --git a/README.md b/README.md index 048e106..729b0f1 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ docker compose --env-file infra/.env -f infra/docker-compose.yml run --rm migrat ```bash uv sync --project backend --all-groups --locked uv run --project backend ruff check backend -uv run --project backend mypy --config-file backend/pyproject.toml backend/app +uv run --project backend ty check --project backend uv run --project backend pytest backend corepack pnpm --dir frontend install --frozen-lockfile diff --git a/backend/alembic/versions/20260923_0004_mcc_categories.py b/backend/alembic/versions/20260923_0004_mcc_categories.py new file mode 100644 index 0000000..464945c --- /dev/null +++ b/backend/alembic/versions/20260923_0004_mcc_categories.py @@ -0,0 +1,149 @@ +"""MCC dictionary tables, category MCC links and default category seeding.""" + +import uuid + +import sqlalchemy as sa + +from alembic import op + +revision = "0004" +down_revision = "0003" +branch_labels = None +depends_on = None + +TENANT_TABLE = "category_mcc_codes" +GLOBAL_TABLES = ("mcc_codes", "bank_category_mappings") + + +def _has_table(bind, table: str) -> bool: + return sa.inspect(bind).has_table(table) + + +def _space_ids(bind) -> list[str]: + return [str(row[0]) for row in bind.execute(sa.text("SELECT id FROM financial_spaces"))] + + +def _create_tenant_policy(table: str) -> None: + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY") + op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}") + op.execute( + f"CREATE POLICY tenant_isolation ON {table} " + "USING (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid) " + "WITH CHECK (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid)" + ) + + +def upgrade() -> None: + bind = op.get_bind() + # Revision 0001 materializes the schema from live model metadata, so a + # database created fresh already carries these objects; guard every step. + if not _has_table(bind, "mcc_codes"): + op.create_table( + "mcc_codes", + sa.Column("code", sa.Integer(), nullable=False), + sa.Column("title", sa.String(length=300), nullable=False), + sa.Column("dictionary_version", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("code", name="pk_mcc_codes"), + ) + if not _has_table(bind, "bank_category_mappings"): + op.create_table( + "bank_category_mappings", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("bank_code", sa.String(length=32), nullable=False), + sa.Column("bank_category", sa.String(length=200), nullable=False), + sa.Column("mcc_code", sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint("id", name="pk_bank_category_mappings"), + sa.UniqueConstraint( + "bank_code", "bank_category", name="uq_bank_mappings_bank_category" + ), + ) + op.create_index( + "ix_bank_category_mappings_bank_code", + "bank_category_mappings", + ["bank_code"], + ) + if not _has_table(bind, TENANT_TABLE): + op.create_table( + TENANT_TABLE, + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("space_id", sa.Uuid(), nullable=False), + sa.Column("category_id", sa.Uuid(), nullable=False), + sa.Column("mcc_code", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("id", name="pk_category_mcc_codes"), + sa.UniqueConstraint("space_id", "mcc_code", name="uq_category_mcc_space_code"), + sa.ForeignKeyConstraint( + ["space_id", "category_id"], + ["categories.space_id", "categories.id"], + ondelete="CASCADE", + name="fk_category_mcc_category_tenant", + ), + sa.ForeignKeyConstraint( + ["mcc_code"], + ["mcc_codes.code"], + ondelete="RESTRICT", + name="fk_category_mcc_dictionary", + ), + ) + op.create_index("ix_category_mcc_codes_space_id", TENANT_TABLE, ["space_id"]) + op.create_index("ix_category_mcc_codes_category_id", TENANT_TABLE, ["category_id"]) + if bind.dialect.name == "postgresql": + _create_tenant_policy(TENANT_TABLE) + + from sqlalchemy.orm import Session + + from app.categorization.service import ensure_default_categories, ensure_mcc_dictionary + from app.db.session import set_space_context + + session = Session(bind=bind) + try: + ensure_mcc_dictionary(session) + for space_id in _space_ids(bind): + set_space_context(session, space_id) + ensure_default_categories(session, space_id=uuid.UUID(hex=space_id)) + session.flush() + finally: + session.close() + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + keys = _default_keys() + if inspector.has_table("categories"): + dependent = ( + ("operations", "operations o JOIN categories c ON c.id = o.category_id"), + ("funding movements", "funding_movements f JOIN categories c ON c.id = f.category_id"), + ("plan assignments", "plan_assignments p JOIN categories c ON c.id = p.category_id"), + ( + "categorization rules", + "categorization_rules r JOIN categories c ON c.id = r.category_id", + ), + ) + blocked_by = [] + for label, join in dependent: + query = f"SELECT 1 FROM {join} WHERE c.system_key IN :keys LIMIT 1" + if not inspector.has_table(join.split()[0]): + continue + stmt = sa.text(query).bindparams(sa.bindparam("keys", expanding=True)) + if bind.execute(stmt, {"keys": keys}).first() is not None: + blocked_by.append(label) + if blocked_by: + raise RuntimeError( + "Cannot downgrade MCC migration while default categories hold history: " + + ", ".join(blocked_by) + ) + bind.execute( + sa.text("DELETE FROM categories WHERE system_key IN :keys").bindparams( + sa.bindparam("keys", expanding=True) + ), + {"keys": keys}, + ) + if _has_table(bind, TENANT_TABLE): + op.drop_table(TENANT_TABLE) + + +def _default_keys() -> list[str]: + from app.categorization.mcc import default_category_system_keys + + return list(default_category_system_keys()) diff --git a/backend/alembic/versions/20260923_0005_optional_hold_reason.py b/backend/alembic/versions/20260923_0005_optional_hold_reason.py new file mode 100644 index 0000000..e55e803 --- /dev/null +++ b/backend/alembic/versions/20260923_0005_optional_hold_reason.py @@ -0,0 +1,48 @@ +"""Allow manual HOLD resolutions without a reason.""" + +import sqlalchemy as sa + +from alembic import op + +revision = "0005" +down_revision = "0004" +branch_labels = None +depends_on = None + + +def _reason_constraint_name() -> str | None: + return next( + ( + name + for item in sa.inspect(op.get_bind()).get_check_constraints("hold_resolutions") + if (name := item.get("name")) and name.endswith("manual_reason_required") + ), + None, + ) + + +def upgrade() -> None: + if name := _reason_constraint_name(): + with op.batch_alter_table("hold_resolutions") as batch_op: + batch_op.drop_constraint(op.f(name), type_="check") + + +def downgrade() -> None: + if _reason_constraint_name(): + return + bind = op.get_bind() + missing_reason = bind.execute( + sa.text( + "SELECT 1 FROM hold_resolutions WHERE resolution IN " + "('confirmed_as_posted', 'discarded_as_cancelled') " + "AND (reason IS NULL OR reason = '') LIMIT 1" + ) + ).first() + if missing_reason: + raise RuntimeError("Cannot require reasons while reasonless HOLD resolutions exist") + with op.batch_alter_table("hold_resolutions") as batch_op: + batch_op.create_check_constraint( + op.f("ck_hold_resolutions_manual_reason_required"), + "NOT (resolution IN ('confirmed_as_posted', 'discarded_as_cancelled') " + "AND (reason IS NULL OR reason = ''))", + ) diff --git a/backend/app/budgeting/router.py b/backend/app/budgeting/router.py index 8ec2286..551ff1a 100644 --- a/backend/app/budgeting/router.py +++ b/backend/app/budgeting/router.py @@ -1,4 +1,5 @@ import uuid +from collections import defaultdict from datetime import date from typing import Annotated, Literal @@ -7,6 +8,7 @@ from pydantic import BaseModel, Field from app.budgeting import service from app.categorization import service as categorization +from app.categorization.models import CategoryMcc from app.dependencies import Context, Csrf, Db router = APIRouter(prefix="/budget", tags=["budget"]) @@ -15,6 +17,7 @@ router = APIRouter(prefix="/budget", tags=["budget"]) class CategoryBody(BaseModel): name: str = Field(min_length=1, max_length=200) kind: Literal["monthly", "savings_envelope"] = "monthly" + mcc_codes: list[int] = Field(default_factory=list, alias="mccCodes") class AssignmentItem(BaseModel): @@ -144,7 +147,7 @@ def categories(month: str, db: Db, context: Context) -> list[dict]: return result -def category_definition_json(category) -> dict: +def category_definition_json(category, mcc_codes: list[int]) -> dict: return { "id": category.id, "name": categorization.resolve_category_name(category), @@ -153,6 +156,7 @@ def category_definition_json(category) -> dict: "systemKey": category.system_key, "linkedAccountId": category.linked_account_id, "archived": category.archived, + "mccCodes": mcc_codes, } @@ -165,12 +169,20 @@ def category_definitions( items = service.list_categories( db, space_id=context.space.id, include_archived=include_archived ) - return [category_definition_json(item) for item in items] + codes_by_category: dict[uuid.UUID, list[int]] = defaultdict(list) + for category_id, code in ( + db.query(CategoryMcc.category_id, CategoryMcc.mcc_code) + .filter_by(space_id=context.space.id) + .order_by(CategoryMcc.mcc_code) + ): + codes_by_category[category_id].append(code) + return [category_definition_json(item, codes_by_category[item.id]) for item in items] class CategoryDefinitionPatchBody(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=200) archived: bool | None = None + mcc_codes: list[int] | None = Field(default=None, alias="mccCodes") @router.patch("/category-definitions/{category_id}") @@ -181,15 +193,29 @@ def patch_category_definition( context: Context, _csrf: Csrf, ) -> dict: - category = service.update_category_definition( - db, - space_id=context.space.id, - user=context.user, - category_id=category_id, - name=body.name, - archived=body.archived, + category = service.get_category(db, space_id=context.space.id, category_id=category_id) + if body.name is not None or body.archived is not None: + category = service.update_category_definition( + db, + space_id=context.space.id, + user=context.user, + category_id=category_id, + name=body.name, + archived=body.archived, + ) + if body.mcc_codes is not None: + categorization.set_category_mcc_codes( + db, + space_id=context.space.id, + category_id=category_id, + codes=body.mcc_codes, + ) + return category_definition_json( + category, + categorization.get_category_mcc_codes( + db, space_id=context.space.id, category_id=category.id + ), ) - return category_definition_json(category) @router.post("/categories", status_code=201) @@ -201,7 +227,19 @@ def create_category(body: CategoryBody, db: Db, context: Context, _csrf: Csrf) - kind=body.kind, name=body.name, ) - return category_definition_json(category) + if body.mcc_codes: + categorization.set_category_mcc_codes( + db, + space_id=context.space.id, + category_id=category.id, + codes=body.mcc_codes, + ) + return category_definition_json( + category, + categorization.get_category_mcc_codes( + db, space_id=context.space.id, category_id=category.id + ), + ) @router.patch("/categories/{category_id}") diff --git a/backend/app/budgeting/service.py b/backend/app/budgeting/service.py index 91add9d..11bb16f 100644 --- a/backend/app/budgeting/service.py +++ b/backend/app/budgeting/service.py @@ -16,6 +16,7 @@ from app.budgeting.models import ( PeriodClosure, PlanAssignment, ) +from app.categorization import mcc from app.categorization.service import resolve_category_name from app.errors import bad_request, conflict, not_found from app.identity.models import User @@ -351,6 +352,7 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri "category_id": str(cat.id), "name": resolve_category_name(cat), "name_key": cat.system_key, + "system": not mcc.is_budgetable_category_key(cat.system_key), "archived": cat.archived, "planned": 0, "funded": funded_period[cat.id], @@ -681,7 +683,7 @@ def set_assignments( result = [] for item in items: category = get_category(db, space_id=space_id, category_id=item["category_id"]) - if category.system_key is not None: + if not mcc.is_budgetable_category_key(category.system_key): raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be planned") if category.archived: raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived") @@ -800,7 +802,7 @@ def fund( if amount_kopecks <= 0: raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive") category = get_category(db, space_id=space_id, category_id=category_id) - if category.system_key is not None: + if not mcc.is_budgetable_category_key(category.system_key): raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be funded") if category.archived: raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived") diff --git a/backend/app/categorization/mcc.py b/backend/app/categorization/mcc.py new file mode 100644 index 0000000..13d2c27 --- /dev/null +++ b/backend/app/categorization/mcc.py @@ -0,0 +1,379 @@ +"""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() diff --git a/backend/app/categorization/models.py b/backend/app/categorization/models.py index b242f01..ca1c7f6 100644 --- a/backend/app/categorization/models.py +++ b/backend/app/categorization/models.py @@ -100,6 +100,34 @@ class MccCode(Base): dictionary_version: Mapped[int] = mapped_column(nullable=False, default=1) +class CategoryMcc(Base, PkMixin): + """Space-scoped MCC codes of a category: many codes per category, but every + code maps to at most one category inside the space.""" + + __tablename__ = "category_mcc_codes" + __table_args__ = ( + UniqueConstraint("space_id", "mcc_code", name="uq_category_mcc_space_code"), + ForeignKeyConstraint( + ["space_id", "category_id"], + ["categories.space_id", "categories.id"], + ondelete="CASCADE", + name="fk_category_mcc_category_tenant", + ), + ForeignKeyConstraint( + ["mcc_code"], + ["mcc_codes.code"], + ondelete="RESTRICT", + name="fk_category_mcc_dictionary", + ), + ) + + space_id: Mapped[uuid.UUID] = mapped_column( + Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True + ) + category_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True) + mcc_code: Mapped[int] = mapped_column(Integer, nullable=False) + + class BankCategoryMapping(Base, PkMixin): """Maps a bank's raw category string to an MCC for suggestion purposes.""" diff --git a/backend/app/categorization/service.py b/backend/app/categorization/service.py index 1d6e9af..ed16078 100644 --- a/backend/app/categorization/service.py +++ b/backend/app/categorization/service.py @@ -1,6 +1,17 @@ +import uuid +from collections.abc import Iterable + from sqlalchemy.orm import Session -from app.categorization.models import CategorizationRule, IncomeSource, Payee, PayeeAlias +from app.categorization import mcc +from app.categorization.models import ( + CategorizationRule, + CategoryMcc, + IncomeSource, + MccCode, + Payee, + PayeeAlias, +) from app.errors import conflict, not_found from app.ledger.models import Category @@ -102,3 +113,143 @@ def set_payee_rule(db: Session, *, space_id, payee_id, category_id, user_id) -> rule.category_id = category_id db.flush() return rule + + +def ensure_mcc_dictionary(db: Session) -> None: + """Idempotently seed the global ``mcc_codes`` table from iso18245.""" + existing = {code for (code,) in db.query(MccCode.code).all()} + for code, title in mcc.iter_mcc_dictionary(): + if code in existing: + continue + db.add(MccCode(code=code, title=title[:300], dictionary_version=mcc.MCC_DICTIONARY_VERSION)) + db.flush() + + +def ensure_default_categories(db: Session, *, space_id: uuid.UUID) -> dict[str, Category]: + """Idempotently pre-create the 20 popular categories plus «Остальное». + + Each default category is keyed by ``system_key`` and carries its MCC code + links; every link is only created while its MCC code is unclaimed inside + the space, so user re-mappings are never overwritten. + """ + ensure_mcc_dictionary(db) + keys = mcc.default_category_system_keys() + existing: dict[str, Category] = { + category.system_key: category + for category in db.query(Category) + .filter(Category.space_id == space_id, Category.system_key.in_(keys)) + .all() + if category.system_key is not None + } + result: dict[str, Category] = {} + claimed = { + code for (code,) in db.query(CategoryMcc.mcc_code).filter_by(space_id=space_id).all() + } + for definition in mcc.default_categories(): + category = existing.get(definition.system_key) + if category is None: + category = Category( + space_id=space_id, + kind="monthly", + system_key=definition.system_key, + custom_name=definition.name, + ) + db.add(category) + db.flush() + for code in definition.mcc_codes: + if code in claimed: + continue + db.add(CategoryMcc(space_id=space_id, category_id=category.id, mcc_code=code)) + claimed.add(code) + result[definition.system_key] = category + others = existing.get(mcc.OTHERS_SYSTEM_KEY) + if others is None: + others = Category( + space_id=space_id, + kind="monthly", + system_key=mcc.OTHERS_SYSTEM_KEY, + custom_name=mcc.OTHERS_CATEGORY_NAME, + ) + db.add(others) + db.flush() + result[mcc.OTHERS_SYSTEM_KEY] = others + db.flush() + return result + + +def category_for_mcc(db: Session, *, space_id: uuid.UUID, code: int) -> Category | None: + """Category owning the MCC code, else the system «Остальное» fallback.""" + link = db.query(CategoryMcc).filter_by(space_id=space_id, mcc_code=code).one_or_none() + if link is not None: + category = db.get(Category, link.category_id) + if category is not None and not category.archived: + return category + return ( + db.query(Category) + .filter_by(space_id=space_id, system_key=mcc.OTHERS_SYSTEM_KEY) + .one_or_none() + ) + + +def get_category_mcc_codes( + db: Session, *, space_id: uuid.UUID, category_id: uuid.UUID +) -> list[int]: + codes = ( + db.query(CategoryMcc.mcc_code) + .filter_by(space_id=space_id, category_id=category_id) + .order_by(CategoryMcc.mcc_code) + .all() + ) + return [code for (code,) in codes] + + +def set_category_mcc_codes( + db: Session, + *, + space_id: uuid.UUID, + category_id: uuid.UUID, + codes: Iterable[int], +) -> list[int]: + """Replace the category's MCC code set; codes are unique per space.""" + category = db.query(Category).filter_by(id=category_id, space_id=space_id).one_or_none() + if category is None: + raise not_found("BUDGET_CATEGORY_NOT_FOUND", "Category not found") + wanted = sorted({int(code) for code in codes}) + if wanted and category.archived: + raise conflict("BUDGET_CATEGORY_ARCHIVED", "Archived categories cannot have MCC codes") + if wanted and category.kind != "monthly": + raise conflict("CATEGORY_MCC_KIND_INVALID", "Only monthly categories can have MCC codes") + for code in wanted: + if db.get(MccCode, code) is None: + raise not_found("MCC_CODE_NOT_FOUND", f"Unknown MCC code {code}") + current = { + row for row in db.query(CategoryMcc).filter_by(space_id=space_id, category_id=category_id) + } + wanted_set = set(wanted) + for row in current: + if row.mcc_code not in wanted_set: + db.delete(row) + owned_elsewhere = { + code + for (code,) in ( + db.query(CategoryMcc.mcc_code) + .filter( + CategoryMcc.space_id == space_id, + CategoryMcc.category_id != category_id, + CategoryMcc.mcc_code.in_(wanted_set), + ) + .all() + ) + } + if owned_elsewhere: + raise conflict( + "CATEGORY_MCC_TAKEN", + "MCC codes already mapped to another category", + details={"mcc_codes": sorted(owned_elsewhere)}, + ) + current_codes = {row.mcc_code for row in current} + for code in wanted: + if code not in current_codes: + db.add(CategoryMcc(space_id=space_id, category_id=category_id, mcc_code=code)) + db.flush() + return wanted diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 7ad6297..19ce90b 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -12,6 +12,7 @@ from app.budgeting.models import ( from app.categorization.models import ( BankCategoryMapping, CategorizationRule, + CategoryMcc, IncomeSource, MccCode, Payee, @@ -35,6 +36,7 @@ __all__ = [ "BankCategoryMapping", "BudgetPeriod", "Category", + "CategoryMcc", "CategorizationRule", "DataExport", "DedupCandidate", diff --git a/backend/app/imports/adapters/alfa_xlsx.py b/backend/app/imports/adapters/alfa_xlsx.py index 77bb105..c70a1e7 100644 --- a/backend/app/imports/adapters/alfa_xlsx.py +++ b/backend/app/imports/adapters/alfa_xlsx.py @@ -214,9 +214,9 @@ def _find_header(rows: list[tuple]) -> tuple[int, dict[str, int]]: ("category", CATEGORY_HEADERS), ("external", ("id операции", "номер операции")), ): - columns[key] = next( # type: ignore[assignment] - (i for i, c in enumerate(cells) if c in synonyms), None - ) + optional_col = next((i for i, c in enumerate(cells) if c in synonyms), None) + if optional_col is not None: + columns[key] = optional_col return index, columns raise UnsupportedStatementLayout("statement header row with date/amount columns not found") diff --git a/backend/app/imports/models.py b/backend/app/imports/models.py index 884c1bd..73a61f7 100644 --- a/backend/app/imports/models.py +++ b/backend/app/imports/models.py @@ -222,7 +222,7 @@ class HoldResolution(Base, PkMixin, TimestampMixin): The extracted HOLD row itself is never mutated. At most one resolution exists per hold source record; automatic matches (`matched_posted`) are created only when a later statement is confirmed, and manual actions - (`confirmed_as_posted`, `discarded_as_cancelled`) always carry a reason. + (`confirmed_as_posted`, `discarded_as_cancelled`) may carry a reason. """ __tablename__ = "hold_resolutions" @@ -231,11 +231,6 @@ class HoldResolution(Base, PkMixin, TimestampMixin): "resolution IN ('matched_posted', 'confirmed_as_posted', 'discarded_as_cancelled')", name="resolution_valid", ), - CheckConstraint( - "NOT (resolution IN ('confirmed_as_posted', 'discarded_as_cancelled') " - "AND (reason IS NULL OR reason = ''))", - name="manual_reason_required", - ), ForeignKeyConstraint( ["space_id", "hold_source_record_id"], ["source_records.space_id", "source_records.id"], diff --git a/backend/app/imports/router.py b/backend/app/imports/router.py index 54a4af3..a8bd929 100644 --- a/backend/app/imports/router.py +++ b/backend/app/imports/router.py @@ -30,14 +30,13 @@ class ConfirmBody(BaseModel): confirmed_without_full_reconciliation: bool = Field( default=False, alias="confirmedWithoutFullReconciliation" ) - reason: str | None = None class HoldResolveBody(BaseModel): model_config = ConfigDict(populate_by_name=True) action: str - reason: str + reason: str | None = None account_id: uuid.UUID | None = Field(default=None, alias="accountId") @@ -160,6 +159,8 @@ def detail(document_id: uuid.UUID, db: Db, context: Context) -> dict: "direction": item["direction"], "bankStatus": item.get("bank_status", "posted"), "description": item["description"], + "mcc": item["mcc"], + "mccName": item["mcc_name"], "payeeHint": None, "warnings": (item["warnings"] or {}).get("codes", []), "decision": "pending" @@ -306,7 +307,7 @@ def confirm( document_id=document_id, account_id=document.account_id, confirmed_without_full_reconciliation=True, - reason=body.reason, + reason=None, ) document = service.confirm_document( db, space_id=context.space.id, document_id=document_id, user=context.user diff --git a/backend/app/imports/service.py b/backend/app/imports/service.py index bc386b6..97dd88c 100644 --- a/backend/app/imports/service.py +++ b/backend/app/imports/service.py @@ -9,6 +9,9 @@ from sqlalchemy import func from sqlalchemy.orm import Session from app.audit import service as audit +from app.categorization import mcc +from app.categorization import service as categorization +from app.categorization.models import MccCode from app.config import get_settings from app.db.base import utcnow from app.db.session import session_scope @@ -169,6 +172,15 @@ def _source_from_row( ) +def _category_id_for_source( + db: Session, *, space_id: uuid.UUID, source: SourceRecord +) -> uuid.UUID | None: + if source.mcc is None: + return None + category = categorization.category_for_mcc(db, space_id=space_id, code=source.mcc) + return category.id if category is not None else None + + def _reconciliation_summary(draft: Any) -> dict[str, Any]: """Reconcile parsed rows against declared bank controls with stable diagnostics. @@ -471,7 +483,8 @@ def process_statement_document(document_id: uuid.UUID, space_id: uuid.UUID) -> s document.adapter_id = adapter.adapter_id document.account_fingerprint = draft.account_fingerprint document.active_parse_run_id = run.id - document.reconciliation = _reconciliation_summary(draft) + reconciliation = _reconciliation_summary(draft) + document.reconciliation = reconciliation if document.account_id is None and draft.account_fingerprint: matches = ( db.query(Account) @@ -492,14 +505,14 @@ def process_statement_document(document_id: uuid.UUID, space_id: uuid.UUID) -> s db.query(SourceRecord.id).filter_by(parse_run_id=run.id, critical=True).first() ) document.needs_account_mapping = document.account_id is None - needs_manual_reconciliation = not document.reconciliation["full"] + needs_manual_reconciliation = not reconciliation["full"] document.status = ( "review_required" if critical or document.needs_account_mapping or needs_manual_reconciliation or has_candidates - or document.reconciliation.get("matched") is False + or reconciliation.get("matched") is False else "ready" ) except AppError as exc: @@ -601,6 +614,15 @@ def document_detail(db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID) .all() ) record_ids = [record.id for record in records] + codes = {record.mcc for record in records if record.mcc is not None} + mcc_names: dict[int, str] = { + code: title + for code, title in ( + db.query(MccCode.code, MccCode.title).filter(MccCode.code.in_(codes)).all() + if codes + else [] + ) + } resolutions: dict[uuid.UUID, dict[str, Any]] = {} candidates: dict[uuid.UUID, list[dict[str, Any]]] = {} if record_ids: @@ -656,6 +678,9 @@ def document_detail(db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID) "direction": item.direction, "bank_status": item.bank_status, "description": item.description, + "mcc": item.mcc, + "mcc_name": mcc_names.get(item.mcc) + or (mcc.russian_title(item.mcc) if item.mcc is not None else None), "critical": item.critical, "warnings": item.field_warnings, "review_decision": item.review_decision, @@ -755,7 +780,7 @@ def set_review_confirmation( document_id: uuid.UUID, account_id: uuid.UUID, confirmed_without_full_reconciliation: bool, - reason: str | None, + reason: str | None = None, ) -> StatementDocument: document = _document(db, space_id=space_id, document_id=document_id) account = db.query(Account).filter_by(id=account_id, space_id=space_id).one_or_none() @@ -766,13 +791,11 @@ def set_review_confirmation( "IMPORT_SYSTEM_ACCOUNT_INVALID", "System accounts cannot be used as statement accounts", ) - if confirmed_without_full_reconciliation and not reason: - raise conflict("IMPORT_RECONCILIATION_REASON_REQUIRED", "A confirmation reason is required") document.account_id = account.id document.needs_account_mapping = False reconciliation = dict(document.reconciliation or {}) reconciliation["confirmed_without_full_reconciliation"] = confirmed_without_full_reconciliation - reconciliation["reason"] = reason + reconciliation["reason"] = reason.strip() or None if reason is not None else None document.reconciliation = reconciliation critical = ( db.query(SourceRecord.id) @@ -801,7 +824,7 @@ def resolve_hold( source_record_id: uuid.UUID, user: User, action: str, - reason: str | None, + reason: str | None = None, account_id: uuid.UUID | None = None, ) -> HoldResolution: """Manually settle one HOLD: book it as a normal operation or discard it.""" @@ -813,8 +836,7 @@ def resolve_hold( ) if action not in HOLD_MANUAL_ACTIONS: raise bad_request("IMPORT_HOLD_ACTION_INVALID", "Unknown hold resolution action") - if reason is None or not reason.strip(): - raise conflict("IMPORT_HOLD_REASON_REQUIRED", "A resolution reason is required") + reason = reason.strip() or None if reason is not None else None source = ( db.query(SourceRecord) .filter_by( @@ -842,7 +864,7 @@ def resolve_hold( if action == "confirm_as_posted" else "discarded_as_cancelled", resolved_by_user_id=user.id, - reason=reason.strip(), + reason=reason, resolved_at=utcnow(), ) if action == "confirm_as_posted": @@ -874,6 +896,7 @@ def resolve_hold( user=user, account_id=account.id, amount_kopecks=source.amount_kopecks, + category_id=_category_id_for_source(db, space_id=space_id, source=source), occurred_on=source.occurred_on, posted_on=source.posted_on, description=source.description, @@ -900,7 +923,7 @@ def resolve_hold( detail={ "document_id": str(document.id), "source_record_id": str(source.id), - "reason": reason.strip(), + "reason": reason, }, ) return resolution @@ -1073,9 +1096,7 @@ def confirm_document( if source.review_decision == "savings_transfer": from app.ledger.savings import ensure_savings_resources - savings_account, _savings_category = ensure_savings_resources( - db, space_id=space_id - ) + savings_account, _savings_category = ensure_savings_resources(db, space_id=space_id) if document.account_id == savings_account.id: raise conflict( "IMPORT_SAVINGS_SOURCE_INVALID", @@ -1160,6 +1181,7 @@ def confirm_document( user=user, account_id=document.account_id, amount_kopecks=source.amount_kopecks, + category_id=_category_id_for_source(db, space_id=space_id, source=source), occurred_on=source.occurred_on, posted_on=source.posted_on, description=source.description, diff --git a/backend/app/ledger/service.py b/backend/app/ledger/service.py index f78cb19..e47288e 100644 --- a/backend/app/ledger/service.py +++ b/backend/app/ledger/service.py @@ -7,6 +7,7 @@ from sqlalchemy import func from sqlalchemy.orm import Session from app.audit import service as audit +from app.categorization import mcc from app.categorization import service as categorization from app.errors import bad_request, conflict, not_found from app.identity.models import User @@ -67,7 +68,7 @@ def _validate_category(db: Session, *, space_id: uuid.UUID, category_id: uuid.UU raise not_found("BUDGET_CATEGORY_NOT_FOUND", "Category not found") if category.archived: raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived") - if category.system_key is not None: + if not mcc.is_budgetable_category_key(category.system_key): raise conflict( "BUDGET_CATEGORY_SYSTEM", "System categories cannot be assigned to regular operations", diff --git a/backend/app/spaces/service.py b/backend/app/spaces/service.py index 1e8ffa9..f62e3aa 100644 --- a/backend/app/spaces/service.py +++ b/backend/app/spaces/service.py @@ -15,9 +15,11 @@ def create_space(db: Session, *, user: User, name: str) -> FinancialSpace: db.flush() set_space_context(db, space.id) db.add(Membership(space_id=space.id, user_id=user.id, role="owner")) + from app.categorization.service import ensure_default_categories from app.ledger.savings import ensure_savings_resources ensure_savings_resources(db, space_id=space.id) + ensure_default_categories(db, space_id=space.id) audit.record(db, event_type="space.created", space_id=space.id, user_id=user.id) return space diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f0dca1d..3bf3ada 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -21,14 +21,15 @@ dependencies = [ "pdfplumber>=0.11", "python-multipart>=0.0.9", "boto3>=1.35", + "iso18245>=1.4.1", ] [dependency-groups] dev = [ "pytest>=8.2", - "httpx>=0.27", + "httpx2>=2.0", "ruff>=0.5", - "mypy>=1.10", + "ty>=0.0.18", "hypothesis>=6.112", ] @@ -52,11 +53,5 @@ target-version = "py314" select = ["E", "F", "I", "UP", "B"] ignore = ["B008"] -[tool.mypy] -python_version = "3.14" -ignore_missing_imports = true -check_untyped_defs = true -no_implicit_optional = true -warn_redundant_casts = true -disable_error_code = ["import-untyped"] -exclude = ["alembic/", "tests/"] +[tool.ty.src] +include = ["app"] diff --git a/backend/tests/test_budget.py b/backend/tests/test_budget.py index b51ea5b..7cfffd8 100644 --- a/backend/tests/test_budget.py +++ b/backend/tests/test_budget.py @@ -13,8 +13,10 @@ from app.budgeting.service import ( reopen_period, set_assignments, ) +from app.categorization import service as categorization from app.errors import AppError from app.identity.models import User +from app.ledger.models import Category from app.ledger.service import create_account, create_expense from app.spaces.service import create_space @@ -51,6 +53,59 @@ def test_uncategorized_expense_blocks_close(db: Session) -> None: assert exc.value.code == "BUDGET_CLOSE_BLOCKED" +def test_default_mcc_categories_are_budgetable_and_editable_mapping(db: Session) -> None: + user, space = seed(db) + defaults = db.query(Category).filter_by(space_id=space.id).all() + by_key = {category.system_key: category for category in defaults} + assert len([key for key in by_key if key not in {None, "savings"}]) == 21 + groceries = by_key["groceries"] + others = by_key["others"] + assert {5411, 5422}.issubset( + categorization.get_category_mcc_codes(db, space_id=space.id, category_id=groceries.id) + ) + period = get_or_create_period(db, space_id=space.id, year=2026, month=5) + set_assignments( + db, + space_id=space.id, + user=user, + period=period, + items=[{"category_id": groceries.id, "planned_kopecks": 2_000}], + ) + account = create_account( + db, + space_id=space.id, + user=user, + name="Cash", + kind="cash", + opened_on=date(2026, 5, 1), + ) + create_expense( + db, + space_id=space.id, + user=user, + account_id=account.id, + amount_kopecks=500, + occurred_on=date(2026, 5, 2), + category_id=others.id, + ) + assert categorization.category_for_mcc(db, space_id=space.id, code=5999).id == others.id + + custom = create_category(db, space_id=space.id, user=user, kind="monthly", name="My shops") + codes = categorization.set_category_mcc_codes( + db, space_id=space.id, category_id=custom.id, codes=[5999, 5994] + ) + assert codes == [5994, 5999] + assert categorization.category_for_mcc(db, space_id=space.id, code=5999).id == custom.id + custom.archived = True + assert categorization.category_for_mcc(db, space_id=space.id, code=5999).id == others.id + custom.archived = False + with pytest.raises(AppError) as exc: + categorization.set_category_mcc_codes( + db, space_id=space.id, category_id=custom.id, codes=[5411] + ) + assert exc.value.code == "CATEGORY_MCC_TAKEN" + + def test_copy_previous_month_clamps_expected_day(db: Session) -> None: from app.budgeting.service import add_expected_income diff --git a/backend/tests/test_budget_savings.py b/backend/tests/test_budget_savings.py index 00b2583..a92ab58 100644 --- a/backend/tests/test_budget_savings.py +++ b/backend/tests/test_budget_savings.py @@ -167,7 +167,8 @@ def test_adjustment_on_regular_account_keeps_pool_and_totals(db: Session) -> Non # The adjustment reconciles the account but never enters the budget pool. assert state["pool"] == 300_000 assert state["uncategorized_expenses"] == 0 - assert state["monthly_categories"] == [] + assert len(state["monthly_categories"]) == 21 + assert all(row["spent"] == 0 for row in state["monthly_categories"]) def test_savings_envelope_available_uses_month_end_balance(db: Session) -> None: diff --git a/backend/tests/test_category_definitions.py b/backend/tests/test_category_definitions.py index 8f23e44..2225218 100644 --- a/backend/tests/test_category_definitions.py +++ b/backend/tests/test_category_definitions.py @@ -71,6 +71,53 @@ def test_space_has_protected_savings_pair(client: TestClient) -> None: assert delete_response.status_code == 409 +def test_categories_expose_and_update_multiple_mcc_codes(client: TestClient) -> None: + csrf = bootstrap(client) + definitions = client.get("/api/v1/budget/category-definitions").json() + groceries = next(item for item in definitions if item["systemKey"] == "groceries") + assert {5411, 5422}.issubset(groceries["mccCodes"]) + assert any(item["systemKey"] == "others" for item in definitions) + + created = client.post( + "/api/v1/budget/categories", + headers={"X-CSRF-Token": csrf}, + json={"name": "Shops", "kind": "monthly", "mccCodes": [5999, 5994]}, + ) + assert created.status_code == 201, created.text + assert created.json()["mccCodes"] == [5994, 5999] + category_id = created.json()["id"] + + updated = client.patch( + f"/api/v1/budget/category-definitions/{category_id}", + headers={"X-CSRF-Token": csrf}, + json={"mccCodes": [5999]}, + ) + assert updated.status_code == 200, updated.text + assert updated.json()["mccCodes"] == [5999] + + taken = client.patch( + f"/api/v1/budget/category-definitions/{category_id}", + headers={"X-CSRF-Token": csrf}, + json={"mccCodes": [5411]}, + ) + assert taken.status_code == 409 + assert taken.json()["error"]["code"] == "CATEGORY_MCC_TAKEN" + + released = client.patch( + f"/api/v1/budget/category-definitions/{groceries['id']}", + headers={"X-CSRF-Token": csrf}, + json={"mccCodes": [code for code in groceries["mccCodes"] if code != 5411]}, + ) + assert released.status_code == 200, released.text + remapped = client.patch( + f"/api/v1/budget/category-definitions/{category_id}", + headers={"X-CSRF-Token": csrf}, + json={"mccCodes": [5411, 5999]}, + ) + assert remapped.status_code == 200, remapped.text + assert remapped.json()["mccCodes"] == [5411, 5999] + + def test_category_lifecycle_create_rename_archive_restore(client: TestClient) -> None: csrf = bootstrap(client) created = client.post( @@ -163,9 +210,7 @@ def test_adjust_and_savings_conversion_http_contracts(client: TestClient) -> Non "occurredOn": "2026-09-10", "reason": "Statement reconciliation", } - denied = client.post( - f"/api/v1/accounts/{account_id}/adjust-balance", json=adjustment_body - ) + denied = client.post(f"/api/v1/accounts/{account_id}/adjust-balance", json=adjustment_body) assert denied.status_code == 403 adjusted = client.post( f"/api/v1/accounts/{account_id}/adjust-balance", diff --git a/backend/tests/test_import_holds.py b/backend/tests/test_import_holds.py index 1f8c25a..b49928d 100644 --- a/backend/tests/test_import_holds.py +++ b/backend/tests/test_import_holds.py @@ -1,12 +1,19 @@ import uuid from datetime import date +from pathlib import Path from types import SimpleNamespace import pytest +import sqlalchemy as sa +from alembic.config import Config +from alembic.migration import MigrationContext +from alembic.operations import Operations from fastapi.testclient import TestClient +from sqlalchemy import create_engine from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from alembic import command from app.budgeting.service import close_period, compute_period_state, get_or_create_period from app.errors import AppError from app.identity.models import User @@ -200,20 +207,8 @@ def test_unresolved_hold_blocks_only_its_occurred_month(db: Session) -> None: ) -def test_discard_as_cancelled_requires_reason_and_unblocks_month(db: Session) -> None: +def test_discard_as_cancelled_without_reason_unblocks_month(db: Session) -> None: user, space, _account, document, hold = hold_document(db) - with pytest.raises(AppError) as exc: - service.resolve_hold( - db, - space_id=space.id, - document_id=document.id, - source_record_id=hold.id, - user=user, - action="discard_as_cancelled", - reason=" ", - ) - assert exc.value.code == "IMPORT_HOLD_REASON_REQUIRED" - assert db.query(HoldResolution).count() == 0 resolution = service.resolve_hold( db, space_id=space.id, @@ -221,7 +216,6 @@ def test_discard_as_cancelled_requires_reason_and_unblocks_month(db: Session) -> source_record_id=hold.id, user=user, action="discard_as_cancelled", - reason="Cancelled by the bank", ) db.flush() assert resolution.resolution == "discarded_as_cancelled" @@ -230,7 +224,7 @@ def test_discard_as_cancelled_requires_reason_and_unblocks_month(db: Session) -> assert db.query(Operation).filter_by(space_id=space.id).count() == 0 detail = service.document_detail(db, space_id=space.id, document_id=document.id) hold_row = next(item for item in detail["records"] if item["id"] == hold.id) - assert hold_row["hold_resolution"]["reason"] == "Cancelled by the bank" + assert hold_row["hold_resolution"]["reason"] is None april = get_or_create_period(db, space_id=space.id, year=2026, month=4) close_period(db, space_id=space.id, user=user, period=april) may = get_or_create_period(db, space_id=space.id, year=2026, month=5) @@ -238,21 +232,20 @@ def test_discard_as_cancelled_requires_reason_and_unblocks_month(db: Session) -> assert may.status == "closed" -def test_confirm_as_posted_requires_reason(db: Session) -> None: +def test_confirm_as_posted_accepts_blank_reason(db: Session) -> None: user, space, _account, document, hold = hold_document(db) - for reason in (None, ""): - with pytest.raises(AppError) as exc: - service.resolve_hold( - db, - space_id=space.id, - document_id=document.id, - source_record_id=hold.id, - user=user, - action="confirm_as_posted", - reason=reason, - ) - assert exc.value.code == "IMPORT_HOLD_REASON_REQUIRED" - assert db.query(HoldResolution).count() == 0 + resolution = service.resolve_hold( + db, + space_id=space.id, + document_id=document.id, + source_record_id=hold.id, + user=user, + action="confirm_as_posted", + reason=" ", + ) + db.flush() + assert resolution.reason is None + assert resolution.operation_id is not None def test_hold_cannot_be_resolved_before_document_confirmation(db: Session) -> None: @@ -736,7 +729,7 @@ def test_hold_resolution_rejects_unknown_kind(db: Session) -> None: db.rollback() -def test_manual_resolution_requires_reason_at_database_level(db: Session) -> None: +def test_manual_resolution_accepts_no_reason_at_database_level(db: Session) -> None: _user, space, _account, _document, hold = hold_document(db) db.add( HoldResolution( @@ -746,8 +739,8 @@ def test_manual_resolution_requires_reason_at_database_level(db: Session) -> Non reason=None, ) ) - with pytest.raises(IntegrityError): - db.flush() + db.flush() + assert db.query(HoldResolution).filter_by(hold_source_record_id=hold.id).one().reason is None db.rollback() _user, space, _account, _document, hold = hold_document(db) db.add( @@ -761,6 +754,49 @@ def test_manual_resolution_requires_reason_at_database_level(db: Session) -> Non db.flush() +def test_upgrade_drops_legacy_manual_reason_constraint(tmp_path: Path, monkeypatch) -> None: + from app.config import get_settings + + database_url = f"sqlite:///{tmp_path / 'hold-reason.db'}" + monkeypatch.setenv("DATABASE_URL", database_url) + get_settings.cache_clear() + config = Config(str(Path(__file__).parents[1] / "alembic.ini")) + config.set_main_option("script_location", str(Path(__file__).parents[1] / "alembic")) + try: + command.upgrade(config, "0003") + engine = create_engine(database_url) + try: + with engine.begin() as connection: + operations = Operations(MigrationContext.configure(connection)) + with operations.batch_alter_table("hold_resolutions") as batch_op: + batch_op.create_check_constraint( + "ck_hold_resolutions_manual_reason_required", + "NOT (resolution IN ('confirmed_as_posted', 'discarded_as_cancelled') " + "AND (reason IS NULL OR reason = ''))", + ) + + command.upgrade(config, "0005") + constraints = sa.inspect(engine).get_check_constraints("hold_resolutions") + assert not any( + (constraint.get("name") or "").endswith("manual_reason_required") + for constraint in constraints + ) + with Session(engine) as db: + db.add( + HoldResolution( + space_id=uuid.uuid4(), + hold_source_record_id=uuid.uuid4(), + resolution="discarded_as_cancelled", + reason=None, + ) + ) + db.flush() + finally: + engine.dispose() + finally: + get_settings.cache_clear() + + def _draft(rows, *, opening=1_000, closing=1_200, **declared): return SimpleNamespace( currency="RUB", @@ -920,6 +956,7 @@ def test_resolve_endpoint_requires_csrf_and_resolves_hold(client: TestClient, db space = SimpleNamespace(id=space_id) document, run = make_document(db, space, account, user=user) hold = add_record(db, document, run, bank_status="hold") + hold.mcc = 5411 service.confirm_document(db, space_id=space_id, document_id=document.id, user=user) db.commit() @@ -932,14 +969,17 @@ def test_resolve_endpoint_requires_csrf_and_resolves_hold(client: TestClient, db resolved = client.post( f"/api/v1/imports/{document.id}/rows/{hold.id}/resolve", headers={"X-CSRF-Token": csrf}, - json={"action": "confirm_as_posted", "reason": "Checked in the bank app"}, + json={"action": "confirm_as_posted"}, ) assert resolved.status_code == 200, resolved.text rows = resolved.json()["rows"] row = next(item for item in rows if item["id"] == str(hold.id)) assert row["bankStatus"] == "hold" + assert row["mcc"] == 5411 + assert row["mccName"] == "Продуктовые магазины и супермаркеты" assert row["holdResolution"]["resolution"] == "confirmed_as_posted" - assert row["holdResolution"]["reason"] == "Checked in the bank app" + assert row["holdResolution"]["reason"] is None assert row["holdResolution"]["operationId"] is not None operations = db.query(Operation).filter_by(space_id=space_id).all() assert len(operations) == 1 + assert operations[0].category_id is not None diff --git a/backend/tests/test_imports.py b/backend/tests/test_imports.py index 77fc010..41008a8 100644 --- a/backend/tests/test_imports.py +++ b/backend/tests/test_imports.py @@ -18,7 +18,7 @@ from app.imports.models import ( SourceRecordLink, StatementDocument, ) -from app.ledger.models import Operation +from app.ledger.models import Category, Operation from app.ledger.service import create_account from app.spaces.service import create_space @@ -93,6 +93,45 @@ def test_confirm_and_revert_are_idempotent(db: Session) -> None: assert db.query(SourceRecordLink).filter_by(space_id=space.id).count() == 0 +def test_import_review_and_confirmation_do_not_require_reason(db: Session) -> None: + user, space, document = ready_document(db) + document.status = "review_required" + document.reconciliation = {"full": False, "matched": None} + service.set_review_confirmation( + db, + space_id=space.id, + document_id=document.id, + account_id=document.account_id, + confirmed_without_full_reconciliation=True, + ) + assert document.reconciliation["reason"] is None + service.confirm_document(db, space_id=space.id, document_id=document.id, user=user) + assert document.status == "confirmed" + + +@pytest.mark.parametrize( + ("code", "system_key", "label"), + [ + (5411, "groceries", "Продуктовые магазины и супермаркеты"), + (5999, "others", "Прочие специализированные магазины"), + (9999, "others", None), + ], +) +def test_import_mcc_assigns_default_category_and_label( + db: Session, code: int, system_key: str, label: str | None +) -> None: + user, space, document = ready_document(db) + source = db.query(SourceRecord).filter_by(document_id=document.id).one() + source.mcc = code + row = service.document_detail(db, space_id=space.id, document_id=document.id)["records"][0] + assert row["mcc"] == code + assert row["mcc_name"] == label + + service.confirm_document(db, space_id=space.id, document_id=document.id, user=user) + operation = db.query(Operation).filter_by(space_id=space.id).one() + assert db.get(Category, operation.category_id).system_key == system_key + + def test_confirm_rejects_arithmetic_mismatch(db: Session) -> None: user, space, document = ready_document(db) document.reconciliation = {"full": True, "matched": False} @@ -222,9 +261,7 @@ def test_duplicate_candidate_can_reuse_unique_operation_without_client_id(db: Se def test_duplicate_candidates_ignore_rows_from_same_parse_run(db: Session) -> None: user, space, document = ready_document(db) service.confirm_document(db, space_id=space.id, document_id=document.id, user=user) - first_source = ( - db.query(SourceRecord).filter_by(parse_run_id=document.active_parse_run_id).one() - ) + first_source = db.query(SourceRecord).filter_by(parse_run_id=document.active_parse_run_id).one() existing_link = db.query(SourceRecordLink).filter_by(source_record_id=first_source.id).one() second_source = SourceRecord( space_id=first_source.space_id, diff --git a/backend/tests/test_savings_migration.py b/backend/tests/test_savings_migration.py index e53ba0f..eb0b06d 100644 --- a/backend/tests/test_savings_migration.py +++ b/backend/tests/test_savings_migration.py @@ -2,10 +2,11 @@ from datetime import date from pathlib import Path from alembic.config import Config -from sqlalchemy import create_engine +from sqlalchemy import create_engine, inspect from sqlalchemy.orm import Session from alembic import command +from app.categorization.models import CategoryMcc from app.config import get_settings from app.identity.models import User from app.ledger.models import Account, Category @@ -13,7 +14,8 @@ from app.spaces.models import FinancialSpace, Membership def test_upgrade_backfills_one_pair_without_claiming_same_named_rows( - tmp_path: Path, monkeypatch, + tmp_path: Path, + monkeypatch, ) -> None: database_url = f"sqlite:///{tmp_path / 'migration.db'}" monkeypatch.setenv("DATABASE_URL", database_url) @@ -61,8 +63,37 @@ def test_upgrade_backfills_one_pair_without_claiming_same_named_rows( assert system_category[0].linked_account_id == system_account[0].id assert any(item.name == "Savings" and item.system_key is None for item in accounts) assert any( - item.name_override == "Savings" and item.system_key is None + item.name_override == "Savings" and item.system_key is None for item in categories + ) + default_categories = { + item.system_key: item for item in categories + if item.system_key not in {None, "savings"} + } + assert len(default_categories) == 21 + assert "groceries" in default_categories and "others" in default_categories + assert ( + db.query(CategoryMcc) + .filter_by( + space_id=space_id, + category_id=default_categories["groceries"].id, + mcc_code=5411, + ) + .count() + == 1 + ) + + command.downgrade(config, "0003") + schema = inspect(engine) + assert schema.has_table("mcc_codes") + assert schema.has_table("bank_category_mappings") + assert not schema.has_table("category_mcc_codes") + with Session(engine) as db: + assert ( + db.query(Category) + .filter(Category.space_id == space_id, Category.system_key == "groceries") + .count() + == 0 ) finally: get_settings.cache_clear() diff --git a/backend/uv.lock b/backend/uv.lock index a107e4a..2f68381 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -103,69 +103,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/d1/095d95eaf2ed1d9f77268cf3291bde148c6cd56121f8db2c74c1ba618a0e/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6ab674f668d5962a3a4136ae0812519b0f1586874263723a32181d60d64137e1", size = 25378, upload-time = "2026-08-20T07:33:04.332Z" }, ] -[[package]] -name = "ast-serialize" -version = "0.11.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/a2/04a9383e7512c91c54b2f34b3ff86dc7d2610506f588c2bda36e952a68f7/ast_serialize-0.11.1.tar.gz", hash = "sha256:cc5db2983805f6be786488aac8c5998d5b71965488d1b18c44d435a2205a5cb4", size = 953785, upload-time = "2026-09-09T16:05:27.851Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/cb/3577c66278e4ea4ff6968aee04327447335ecfc58c76123f68cf7f76d75e/ast_serialize-0.11.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7ca1557553fdab313999ad69156de154ff87152cae963698be2e765164bb6c0", size = 897021, upload-time = "2026-09-09T16:03:59.316Z" }, - { url = "https://files.pythonhosted.org/packages/47/db/9b4eed53ab0bb63653f56ef71062fa91693c8c26b0b648c58d98365c990c/ast_serialize-0.11.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8b54e44763a851c336ca137c60a2434511f0d155e0b924ef374bfbc8d8db8927", size = 1235148, upload-time = "2026-09-09T16:04:01.201Z" }, - { url = "https://files.pythonhosted.org/packages/fd/97/1ebe323015c3cf530f08e88df9929bfd9f5cf8165842bd4b37a7c958565c/ast_serialize-0.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:753afabcc4abf295f515ea33185cf997a1990b88a29d3a3336a9acf62ea85950", size = 1216082, upload-time = "2026-09-09T16:04:02.87Z" }, - { url = "https://files.pythonhosted.org/packages/ac/72/0d3a368edc2d1e0e188c0d3f23821f4e20a6a2291b23d9df482a6b11277f/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98b8aba6bb682b9e8859c628382b4881c0600b28056d63c3168c227c449f3284", size = 1282848, upload-time = "2026-09-09T16:04:04.316Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fb/2a546a57d7caa708d739c1747bbdf45cc3aa9dac9407a11d9d67e8a0e255/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:651a7890558896c3e08089e51ab3b3a43710eab15b72ad861c400bc9a51923ba", size = 1285337, upload-time = "2026-09-09T16:04:05.899Z" }, - { url = "https://files.pythonhosted.org/packages/bf/16/4ff4179584f8b8bec348787bf721cb7ce3d4c707c7878778065249b59b9a/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff8ddc1453bfe7934292409c7b8e9f68b0a6cff4e4c12535e4b4a66fd83aca0c", size = 1554906, upload-time = "2026-09-09T16:04:07.42Z" }, - { url = "https://files.pythonhosted.org/packages/df/e8/e54a54676ca8965e1ae3cfe065dc26c75158e0b3a3ce35af283da106fbf9/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:daec16b026c5741950a00c708acdf698c4f17babbe4829dd8232324822e9514f", size = 1301472, upload-time = "2026-09-09T16:04:08.782Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ab/618835a2479868a0d4ac807dd04cf2f89d24afe22c4171538d9779c2772e/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9961abcb93bf03652ab09c00862a2396abacd57551c769ad34490a2ae508a61d", size = 1301293, upload-time = "2026-09-09T16:04:10.17Z" }, - { url = "https://files.pythonhosted.org/packages/61/cb/8acc29a1b279ccdfb30dcbdf099e1d8d34c4ee77be9be751c48061433129/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:8ca9e48edf246f09fd9bdb64fbd9cd8d18cd2a69ffc24d44891466e17595094f", size = 1307807, upload-time = "2026-09-09T16:04:11.989Z" }, - { url = "https://files.pythonhosted.org/packages/da/74/bada875132f452cb5c1179e3156df43cee6df6cb51f773220688be3d4bb4/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6a57e0e025e9dcee48e466b3cf909178e298d238148664e796fc0f60ded5e52e", size = 1356265, upload-time = "2026-09-09T16:04:13.429Z" }, - { url = "https://files.pythonhosted.org/packages/4f/ae/720a8042fc8c1d170f4cc7ed46295ef078f8198ed3476f58210a1e675e0c/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:110386eebccf200446d5f4dcc215f80866a5d3d3d054d3ced6ec1bf386cd3f02", size = 1459957, upload-time = "2026-09-09T16:04:14.878Z" }, - { url = "https://files.pythonhosted.org/packages/7d/2c/ce2ff1ffd4d70376073393c154033728c8535c17211b7710fee7b0f04474/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ef3950747f3a588f692cf4d6f2937eedd1a2d5e199f863af29e8ec7da78a4f36", size = 1562369, upload-time = "2026-09-09T16:04:16.563Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3e/d7a5dd5f609bd0bac45617ce9425e4bd1e369b998c8678edfd70c985408c/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0bc94b81878a3f999fe4cdb606bf00ef7dff6b4f66d0e089b2cf7bfc00d59beb", size = 1556466, upload-time = "2026-09-09T16:04:18.396Z" }, - { url = "https://files.pythonhosted.org/packages/99/cd/a45179802a8637f2be252163ec4e4948534fa8d88798dd391c9cc54e096e/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:341dfc9e6b4e47e5c37e1b7257a77d39f407cf49a371ee90225ab624c2fe8b36", size = 1687190, upload-time = "2026-09-09T16:04:19.965Z" }, - { url = "https://files.pythonhosted.org/packages/01/eb/ef206d764ec0554368501b8b5268f5468819dc0588815acb0575465643df/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7b524cb1b08e15db0f116075c39ab51674b6d3c8103ec1a59c8ca464be83e28a", size = 1481188, upload-time = "2026-09-09T16:04:21.368Z" }, - { url = "https://files.pythonhosted.org/packages/8b/37/9a724c523af7c8b97a659bf4dc28a8e60f97d0cf4d19be277db4067ca734/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c2358b341aca08bd2e1005713bc480bc06eee9f345b67fcb826829aa7b8d0d3", size = 1500731, upload-time = "2026-09-09T16:04:22.905Z" }, - { url = "https://files.pythonhosted.org/packages/48/10/890d5fcaf568088de7989e8cdb4ca525f485bd6cb25eea49d45f017500af/ast_serialize-0.11.1-cp314-cp314t-win32.whl", hash = "sha256:b9e61143a5904f46daa0cde74ba60d3b7c0e3e000752ee138e2bc68e126a5803", size = 1119009, upload-time = "2026-09-09T16:04:24.469Z" }, - { url = "https://files.pythonhosted.org/packages/9d/61/2bd32bd16b5e2ee07badc8c32a73c545dc5a9f087ed19c07eee1243d5a1b/ast_serialize-0.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:31df8649761d5cb6eb0de2209bd4a167c7477c0516147c70c0b2f5bddc7839bb", size = 1155665, upload-time = "2026-09-09T16:04:26.638Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b6/84bb5e1dc418e660524b873c5bac9be2bf13d38676c96feef4cd4e7d03f3/ast_serialize-0.11.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2f8f33e416a4c7aad12ad757e895c676e953b4027b80b90718dccea83d45111a", size = 1131854, upload-time = "2026-09-09T16:04:28.099Z" }, - { url = "https://files.pythonhosted.org/packages/8c/dd/16de2c0d23a6b298c735d4e4b56d86fa70476eef98e2c66ceaa65503b62f/ast_serialize-0.11.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:a015ede631eeb098a23de8ec0cfe11a1dd02159ab7a26bdc7c6bde2befbbc7b4", size = 1235495, upload-time = "2026-09-09T16:04:29.654Z" }, - { url = "https://files.pythonhosted.org/packages/27/f7/302d2251e6298bbeabc8a1815c9147127031517b305001a02f34fd46b6b5/ast_serialize-0.11.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:57b7d52d1f5c92905cd648bda9efc83cac33097de1e5bb68de8feca9b5f7e87a", size = 1215642, upload-time = "2026-09-09T16:04:31.186Z" }, - { url = "https://files.pythonhosted.org/packages/93/06/93b6527646613502f364cebfb23783fa6701cdbe90761ee26144f1fa20b9/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e47b9b028efbc0486263a49f782ad0ae3e879855fe5a2897b59feefb78e1c40c", size = 1283526, upload-time = "2026-09-09T16:04:32.597Z" }, - { url = "https://files.pythonhosted.org/packages/2f/ac/ffb216262c9a582d039d846081b8c3017dc368cbed5a2520deca5cb7f8cb/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00adb748034c7b1938ec56925f9f6230ffcd9c56d47355fe32485df29b90b977", size = 1287526, upload-time = "2026-09-09T16:04:34.214Z" }, - { url = "https://files.pythonhosted.org/packages/a2/06/19a4c4837c4d5e73b982938d6da3c9ae10513b61527575b3e1b8396f9298/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:82359d00ea808c260c327e43955c0190bb7baef152a01ae0b5c74d05387b9552", size = 1558354, upload-time = "2026-09-09T16:04:35.669Z" }, - { url = "https://files.pythonhosted.org/packages/e5/29/2373199907b2d97feed176115308dfb3dd0446566b047009828f266d2d66/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f35bb9ddeed4008c7d3e272dd1999885d712e1ce623b0a65ba8a8ed1d51c35cf", size = 1302731, upload-time = "2026-09-09T16:04:37.423Z" }, - { url = "https://files.pythonhosted.org/packages/33/55/ab6805a1457dbe565c84f8d36e2aa4207ef22408adfd01d225823cd07484/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74093ed682ba58456f0d4450da7fb62b826fddd97824d44fc4a81ca6e0bf9e23", size = 1301594, upload-time = "2026-09-09T16:04:38.861Z" }, - { url = "https://files.pythonhosted.org/packages/52/e2/4f54eab2201bb420d39bf61423abcb5d4daffd28e2270e1c5eee2bf3c0f1/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:4dd7218870c203eff4533cfc04a39f29077d6789b20f5b746e7648fe5762a548", size = 1309355, upload-time = "2026-09-09T16:04:40.543Z" }, - { url = "https://files.pythonhosted.org/packages/10/07/755dd98664e2374080b72ccb5fea63d9f11b4bec2409a05b2a4ebc97618d/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e8d20700766171a8a17f89cf53d7bd9712c509460d28a51f7f98340577b78aa", size = 1356645, upload-time = "2026-09-09T16:04:42.299Z" }, - { url = "https://files.pythonhosted.org/packages/34/a0/fadbde3e108064236e2679728ce6d8247aefc81bac5fd82505b59cf69172/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:20b3371c0403099cd55d59f4bcedb6d3994d9db3fa41711c648760a89ef2a575", size = 1459696, upload-time = "2026-09-09T16:04:44.069Z" }, - { url = "https://files.pythonhosted.org/packages/02/fd/b4f95249bd895368ea8290668e27d1f0a5c4ad888bd72a34c7beb2a500d6/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:cc1a78b913f8665dda145999b1b4805ad2ef442ecbe3b819512cf7f95e70498a", size = 1562517, upload-time = "2026-09-09T16:04:45.461Z" }, - { url = "https://files.pythonhosted.org/packages/68/98/6afac594380410710d9d863b9ef41e9c6ca89bd901bda1464ee9e99180a8/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:38f7141881e783bccc362d201d7fd7437934216b669b9fef39febc2f63fd2d9b", size = 1556951, upload-time = "2026-09-09T16:04:47.12Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6f/5a81b5cbf1731f4722bc90adf4b4ace7dda69ca29837844147063aeafb22/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:13903a3f212ab9e5d05a6c3f2337288f4f8f4c18e6ab3817417ae7a3d46dee14", size = 1691039, upload-time = "2026-09-09T16:04:48.691Z" }, - { url = "https://files.pythonhosted.org/packages/99/eb/d9b903206cbd6d5143838367d385fa88ec49eaae2cc12f284327c6e0af05/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:e5a9c53e64732118ae8235d0e57eb90e2333e90ff749a1a7dca6f8bfdfa5200e", size = 1483297, upload-time = "2026-09-09T16:04:50.136Z" }, - { url = "https://files.pythonhosted.org/packages/ed/48/ee13b4079ec67e9b333a87e5bb8eee643ab1b49f7173b3fca71158c6cdf6/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:aecb606f67f21fd1c0ffba1826470d2ee400e41b23881ad9a1b2157306865b1a", size = 1501686, upload-time = "2026-09-09T16:04:51.734Z" }, - { url = "https://files.pythonhosted.org/packages/15/67/2175b79e1ee6b042e4bf8ed6871e60cafea7e9cf3fc69e87d7a9d520ae77/ast_serialize-0.11.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:d1ef9c478d8c8ca83499704d13e5ac32c840ed7ca7884aaaf716166aca5a2806", size = 1119522, upload-time = "2026-09-09T16:04:53.248Z" }, - { url = "https://files.pythonhosted.org/packages/f6/c3/8c96aa5f121e9bdda0346b2df3919185eb1ec9a2cce2d20e311b4575ca0b/ast_serialize-0.11.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:27eef0739e0110f5db1ff5dfa38b3dadfcb6f0c31975a2b412f9f2bd72139cea", size = 1157260, upload-time = "2026-09-09T16:04:54.944Z" }, - { url = "https://files.pythonhosted.org/packages/10/bc/8aa663209e335eca73c9e1006197222b6b8dafa6665e513cdd975aa65496/ast_serialize-0.11.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:f9454960bbf185d33c669ccd5c9b76c96709a4542a95a65e27342c4d10dd8e20", size = 1132060, upload-time = "2026-09-09T16:04:56.501Z" }, - { url = "https://files.pythonhosted.org/packages/7f/a7/e738887429de70350d9e25f84a95efbdd086b79579a811509dee8b02d7d5/ast_serialize-0.11.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7b9a6089f1337838492b217707e9a55d0b9b407fe9169a960fa12282abf2234c", size = 1240585, upload-time = "2026-09-09T16:04:59.616Z" }, - { url = "https://files.pythonhosted.org/packages/a1/f9/46b64fa883f3c8b8cf51629978f16d8a2da3d5c5c02f64add40864907703/ast_serialize-0.11.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:68929da7fb1c7375f69641baac9519d5c41ee441cd09a591ea5dfc83107ffe6f", size = 1228038, upload-time = "2026-09-09T16:05:00.948Z" }, - { url = "https://files.pythonhosted.org/packages/ad/89/fe75c8d0f104a4be11cd0e23acecf129484c9ba06c7130033752d63a78c5/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8945557ed3015173dbf8d44184da9621add9162720acbc0ac4832b5a091b4e8a", size = 1292388, upload-time = "2026-09-09T16:05:02.498Z" }, - { url = "https://files.pythonhosted.org/packages/cb/56/3f21c881900d8254a35195e1e962355b4d570ccd9cc193b0ba08abb81952/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9cbed53bb8992b72e587dd47d0ca71703f5f715420f48ed57587c06c2fcd213", size = 1294413, upload-time = "2026-09-09T16:05:04.212Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b2/8d77be3dad1139158c59e370391f4990ea73f7f102983392272856bd3a63/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65c836d1ab7af65e64a1dd73de82572566e838dd27529b1124eac0ac86f35e76", size = 1565921, upload-time = "2026-09-09T16:05:05.753Z" }, - { url = "https://files.pythonhosted.org/packages/94/2e/0675b5796c897f957fbdac6af0ebeeaa9a63cee866c1c54f1e5136ef5bf6/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ab6df9898600ff45149b86c12bb5aa3359cc71843ad99ad0c924d11392469ab", size = 1312160, upload-time = "2026-09-09T16:05:07.269Z" }, - { url = "https://files.pythonhosted.org/packages/93/c2/1749fa4efae0dc98aad3c3d29f0178e2609208c3ba34700b4551082e40d5/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3eebd3dd25a81839ac7d25b5f3fb3527f6141b35eca29f63d2613fe6254307fd", size = 1312405, upload-time = "2026-09-09T16:05:08.753Z" }, - { url = "https://files.pythonhosted.org/packages/00/14/2afc9f9d7db551f805b4d5e496946dc3dc2a703d27294ccc200b6ff07a7a/ast_serialize-0.11.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:311333c5f58f55fc04121be2c5adffa42a19e1038a43a2cef862fdf58c45bf4f", size = 1319458, upload-time = "2026-09-09T16:05:10.552Z" }, - { url = "https://files.pythonhosted.org/packages/c7/4a/8b2866d9d6d5d0b037d7bf2b74db6e75a66695c503248632116f24009a8d/ast_serialize-0.11.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:aa8d0a9f6d02e7626cdf540555bc2f5313eabe803dcca04854fc628ddf4b1058", size = 1365055, upload-time = "2026-09-09T16:05:12.069Z" }, - { url = "https://files.pythonhosted.org/packages/47/17/73119504574f9b46610ffb718501b254c8adb727d4e43c5babc3c62d4529/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e209a797fdf8680d58d2e969a9ddb25058ea682f6cff59dbabc98d8aef4e0db1", size = 1467771, upload-time = "2026-09-09T16:05:13.458Z" }, - { url = "https://files.pythonhosted.org/packages/56/f5/776bfb7a856ba0bb9bd373b76a90230fb77b1a10c987810b621ed244d5ba/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b391becd31e9889da438d388aac0362e3dfd222affbe45d920580c351486c787", size = 1571459, upload-time = "2026-09-09T16:05:15.15Z" }, - { url = "https://files.pythonhosted.org/packages/c7/43/86a655170ee36a8fdce65a922b7e34040bb319eb4ec251161032343c1fc3/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:021afb3482d27d1dace9c8999724a13083e96c7ece785e53e418bf5df3b50e0a", size = 1568959, upload-time = "2026-09-09T16:05:16.677Z" }, - { url = "https://files.pythonhosted.org/packages/ed/b1/015a3a156dc9bd46e9c4e4f24a939eb0b87179b1c53c9495ac6f088f13b4/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:c1dc60251d93beff32d4147ecd4ccc518b0f022a900093f6f3021c2012416f0f", size = 1698173, upload-time = "2026-09-09T16:05:18.248Z" }, - { url = "https://files.pythonhosted.org/packages/0e/ea/a988b70980aca3a8a3fb731901dc911fe73a456cebbc225b767bbe5490ff/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:b77d6a2267227d68d8221cfca177f8c6d9f68019f3250401d0355d2638db9fb9", size = 1493298, upload-time = "2026-09-09T16:05:19.806Z" }, - { url = "https://files.pythonhosted.org/packages/de/15/e049b2701ddab9087d7f62448f4c2c1b1463e676baa471345cf3e4a31e1b/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e68ac7d7fd1a5d42a3a6d726d7cc8a7e0c9987934f11c6a5162aa2cf68e81e24", size = 1510213, upload-time = "2026-09-09T16:05:21.402Z" }, - { url = "https://files.pythonhosted.org/packages/46/a2/23d555eb842d1397dce54fac31851e6f151e03a0ae74db389d9b39718c33/ast_serialize-0.11.1-cp39-abi3-win32.whl", hash = "sha256:ed449d786b9032a7b85fcd6c2f7f54c4cd0e1e1ad254d396805ff922096bf08d", size = 1125239, upload-time = "2026-09-09T16:05:22.796Z" }, - { url = "https://files.pythonhosted.org/packages/1f/4a/251f3fd1b8a5549edaedf8f3ba0b9fb5060e194d3c0ed208b593fccaeff1/ast_serialize-0.11.1-cp39-abi3-win_amd64.whl", hash = "sha256:6b43f5a9b9a8dd20ba3124914e63aa7d7427de761ac849081cda403c2112fda0", size = 1164260, upload-time = "2026-09-09T16:05:24.714Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b5/08e2f643feb9e4d72d42a484949ad51238b18262a01c7036196c201cc330/ast_serialize-0.11.1-cp39-abi3-win_arm64.whl", hash = "sha256:a579ea6f473aab3958a64734194b22fbaec108ec45d994c28d3bd721e1e1da32", size = 1137533, upload-time = "2026-09-09T16:05:26.095Z" }, -] - [[package]] name = "billiard" version = "4.2.4" @@ -228,15 +165,6 @@ redis = [ { name = "kombu", extra = ["redis"] }, ] -[[package]] -name = "certifi" -version = "2026.7.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, -] - [[package]] name = "cffi" version = "2.1.1" @@ -508,6 +436,7 @@ dependencies = [ { name = "defusedxml" }, { name = "email-validator" }, { name = "fastapi" }, + { name = "iso18245" }, { name = "openpyxl" }, { name = "pdfplumber" }, { name = "psycopg", extra = ["binary"] }, @@ -521,11 +450,11 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "httpx" }, + { name = "httpx2" }, { name = "hypothesis" }, - { name = "mypy" }, { name = "pytest" }, { name = "ruff" }, + { name = "ty" }, ] [package.metadata] @@ -538,6 +467,7 @@ requires-dist = [ { name = "defusedxml", specifier = ">=0.7" }, { name = "email-validator", specifier = ">=2.1" }, { name = "fastapi", specifier = ">=0.115" }, + { name = "iso18245", specifier = ">=1.4.1" }, { name = "openpyxl", specifier = ">=3.1" }, { name = "pdfplumber", specifier = ">=0.11" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, @@ -551,11 +481,11 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "httpx", specifier = ">=0.27" }, + { name = "httpx2", specifier = ">=2.0" }, { name = "hypothesis", specifier = ">=6.112" }, - { name = "mypy", specifier = ">=1.10" }, { name = "pytest", specifier = ">=8.2" }, { name = "ruff", specifier = ">=0.5" }, + { name = "ty", specifier = ">=0.0.18" }, ] [[package]] @@ -591,16 +521,16 @@ wheels = [ ] [[package]] -name = "httpcore" -version = "1.0.9" +name = "httpcore2" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, { name = "h11" }, + { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/8c/e925b1c92018abb3a1863ce1549d76d2381e334d21d65d4ac8f65dabd78a/httpcore2-2.13.0.tar.gz", hash = "sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db", size = 67740, upload-time = "2026-09-14T14:18:04.717Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0d/117a771a2bb91df334b66bf4da14cd02f21aefbcfe53180f336ce55e8f90/httpcore2-2.13.0-py3-none-any.whl", hash = "sha256:35ae5be347aa40467b4a5dc032ac67ebb6d27189fc97e8cebcf99616f6a1bb9e", size = 83162, upload-time = "2026-09-14T14:18:02.529Z" }, ] [[package]] @@ -626,18 +556,28 @@ wheels = [ ] [[package]] -name = "httpx" -version = "0.28.1" +name = "httpx2" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/a0/e9deef4654132857b5a5dbe4eddd0ac59c2814500e11f2f5044cd81103ee/httpx2-2.13.0.tar.gz", hash = "sha256:81bd07dc67a3701729ef1f777a3c00c915d4539604fdb5afd327f8682f6b7b44", size = 100290, upload-time = "2026-09-14T14:18:05.486Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d1/a0c72b0e006df654709fbc366cc5bcb53e5aee13e1e3395152c6dd293376/httpx2-2.13.0-py3-none-any.whl", hash = "sha256:fc12720cedf72faa26cca6b4ca394e05c894e7d7933fc45cafe767960804e49a", size = 95565, upload-time = "2026-09-14T14:18:03.553Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -707,6 +647,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] +[[package]] +name = "importlib-resources" +version = "5.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/f1/8711c49ffd121083007a24c1bff0d324c9ff621d4fdf8b4ffcb8d9e60330/importlib_resources-5.13.0.tar.gz", hash = "sha256:82d5c6cca930697dbbd86c93333bb2c2e72861d4789a11c2662b933e5ad2b528", size = 36550, upload-time = "2023-07-07T16:21:00.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/68/bd9dd6bbf06772c7accce77d0354d783333fbe712a60b08fc13540c05422/importlib_resources-5.13.0-py3-none-any.whl", hash = "sha256:9f7bd0c97b79972a6cce36a366356d16d5e13b09679c11a58f1014bfdf8e64b2", size = 32912, upload-time = "2023-07-07T16:20:58.128Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -716,6 +665,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "iso18245" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-resources" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/de/e8095ac5ac026e4ad23c07d1cb35bc1495051d2ae32c863b246d43e1d9a1/iso18245-1.4.1.tar.gz", hash = "sha256:1ee69d2b6b6334e29d9131371970a171649a969fe319ae7b09afcfc2ecb02720", size = 50749, upload-time = "2025-09-03T09:23:07.299Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/5e/ef3e709668014b9ebabb86d05f4848f96ffac406e202c7207b773b3ccc2f/iso18245-1.4.1-py3-none-any.whl", hash = "sha256:07fcac02b22044e5f188a7683684f2166900a36d39119eb6ca0f4ef5f0df5772", size = 66746, upload-time = "2025-09-03T09:23:05.833Z" }, +] + [[package]] name = "jmespath" version = "1.1.0" @@ -745,45 +706,6 @@ redis = [ { name = "redis" }, ] -[[package]] -name = "librt" -version = "0.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, - { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, - { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, - { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, - { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, - { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, - { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, - { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, - { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, - { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, - { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, - { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, - { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, - { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, - { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, - { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, - { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, - { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, - { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, - { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, - { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, - { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, - { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, - { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, - { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, - { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, - { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, - { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, - { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, - { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, -] - [[package]] name = "mako" version = "1.4.1" @@ -826,44 +748,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] -[[package]] -name = "mypy" -version = "2.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ast-serialize" }, - { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, - { name = "mypy-extensions" }, - { name = "pathspec" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, - { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, - { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, - { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, - { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, - { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, - { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, - { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, - { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, - { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, - { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, - { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, - { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - [[package]] name = "openpyxl" version = "3.1.5" @@ -885,15 +769,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] -[[package]] -name = "pathspec" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, -] - [[package]] name = "pdfminer-six" version = "20260107" @@ -1292,6 +1167,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "ty" +version = "0.0.83" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/7d/2fd9575bce2d14e2281bec82d0713dee68329b0dc944e9c06c2e36b761fe/ty-0.0.83.tar.gz", hash = "sha256:db118de73c05ac476faceb4d42d59782feaeddfc4d721fd3ae8b807a0a2ae4e4", size = 7373076, upload-time = "2026-09-21T22:47:29.207Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/85/7259431bade2317d8b82fa27220ae4618c21396acf0ca28a7bd75e92afd7/ty-0.0.83-py3-none-linux_armv6l.whl", hash = "sha256:836bc08bad96ecc7e38baf1a6ddaec15aa8c8b46ae99a96365397b5f6481b7a5", size = 13905669, upload-time = "2026-09-21T22:46:36.677Z" }, + { url = "https://files.pythonhosted.org/packages/d2/30/9527386ade8dd71fc52f65cde3c094411af7ad0d505f9063ef0841998702/ty-0.0.83-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f1fb029fe076c7dca404d98e38fdb5fdbc2038a09407f89d43be1fdd9b2c459", size = 13499505, upload-time = "2026-09-21T22:46:40.114Z" }, + { url = "https://files.pythonhosted.org/packages/a0/54/63080d8cfd59a53810499a69a2ebc995724be27618b70d7f099e73ace317/ty-0.0.83-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3515776d01ee3b1be974dafa73d449d0bc711afbc12ba341f82f70bc641ff4ee", size = 13498980, upload-time = "2026-09-21T22:46:43.197Z" }, + { url = "https://files.pythonhosted.org/packages/49/78/e647f190bd9513d09db30d45309e9b5df070b5caa44af2de3290fab59fbe/ty-0.0.83-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5b80d66c7f9c8d60f25ccfd40c6eaebe8fd7c9005074904e0e164dd89c13d03", size = 13494705, upload-time = "2026-09-21T22:46:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/2e/12/aef645b13c5bd15e3e5993537dbc23f7d65475f3cf1755d07b4d3e56850d/ty-0.0.83-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:57eae4aa55dab1af5d1423227c6517ccab1e658fe265c8e5a58caa584ca2876b", size = 13661466, upload-time = "2026-09-21T22:46:49.392Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c4/11872e823653c568468ef7f0c3b23aed7ad2d33652e20144d28d5bfad58a/ty-0.0.83-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dea3680911340ecf93dff5786dbeefd69c9399df685a22751e1fbd27e6007b50", size = 14479677, upload-time = "2026-09-21T22:46:52.277Z" }, + { url = "https://files.pythonhosted.org/packages/8e/db/5b0d0d24d16d24d781f29ee842ee718903e453dc4068acb02bef860d5540/ty-0.0.83-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8453786c274f48e2a89b78e8a505a4f449323f357f2ce26a9b90a4bd68176f67", size = 14972208, upload-time = "2026-09-21T22:46:55.242Z" }, + { url = "https://files.pythonhosted.org/packages/54/04/0077a53bcf6ceb41365a45d8091b52094c91a5f4b071c2a6d705ed8bb65b/ty-0.0.83-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7701bdae3b52271cf0835abe4202aa3b23b735425d8c5a3e6a8ca1c68d39ead8", size = 14660550, upload-time = "2026-09-21T22:46:58.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ff/b6c879f1c3121387edd19ebdb1fb02af30691f88f632278a5d467fb5f348/ty-0.0.83-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa95b4376447e5309fe3ccc98ee75916a9082714e6a029b99427ca62021dc94a", size = 14147376, upload-time = "2026-09-21T22:47:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bc/82e730247d53cb02acd5a91237c0c62f215b2e0379689a550176d307ed89/ty-0.0.83-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2a4550a08fc1899a33e4a08787a73ec9daa99d292268df4f0a5f1c24c63854c3", size = 14567502, upload-time = "2026-09-21T22:47:04.344Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/bb6f57923231f51b626931625ada71e219d64541f8fadf96065bc5034358/ty-0.0.83-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:feb72080f0835fa2b27485e3b11fa50dc57c30e0ba73808a3f7d64b56db83f6f", size = 13443051, upload-time = "2026-09-21T22:47:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/50/20/9862431662d4453527cd52cf0f30d5975cd1801aa39e570bf45fb299be77/ty-0.0.83-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3e5206def2d6e1df93c75ed8dc763255233ccc3153647509d125bada10ad770a", size = 13678908, upload-time = "2026-09-21T22:47:10.714Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e3/94ebbe1573b76660fa09f2dbce96da6d0bb69a2d29c7b6ad538b452979d4/ty-0.0.83-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bdf5dfa157f5c127ba096fe0561b47877e6e79562c7fc2b35906ba5e9f6bb723", size = 13937248, upload-time = "2026-09-21T22:47:14.133Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c0/3ea45c2ec3a82fddb7137e457ccb2a452446905430d225adaa4990cdc338/ty-0.0.83-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:626d6ad00f05a5d9b7ea80de44b12931d0c0fb50e64eab20b7fcc13e9aff2a68", size = 14290826, upload-time = "2026-09-21T22:47:16.874Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b2/c941ce83a59e46ef31dd04bdf6067530e7ec77d5f0cdd1e223fbc6fc351b/ty-0.0.83-py3-none-win32.whl", hash = "sha256:0391017dac6ba3e452e0322301f50bba12fbccadac07d7fd888c46951ad37638", size = 13105291, upload-time = "2026-09-21T22:47:19.807Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8a/5eef9458505698b144643cbd2062f465c5d860d8cf86a8710d74505ad9a0/ty-0.0.83-py3-none-win_amd64.whl", hash = "sha256:1b67d86f5bbad4076ae1a2ff23a6a4493219885d72d4b148cb0193e090390060", size = 13901557, upload-time = "2026-09-21T22:47:23.429Z" }, + { url = "https://files.pythonhosted.org/packages/9f/27/a06061513f1de25569c2e8898018bd552ab7b31afa7cfe818703269b3350/ty-0.0.83-py3-none-win_arm64.whl", hash = "sha256:a916a3d7d68fe30b7aacdb9fe16d461fa944d722e59e96ea0fa9d206a1b6625f", size = 13627960, upload-time = "2026-09-21T22:47:26.305Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index e7a62cf..e2c13f4 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -218,16 +218,19 @@ export interface CategoryDefinition { systemKey: string | null linkedAccountId: string | null archived: boolean + mccCodes: number[] } export interface CreateCategoryPayload { name: string kind: CreatableCategoryKind + mccCodes?: number[] } export interface PatchCategoryDefinitionPayload { name?: string archived?: boolean + mccCodes?: number[] } export type ImportStatus = @@ -291,6 +294,8 @@ export interface ImportRow { direction: 'in' | 'out' bankStatus: RowBankStatus description: string | null + mcc: number | null + mccName: string | null payeeHint: string | null warnings: string[] decision: RowDecision @@ -322,13 +327,12 @@ export interface SaveRowDecisionPayload { export interface ResolveHoldPayload { action: HoldResolutionAction - reason: string + reason?: string accountId?: string } export interface ConfirmImportPayload { confirmedWithoutFullReconciliation?: boolean - reason?: string } export interface CategorySpendItem { diff --git a/frontend/src/components/budget/CategoryManagerModal.tsx b/frontend/src/components/budget/CategoryManagerModal.tsx index efc3d75..2e77432 100644 --- a/frontend/src/components/budget/CategoryManagerModal.tsx +++ b/frontend/src/components/budget/CategoryManagerModal.tsx @@ -19,26 +19,42 @@ import { categoryLabel } from '@/lib/categoryLabel' const CREATE_KINDS: readonly CreatableCategoryKind[] = ['monthly', 'savings_envelope'] +function parseMccCodes(value: string): number[] { + return value.split(',').map((code) => Number(code.trim())) +} + function CreateCategoryForm() { const { t } = useTranslation() const create = useCreateCategory() const schema = z.object({ name: z.string().trim().min(1, t('common.requiredField')), kind: z.enum(['monthly', 'savings_envelope']), + mccCodes: z.string().trim().regex(/^(?:\d{3,4}(?:\s*,\s*\d{3,4})*)?$/, t('budget.manager.mccCodesInvalid')).optional(), }) type Values = z.infer const { register, handleSubmit, reset, + watch, formState: { errors }, } = useForm({ resolver: zodResolver(schema), - defaultValues: { name: '', kind: 'monthly' }, + defaultValues: { name: '', kind: 'monthly', mccCodes: '' }, + shouldUnregister: true, }) const submit = handleSubmit((values) => { - create.mutate(values, { onSuccess: () => reset({ name: '', kind: values.kind }) }) + create.mutate( + { + name: values.name, + kind: values.kind, + ...(values.kind === 'monthly' && values.mccCodes + ? { mccCodes: parseMccCodes(values.mccCodes) } + : {}), + }, + { onSuccess: () => reset({ name: '', kind: values.kind, mccCodes: '' }) }, + ) }) return ( @@ -55,6 +71,14 @@ function CreateCategoryForm() { ))} + {watch('kind') === 'monthly' ? ( + + ) : null} @@ -103,12 +127,59 @@ function RenameModal({ ) } +function MccCodesModal({ + definition, + onClose, +}: { + definition: CategoryDefinition + onClose: () => void +}) { + const { t } = useTranslation() + const update = useUpdateCategoryDefinition() + const schema = z.object({ + mccCodes: z.string().trim().regex(/^(?:\d{3,4}(?:\s*,\s*\d{3,4})*)?$/, t('budget.manager.mccCodesInvalid')), + }) + type Values = z.infer + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { mccCodes: definition.mccCodes.map((code) => String(code).padStart(4, '0')).join(', ') }, + }) + + const submit = handleSubmit((values) => { + update.mutate( + { id: definition.id, payload: { mccCodes: values.mccCodes ? parseMccCodes(values.mccCodes) : [] } }, + { onSuccess: onClose }, + ) + }) + + return ( + +
+ + + +
+ ) +} + export function CategoryManagerModal({ onClose }: { onClose: () => void }) { const { t } = useTranslation() const definitions = useCategoryDefinitions() const update = useUpdateCategoryDefinition() const [tab, setTab] = useState<'active' | 'archived'>('active') const [renameTarget, setRenameTarget] = useState(null) + const [mccTarget, setMccTarget] = useState(null) const [archiveTarget, setArchiveTarget] = useState(null) return ( @@ -161,25 +232,44 @@ export function CategoryManagerModal({ onClose }: { onClose: () => void }) { {t(`budget.category.kind.${definition.kind}`)} {isSystem ? ` · ${t('budget.manager.systemProtected')}` : ''} + {definition.mccCodes.length > 0 ? ( +
+ {t('budget.manager.mccCodesCount', { count: definition.mccCodes.length })} + {definition.mccCodes.map((code) => String(code).padStart(4, '0')).join(', ')} +
+ ) : null} - {!isSystem ? ( + {!isSystem || (definition.kind === 'monthly' && !definition.archived) ? (
{!definition.archived ? ( <> - - + {!isSystem ? ( + + ) : null} + {definition.kind === 'monthly' ? ( + + ) : null} + {!isSystem ? ( + + ) : null} ) : (
-
1567 nodes · 4903 edges · 85 communities
+
1620 nodes · 5066 edges · 89 communities