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.
This commit is contained in:
k1nq
2026-09-23 10:57:27 +05:00
parent db4dd7572e
commit 63d065011b
40 changed files with 11352 additions and 7475 deletions
+2 -2
View File
@@ -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()"
+1 -1
View File
@@ -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
@@ -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())
@@ -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 = ''))",
)
+49 -11
View File
@@ -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}")
+4 -2
View File
@@ -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")
+379
View File
@@ -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()
+28
View File
@@ -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."""
+152 -1
View File
@@ -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
+2
View File
@@ -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",
+3 -3
View File
@@ -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")
+1 -6
View File
@@ -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"],
+4 -3
View File
@@ -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
+37 -15
View File
@@ -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,
+2 -1
View File
@@ -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",
+2
View File
@@ -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
+5 -10
View File
@@ -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"]
+55
View File
@@ -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
+2 -1
View File
@@ -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:
+48 -3
View File
@@ -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",
+74 -34
View File
@@ -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
+41 -4
View File
@@ -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,
+34 -3
View File
@@ -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()
+83 -174
View File
@@ -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"
+6 -2
View File
@@ -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 {
@@ -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<typeof schema>
const {
register,
handleSubmit,
reset,
watch,
formState: { errors },
} = useForm<Values>({
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() {
</option>
))}
</SelectField>
{watch('kind') === 'monthly' ? (
<TextField
label={t('budget.manager.mccCodes')}
hint={t('budget.manager.mccCodesHint')}
error={errors.mccCodes?.message}
{...register('mccCodes')}
/>
) : null}
<Button type="submit" disabled={create.isPending}>
{create.isPending ? t('common.saving') : t('common.create')}
</Button>
@@ -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<typeof schema>
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Values>({
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 (
<Modal open onClose={onClose} title={t('budget.manager.editMccCodesTitle')}>
<form onSubmit={submit} noValidate className="form-grid">
<TextField
label={t('budget.manager.mccCodes')}
hint={t('budget.manager.mccCodesHint')}
error={errors.mccCodes?.message}
{...register('mccCodes')}
/>
<Button type="submit" disabled={update.isPending}>
{update.isPending ? t('common.saving') : t('common.save')}
</Button>
</form>
</Modal>
)
}
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<CategoryDefinition | null>(null)
const [mccTarget, setMccTarget] = useState<CategoryDefinition | null>(null)
const [archiveTarget, setArchiveTarget] = useState<CategoryDefinition | null>(null)
return (
<Modal open onClose={onClose} title={t('budget.manager.title')}>
@@ -161,25 +232,44 @@ export function CategoryManagerModal({ onClose }: { onClose: () => void }) {
{t(`budget.category.kind.${definition.kind}`)}
{isSystem ? ` · ${t('budget.manager.systemProtected')}` : ''}
</span>
{definition.mccCodes.length > 0 ? (
<details className="manager-row-meta">
<summary>{t('budget.manager.mccCodesCount', { count: definition.mccCodes.length })}</summary>
{definition.mccCodes.map((code) => String(code).padStart(4, '0')).join(', ')}
</details>
) : null}
</div>
{!isSystem ? (
{!isSystem || (definition.kind === 'monthly' && !definition.archived) ? (
<div className="inline-actions manager-row-actions">
{!definition.archived ? (
<>
<Button
variant="subtle"
disabled={update.isPending}
onClick={() => setRenameTarget(definition)}
>
{t('budget.manager.rename')}
</Button>
<Button
variant="subtle"
disabled={update.isPending}
onClick={() => setArchiveTarget(definition)}
>
{t('budget.manager.archive')}
</Button>
{!isSystem ? (
<Button
variant="subtle"
disabled={update.isPending}
onClick={() => setRenameTarget(definition)}
>
{t('budget.manager.rename')}
</Button>
) : null}
{definition.kind === 'monthly' ? (
<Button
variant="subtle"
disabled={update.isPending}
onClick={() => setMccTarget(definition)}
>
{t('budget.manager.editMccCodes')}
</Button>
) : null}
{!isSystem ? (
<Button
variant="subtle"
disabled={update.isPending}
onClick={() => setArchiveTarget(definition)}
>
{t('budget.manager.archive')}
</Button>
) : null}
</>
) : (
<Button
@@ -204,6 +294,9 @@ export function CategoryManagerModal({ onClose }: { onClose: () => void }) {
{renameTarget ? (
<RenameModal definition={renameTarget} onClose={() => setRenameTarget(null)} />
) : null}
{mccTarget ? (
<MccCodesModal definition={mccTarget} onClose={() => setMccTarget(null)} />
) : null}
<ConfirmDialog
open={archiveTarget !== null}
danger
+2
View File
@@ -67,6 +67,8 @@ export function useUpdateCategoryDefinition() {
toast.push('success', t('toast.categoryArchived'))
} else if (variables.payload.archived === false) {
toast.push('success', t('toast.categoryRestored'))
} else if (variables.payload.mccCodes !== undefined) {
toast.push('success', t('toast.categoryMccUpdated'))
} else {
toast.push('success', t('toast.categoryRenamed', { name: definition.name ?? '' }))
}
+24 -2
View File
@@ -254,6 +254,12 @@
"systemBadge": "System",
"systemProtected": "renaming and archiving are unavailable",
"rename": "Rename",
"mccCodes": "MCC codes",
"mccCodesHint": "Optional: comma-separated 3- or 4-digit codes",
"mccCodesInvalid": "Enter 3- or 4-digit MCC codes separated by commas",
"mccCodesCount": "MCC codes ({{count}})",
"editMccCodes": "Edit MCC",
"editMccCodesTitle": "Category MCC codes",
"archive": "Archive",
"restore": "Restore",
"renameTitle": "Rename category",
@@ -368,7 +374,7 @@
"holdCancelledTitle": "Operation cancelled",
"holdCancelledBody": "The held operation of {{date}} for {{amount}} will be marked cancelled and never enters the ledger.",
"holdReason": "Resolution reason",
"holdReasonHint": "Required; kept in the audit journal",
"holdReasonHint": "Optional; you can leave it blank",
"holdResolution.confirmed_as_posted": "Posted",
"holdResolution.discarded_as_cancelled": "Cancelled",
"warnings": "Warnings ({{count}})",
@@ -389,6 +395,7 @@
"rowsTitle": "Statement rows",
"noRows": "No rows.",
"rowWarnings": "Notes",
"mcc": "MCC",
"rowWarnings.amount": "amount",
"rowWarnings.date": "date",
"rowWarnings.direction": "direction",
@@ -405,7 +412,6 @@
"confirmTitle": "Apply import",
"confirmBody": "Operations will be created from the selected rows. Applying is atomic and can be reverted.",
"confirmWithout": "I confirm applying without full balance reconciliation",
"confirmReason": "Reason",
"reparse": "Reparse",
"revert": "Revert import",
"revertTitle": "Revert import",
@@ -463,6 +469,7 @@
"savingsConversionReverted": "Transfer reverted to expense",
"categoryCreated": "Category “{{name}}” created",
"categoryRenamed": "Category renamed to “{{name}}”",
"categoryMccUpdated": "Category MCC codes updated",
"categoryArchived": "Category archived",
"categoryRestored": "Category restored",
"planUpdated": "Plan updated",
@@ -491,12 +498,27 @@
"categories": {
"uncategorized": "Uncategorized",
"groceries": "Groceries",
"cafes": "Cafes and restaurants",
"transport": "Transport",
"fuel": "Fuel and gas stations",
"housing": "Housing",
"utilities": "Utilities",
"health": "Health",
"electronics": "Electronics",
"clothing": "Clothing and footwear",
"beauty": "Beauty and personal care",
"education": "Education",
"entertainment": "Entertainment",
"sport": "Sports and fitness",
"travel": "Travel",
"home": "Home improvement",
"telecom": "Phone and internet",
"kids": "Children",
"pets": "Pets",
"gifts": "Gifts and hobbies",
"auto": "Automotive",
"finance": "Finance and insurance",
"others": "Other",
"eating_out": "Eating out",
"clothes": "Clothes",
"personal": "Personal",
+24 -2
View File
@@ -254,6 +254,12 @@
"systemBadge": "Системная",
"systemProtected": "переименование и архивирование недоступны",
"rename": "Переименовать",
"mccCodes": "Коды МСС",
"mccCodesHint": "Необязательно: 3–4-значные коды через запятую",
"mccCodesInvalid": "Укажите 3–4-значные коды МСС через запятую",
"mccCodesCount": "Коды МСС ({{count}})",
"editMccCodes": "Изменить МСС",
"editMccCodesTitle": "Коды МСС категории",
"archive": "В архив",
"restore": "Восстановить",
"renameTitle": "Переименовать категорию",
@@ -368,7 +374,7 @@
"holdCancelledTitle": "Операция отменена",
"holdCancelledBody": "Замороженная операция от {{date}} на {{amount}} будет помечена отменённой и не попадёт в операции.",
"holdReason": "Причина решения",
"holdReasonHint": "Обязательно; попадёт в журнал аудита",
"holdReasonHint": "Необязательно, можно оставить пустым",
"holdResolution.confirmed_as_posted": "Проведена",
"holdResolution.discarded_as_cancelled": "Отменена",
"warnings": "Предупреждения ({{count}})",
@@ -389,6 +395,7 @@
"rowsTitle": "Строки выписки",
"noRows": "Строк нет.",
"rowWarnings": "Замечания",
"mcc": "МСС",
"rowWarnings.amount": "сумма",
"rowWarnings.date": "дата",
"rowWarnings.direction": "направление",
@@ -405,7 +412,6 @@
"confirmTitle": "Применить импорт",
"confirmBody": "Будут созданы операции по выбранным строкам. Применение атомарно, его можно отменить.",
"confirmWithout": "Подтверждаю применение без полной сверки остатков",
"confirmReason": "Причина",
"reparse": "Переразобрать",
"revert": "Отменить применение",
"revertTitle": "Отменить применение импорта",
@@ -465,6 +471,7 @@
"savingsConversionReverted": "Перевод возвращён в расходы",
"categoryCreated": "Категория «{{name}}» создана",
"categoryRenamed": "Категория переименована в «{{name}}»",
"categoryMccUpdated": "Коды МСС категории обновлены",
"categoryArchived": "Категория отправлена в архив",
"categoryRestored": "Категория восстановлена",
"planUpdated": "План обновлён",
@@ -493,12 +500,27 @@
"categories": {
"uncategorized": "Без категории",
"groceries": "Продукты",
"cafes": "Кафе и рестораны",
"transport": "Транспорт",
"fuel": "Топливо и АЗС",
"housing": "Жильё",
"utilities": "Коммунальные",
"health": "Здоровье",
"electronics": "Электроника",
"clothing": "Одежда и обувь",
"beauty": "Красота и уход",
"education": "Образование",
"entertainment": "Развлечения",
"sport": "Спорт и фитнес",
"travel": "Путешествия",
"home": "Дом и ремонт",
"telecom": "Связь и интернет",
"kids": "Дети",
"pets": "Питомцы",
"gifts": "Подарки и хобби",
"auto": "Авто",
"finance": "Финансы и страхование",
"others": "Остальное",
"eating_out": "Кафе и рестораны",
"clothes": "Одежда",
"personal": "Личное",
+15 -32
View File
@@ -1,14 +1,11 @@
import { useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useParams } from 'react-router-dom'
import { z } from 'zod'
import type { HoldResolutionAction, ImportRow, RowDecision } from '@/api/types'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { TextareaField, TextField } from '@/components/ui/fields'
import { TextareaField } from '@/components/ui/fields'
import { Modal } from '@/components/ui/Modal'
import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate'
@@ -56,25 +53,15 @@ function HoldResolveModal({
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const resolve = useResolveHold(importId)
const schema = z.object({
reason: z.string().trim().min(1, t('common.requiredField')),
})
type Values = z.infer<typeof schema>
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: { reason: '' },
})
const [reason, setReason] = useState('')
const submit = handleSubmit((values) => {
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
resolve.mutate(
{ rowId: row.id, payload: { action, reason: values.reason } },
{ rowId: row.id, payload: { action, reason: reason.trim() || undefined } },
{ onSuccess: onClose },
)
})
}
return (
<Modal
@@ -101,8 +88,8 @@ function HoldResolveModal({
<TextareaField
label={t('imports.holdReason')}
hint={t('imports.holdReasonHint')}
error={errors.reason?.message}
{...register('reason')}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
<Button type="submit" variant="primary" disabled={resolve.isPending}>
{resolve.isPending ? t('common.saving') : t('common.confirm')}
@@ -124,7 +111,6 @@ function ConfirmModal({
const { t } = useTranslation()
const confirm = useConfirmImport(importId)
const [acknowledged, setAcknowledged] = useState(false)
const [reason, setReason] = useState('')
const disabled = needsConfirmation && !acknowledged
@@ -142,20 +128,14 @@ function ConfirmModal({
/>
{t('imports.confirmWithout')}
</label>
<TextField
label={t('imports.confirmReason')}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</>
) : null}
<Button
disabled={disabled || confirm.isPending}
onClick={() =>
confirm.mutate(
needsConfirmation ? { confirmedWithoutFullReconciliation: true, reason: reason || undefined } : {},
{ onSuccess: onClose },
)
confirm.mutate(needsConfirmation ? { confirmedWithoutFullReconciliation: true } : {}, {
onSuccess: onClose,
})
}
>
{t('imports.confirm')}
@@ -334,6 +314,9 @@ export function ImportDetailPage() {
<td data-label={t('operations.description')} className="statement-description">
{row.description ?? '—'}
{row.payeeHint ? <span className="muted"> · {row.payeeHint}</span> : null}
{row.mcc !== null ? (
<span className="muted"> · {t('imports.mcc')} {row.mcc}{row.mccName ? ` · ${row.mccName}` : ''}</span>
) : null}
</td>
<td
data-label={t('common.amount')}
@@ -70,10 +70,21 @@ const diningDefinition: CategoryDefinition = {
systemKey: null,
linkedAccountId: null,
archived: false,
mccCodes: [5999, 5994],
}
const definitions: CategoryDefinition[] = [
diningDefinition,
{
id: 'def-groceries',
name: 'Продукты и супермаркеты',
nameKey: 'groceries',
kind: 'monthly',
systemKey: 'groceries',
linkedAccountId: null,
archived: false,
mccCodes: [5411, 5422],
},
{
id: 'def-savings',
name: 'Накопления',
@@ -82,6 +93,7 @@ const definitions: CategoryDefinition[] = [
systemKey: 'savings',
linkedAccountId: 'a-sys',
archived: false,
mccCodes: [],
},
{
id: 'def-old-gym',
@@ -91,6 +103,7 @@ const definitions: CategoryDefinition[] = [
systemKey: null,
linkedAccountId: null,
archived: true,
mccCodes: [],
},
]
@@ -129,6 +142,75 @@ describe('BudgetPage category manager lifecycle', () => {
expect(await screen.findByText('Category “Dining” created')).toBeInTheDocument()
})
it('creates a category with multiple MCC codes', async () => {
createCategoryMock.mockResolvedValue(definition({ id: 'def-new', mccCodes: [5999, 5994] }))
const { dialog } = await openManager()
await userEvent.type(within(dialog).getByLabelText('Name'), 'Shops')
await userEvent.type(within(dialog).getByLabelText('MCC codes'), '5999, 5994')
await userEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
expect(createCategoryMock).toHaveBeenCalledWith({
name: 'Shops',
kind: 'monthly',
mccCodes: [5999, 5994],
})
})
it('does not offer MCC mapping for a savings envelope', async () => {
createCategoryMock.mockResolvedValue(definition({ kind: 'savings_envelope' }))
const { dialog } = await openManager()
await userEvent.type(within(dialog).getByLabelText('Name'), 'Trip')
await userEvent.type(within(dialog).getByLabelText('MCC codes'), '5999')
await userEvent.selectOptions(within(dialog).getByRole('combobox', { name: 'Type' }), 'savings_envelope')
expect(within(dialog).queryByLabelText('MCC codes')).not.toBeInTheDocument()
await userEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
expect(createCategoryMock).toHaveBeenCalledWith({ name: 'Trip', kind: 'savings_envelope' })
})
it('lets users release an MCC code from a pre-created category', async () => {
updateDefinitionMock.mockResolvedValue(definitions[1]!)
const { screen, dialog } = await openManager()
const groceriesRow = (await within(dialog).findByText('Groceries')).closest('li') as HTMLElement
expect(within(groceriesRow).queryByRole('button', { name: 'Rename' })).not.toBeInTheDocument()
await userEvent.click(within(groceriesRow).getByRole('button', { name: 'Edit MCC' }))
const editor = await screen.findByRole('dialog', { name: 'Category MCC codes' })
const input = within(editor).getByLabelText('MCC codes')
await userEvent.clear(input)
await userEvent.type(input, '5422')
await userEvent.click(within(editor).getByRole('button', { name: 'Save' }))
expect(updateDefinitionMock).toHaveBeenCalledWith('def-groceries', { mccCodes: [5422] })
})
it('shows and updates multiple MCC codes on a category', async () => {
updateDefinitionMock.mockResolvedValue(definition({ mccCodes: [5999, 5994, 5992] }))
const { screen, dialog } = await openManager()
const diningRow = (await within(dialog).findByText('Dining')).closest('li') as HTMLElement
await userEvent.click(within(diningRow).getByText('MCC codes (2)'))
expect(within(diningRow).getByText('5999, 5994')).toBeInTheDocument()
await userEvent.click(within(diningRow).getByRole('button', { name: 'Edit MCC' }))
const editor = await screen.findByRole('dialog', { name: 'Category MCC codes' })
const input = within(editor).getByLabelText('MCC codes')
await userEvent.clear(input)
await userEvent.type(input, '5999, 5994, 5992')
await userEvent.click(within(editor).getByRole('button', { name: 'Save' }))
expect(updateDefinitionMock).toHaveBeenCalledWith('def-dining', {
mccCodes: [5999, 5994, 5992],
})
})
it('accepts a three-digit MCC code', async () => {
updateDefinitionMock.mockResolvedValue(definition({ mccCodes: [742] }))
const { screen, dialog } = await openManager()
const diningRow = (await within(dialog).findByText('Dining')).closest('li') as HTMLElement
await userEvent.click(within(diningRow).getByRole('button', { name: 'Edit MCC' }))
const editor = await screen.findByRole('dialog', { name: 'Category MCC codes' })
const input = within(editor).getByLabelText('MCC codes')
await userEvent.clear(input)
await userEvent.type(input, '742')
await userEvent.click(within(editor).getByRole('button', { name: 'Save' }))
expect(updateDefinitionMock).toHaveBeenCalledWith('def-dining', { mccCodes: [742] })
})
it('renames and archives a user category through inline actions', async () => {
updateDefinitionMock.mockResolvedValue(definition({ name: 'Eating out' }))
const { screen, dialog } = await openManager()
+134
View File
@@ -0,0 +1,134 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import userEvent from '@testing-library/user-event'
import { within } from '@testing-library/react'
import { api } from '@/api/endpoints'
import type { ImportDetail, ImportRow } from '@/api/types'
import { ImportDetailPage } from '@/pages/ImportDetailPage'
import { renderWithProviders } from './helpers'
vi.mock('@/api/endpoints', () => ({
api: {
imports: {
list: vi.fn(),
get: vi.fn(),
upload: vi.fn(),
saveRowDecision: vi.fn(),
resolveHold: vi.fn(),
confirm: vi.fn(),
reparse: vi.fn(),
revert: vi.fn(),
},
},
}))
const getImportMock = vi.mocked(api.imports.get)
const confirmMock = vi.mocked(api.imports.confirm)
function postedRow(): ImportRow {
return {
id: 'row-1',
externalId: 'ext-1',
occurredOn: '2026-09-10',
amountKopecks: 150000,
direction: 'out',
bankStatus: 'posted',
description: 'Coffee beans',
mcc: null,
mccName: null,
payeeHint: null,
warnings: [],
decision: 'create',
duplicateOfOperationId: null,
holdResolution: null,
}
}
function detailFixture(reconciliationStatus: 'matched' | 'mismatched'): ImportDetail {
const rows = [postedRow()]
return {
id: 'imp-1',
fileName: 'alfa.xlsx',
bankAdapter: 'alfa_debit_xlsx',
status: 'ready',
accountId: 'a1',
accountName: 'Alfa Card',
periodFrom: null,
periodTo: null,
unresolvedHoldCount: 0,
createdAt: '2026-09-01T10:00:00Z',
failureReason: null,
blockers: [],
warnings: [],
reconciliation: {
status: reconciliationStatus,
openingBalanceKopecks: null,
closingBalanceKopecks: null,
computedClosingKopecks: null,
reason: null,
},
rows,
totals: {
total: rows.length,
create: rows.length,
discard: 0,
duplicate: 0,
transfer: 0,
savingsTransfer: 0,
pending: 0,
},
}
}
describe('ImportDetailPage apply import', () => {
beforeEach(() => {
getImportMock.mockResolvedValue(detailFixture('matched'))
})
afterEach(() => {
vi.clearAllMocks()
})
it('applies the import without a reason field', async () => {
confirmMock.mockResolvedValue(detailFixture('matched'))
const screen = renderWithProviders(<ImportDetailPage />)
await screen.findByRole('button', { name: 'Apply import' })
await userEvent.click(screen.getByRole('button', { name: 'Apply import' }))
const dialog = await screen.findByRole('dialog', { name: 'Apply import' })
expect(within(dialog).queryAllByRole('textbox')).toHaveLength(0)
await userEvent.click(within(dialog).getByRole('button', { name: 'Apply import' }))
expect(confirmMock).toHaveBeenCalledTimes(1)
expect(confirmMock).toHaveBeenCalledWith('imp-1', {})
})
it('applies without reconciliation and never sends a reason', async () => {
getImportMock.mockResolvedValue(detailFixture('mismatched'))
confirmMock.mockResolvedValue(detailFixture('mismatched'))
const screen = renderWithProviders(<ImportDetailPage />)
await screen.findByRole('button', { name: 'Apply import' })
await userEvent.click(screen.getByRole('button', { name: 'Apply import' }))
const dialog = await screen.findByRole('dialog', { name: 'Apply import' })
expect(within(dialog).queryAllByRole('textbox')).toHaveLength(0)
const submit = within(dialog).getByRole('button', { name: 'Apply import' })
expect(submit).toBeDisabled()
await userEvent.click(
within(dialog).getByRole('checkbox', {
name: 'I confirm applying without full balance reconciliation',
}),
)
expect(submit).toBeEnabled()
await userEvent.click(submit)
expect(confirmMock).toHaveBeenCalledTimes(1)
expect(confirmMock).toHaveBeenCalledWith('imp-1', {
confirmedWithoutFullReconciliation: true,
})
})
})
@@ -33,6 +33,8 @@ function row(overrides: Partial<ImportRow>): ImportRow {
direction: 'out',
bankStatus: 'posted',
description: 'Coffee beans',
mcc: null,
mccName: null,
payeeHint: null,
warnings: [],
decision: 'create',
@@ -124,7 +126,15 @@ describe('ImportDetailPage HOLD resolution', () => {
expect(screen.getByRole('button', { name: 'Operation cancelled' })).toBeInTheDocument()
})
it('requires a reason and resolves a hold as posted', async () => {
it('labels imported MCC codes even without a dedicated category', async () => {
getImportMock.mockResolvedValue(
detailFixture([row({ mcc: 7995, mccName: 'Лотереи и азартные игры' })]),
)
const screen = renderWithProviders(<ImportDetailPage />)
expect(await screen.findByText(/MCC 7995 · Лотереи и азартные игры/)).toBeInTheDocument()
})
it('resolves a hold as posted without requiring a reason', async () => {
resolveHoldMock.mockResolvedValue(detailFixture([row({}), resolvedHold]))
const screen = renderWithProviders(<ImportDetailPage />)
await screen.findByRole('button', { name: 'Operation posted' })
@@ -132,17 +142,30 @@ describe('ImportDetailPage HOLD resolution', () => {
await userEvent.click(screen.getByRole('button', { name: 'Operation posted' }))
const dialog = await screen.findByRole('dialog', { name: 'Operation posted' })
await userEvent.click(within(dialog).getByRole('button', { name: 'Confirm' }))
expect(await screen.findByText('Required field')).toBeInTheDocument()
expect(resolveHoldMock).not.toHaveBeenCalled()
await userEvent.type(screen.getByLabelText('Resolution reason'), 'Charge cleared at the bank')
await userEvent.click(within(dialog).getByRole('button', { name: 'Confirm' }))
expect(resolveHoldMock).toHaveBeenCalledTimes(1)
expect(resolveHoldMock).toHaveBeenCalledWith('imp-1', 'row-hold', {
action: 'confirm_as_posted',
reason: 'Charge cleared at the bank',
reason: undefined,
})
})
it('sends the optional reason when provided', async () => {
resolveHoldMock.mockResolvedValue(detailFixture([row({}), resolvedHold]))
const screen = renderWithProviders(<ImportDetailPage />)
await screen.findByRole('button', { name: 'Operation cancelled' })
await userEvent.click(screen.getByRole('button', { name: 'Operation cancelled' }))
const dialog = await screen.findByRole('dialog', { name: 'Operation cancelled' })
await userEvent.type(screen.getByLabelText('Resolution reason'), 'Duplicate charge')
await userEvent.click(within(dialog).getByRole('button', { name: 'Confirm' }))
expect(resolveHoldMock).toHaveBeenCalledTimes(1)
expect(resolveHoldMock).toHaveBeenCalledWith('imp-1', 'row-hold', {
action: 'discard_as_cancelled',
reason: 'Duplicate charge',
})
})
+21 -17
View File
@@ -1,9 +1,9 @@
{
"0": "identity/service.py",
"0": "categorization/service.py",
"1": "budgeting/router.py",
"2": "Base",
"3": "exports/service.py",
"4": "imports/service.py",
"4": "imports/router.py",
"5": "alfa_xlsx.py",
"6": "system-account-picker.test.tsx",
"7": "types.ts",
@@ -14,14 +14,14 @@
"12": "BudgetPage.tsx",
"13": "@testing-library/user-event",
"14": "devDependencies",
"15": "Session",
"15": "AppError",
"16": "dependencies",
"17": "ImportDetailPage.tsx",
"18": "OperationsPage.tsx",
"19": "useAccounts.ts",
"20": "index.ts",
"19": "useOperations.ts",
"20": "apiErrors.ts",
"21": "test_adapter_contracts.py",
"22": "AccountsPage.tsx",
"22": "useBudget.ts",
"23": "client.ts",
"24": "fields.tsx",
"25": "Docker Compose Development Stack",
@@ -43,8 +43,8 @@
"41": "test_api.py",
"42": "i18n-parity.test.ts",
"43": "Expected Income",
"44": "helpers.tsx",
"45": "20260922_0003_savings_system.py",
"44": "api",
"45": "set_space_context",
"46": "category-manager.test.tsx",
"47": "graphify",
"48": "@types/react-dom",
@@ -57,31 +57,35 @@
"55": "spaces/__init__.py",
"56": "Payee",
"57": "eslint.config.js",
"58": "spaces/router.py",
"58": "test_private_fixtures.py",
"59": "vite-env.d.ts",
"60": "vite.config.ts",
"61": "finance-backend",
"62": "LocalDirectoryBlobStore",
"63": "TestOzonDebitContract",
"63": "OzonDebitPdfAdapter",
"64": "Gitea CI and real statement imports",
"65": "20260918_0002_hold_lifecycle.py",
"66": "eslint-plugin-react-hooks",
"67": "main.py",
"68": "current_session",
"68": "analytics/router.py",
"69": "TestAlfaDebitContract",
"70": "vite",
"71": "rocketbank_pdf.py",
"71": "mcc.py",
"72": "TestAlfaSavingsRule",
"73": "savings-convert-revert.test.tsx",
"74": "api",
"74": "exports/router.py",
"75": "ColumnChart.tsx",
"76": "get_settings",
"76": "identity/service.py",
"77": "AGENTS.md",
"78": "graphify.js",
"79": "dependencies.py",
"79": "Category",
"80": "test_category_definitions.py",
"81": "budget-plan.test.tsx",
"82": "import-hold-resolution.test.tsx",
"83": "test_postgres_and_s3.py",
"86": "test_import_holds.py"
"83": "get_settings",
"84": "session_scope",
"85": "20260923_0004_mcc_categories.py",
"86": "Session",
"87": "app_error_handler",
"88": "20260923_0005_optional_hold_reason.py"
}
+1 -1
View File
@@ -1 +1 @@
{"0": "c347d287a7f3a6ce", "1": "5b2b5135d389ebc4", "2": "3ff2ac44aa20ca83", "3": "51897f64f56621c8", "4": "2a70b0292e8ae169", "5": "8759229a1634c950", "6": "c854d88fbb83cb56", "7": "68e73285c0067f67", "8": "35d07c09aa48f345", "9": "09b32adce261b645", "10": "0a5a0ba97778625e", "11": "f039d542469fefc7", "12": "c745c877b713a2ce", "13": "8744084aeb65fbd3", "14": "b3e752fefc98c002", "15": "672078e1316fbdfd", "16": "8ef6cf9118db3195", "17": "717661b235a2b452", "18": "ba4f5f0de2481418", "19": "7cf5574993a70b4d", "20": "f544a0eb8929475b", "21": "0ae43a136d1c49b8", "22": "91216f82cff5baf2", "23": "f3004ec80ca9f448", "24": "0e1e4d3fb2a7b5c6", "25": "f01c92b243645b4e", "26": "54c3380f579b6729", "27": "d8d767c824110809", "28": "8bc2e2fc5c336465", "29": "3147966ac3ee3b0f", "30": "2918b65a60e97b85", "31": "099ee7edfccfb30f", "32": "0778a13a682f0cbb", "33": "6be20de557b963e9", "34": "da7b9f31790187c3", "35": "17bba31989deabe4", "36": "267c43d4a566a343", "37": "cff206ec60c41144", "38": "40e8d8816af26a7d", "39": "0e87c765d5713ad6", "40": "ce680f01cc87ac2b", "41": "24380e4087fb70d5", "42": "c58209407d4237e8", "43": "54905c779f6306ec", "44": "ffaa580c9703d37b", "45": "0e25b613a054fa87", "46": "28cda6a600d51f90", "47": "2f8c667574e48967", "48": "08332675f638af21", "49": "4ed4ad08eaab7c1c", "50": "39e65f28c37e0e46", "51": "ba6724f5637cd5d9", "52": "fdf8b4d4bf2be849", "53": "66f8ffc9c3fbedf2", "54": "73723cec09ed2417", "55": "f9e8dac18e2e7bdf", "56": "86619fe91fc50452", "57": "8a3d1d863ddb4d2f", "58": "ade19319347f3d3e", "59": "99c0fcbdc4f53a0e", "60": "cd42295e6b9fcac1", "61": "3712295d0a11267c", "62": "f787d08450bb4ac6", "63": "c70141073bf2394a", "64": "fa2cf116b4b9871b", "65": "f3a0edb87b006f25", "66": "a0aa708a9e56bca1", "67": "37f0b6f332e5305b", "68": "46974fbae1bedb4d", "69": "2b697108a93af879", "70": "e049e07b5afc08cf", "71": "dfd55e9d6c47182b", "72": "2b92b704e5454347", "73": "e404eb8a5c1d93eb", "74": "b422dafdddc8c8e8", "75": "c11d4d615ce6b927", "76": "f82d1ba2ab5e9425", "77": "97ec841e4f367258", "78": "c9051bc3ad4170f1", "79": "05b0f20d97232a3d", "80": "214c5c2b62d5e491", "81": "30a50b463d66a82d", "82": "eb03ce59893de6ca", "83": "9cb55d4b9082fb1b", "86": "4aee66a8f780c711"}
{"0": "d402ed6bf2f9749a", "1": "13e542d5134fb02d", "2": "030a82389096fac1", "3": "62bd548fccb6736a", "4": "a3eec3e115dd18ab", "5": "85613707ec86a10d", "6": "c854d88fbb83cb56", "7": "ce35a9a478fc8cd2", "8": "8c36b1398810a6b1", "9": "c038f0a3bde9e10a", "10": "0a5a0ba97778625e", "11": "f039d542469fefc7", "12": "48335a79c63e77c1", "13": "8744084aeb65fbd3", "14": "b3e752fefc98c002", "15": "ba0d3a00af9834c7", "16": "8ef6cf9118db3195", "17": "717661b235a2b452", "18": "898d886d832be33b", "19": "971937bb83db4c53", "20": "1cf634eacb517a1a", "21": "e52e5efe7922326f", "22": "9cddcb53911ff0b1", "23": "f3004ec80ca9f448", "24": "1cbed23ab21a6a4d", "25": "f01c92b243645b4e", "26": "54c3380f579b6729", "27": "d8d767c824110809", "28": "8bc2e2fc5c336465", "29": "3147966ac3ee3b0f", "30": "7552b18e805318e1", "31": "099ee7edfccfb30f", "32": "0778a13a682f0cbb", "33": "6be20de557b963e9", "34": "da7b9f31790187c3", "35": "17bba31989deabe4", "36": "267c43d4a566a343", "37": "cff206ec60c41144", "38": "6a630216fce8844c", "39": "0e87c765d5713ad6", "40": "ce680f01cc87ac2b", "41": "24380e4087fb70d5", "42": "c58209407d4237e8", "43": "54905c779f6306ec", "44": "cba77756e130a563", "45": "f6413ac1b7b13383", "46": "28cda6a600d51f90", "47": "2f8c667574e48967", "48": "08332675f638af21", "49": "4ed4ad08eaab7c1c", "50": "39e65f28c37e0e46", "51": "ba6724f5637cd5d9", "52": "fdf8b4d4bf2be849", "53": "66f8ffc9c3fbedf2", "54": "73723cec09ed2417", "55": "f9e8dac18e2e7bdf", "56": "86619fe91fc50452", "57": "8a3d1d863ddb4d2f", "58": "013077c0798a954e", "59": "99c0fcbdc4f53a0e", "60": "cd42295e6b9fcac1", "61": "3712295d0a11267c", "62": "f787d08450bb4ac6", "63": "cd4362e280c077a7", "64": "fa2cf116b4b9871b", "65": "f3a0edb87b006f25", "66": "a0aa708a9e56bca1", "67": "b52ce9a5c39fc6c4", "68": "87907dcc6f25ea7b", "69": "0d74f7f6d933b4d1", "70": "e049e07b5afc08cf", "71": "b53d10722757480c", "72": "2b92b704e5454347", "73": "e404eb8a5c1d93eb", "74": "c90eb017c5213760", "75": "c11d4d615ce6b927", "76": "8205382b8b12a74a", "77": "97ec841e4f367258", "78": "c9051bc3ad4170f1", "79": "90c7a4d2aa73a27a", "80": "f091cbed551517ed", "81": "30a50b463d66a82d", "82": "d58d1cd140a5fa40", "83": "60235533bca19499", "84": "da95b6d914b33dab", "85": "18de98a5c966fdb2", "86": "0f2744bce6a3e1f1", "87": "511e2b5f5a529368", "88": "f6b2e16972be0df8"}
+139 -115
View File
@@ -1,25 +1,25 @@
# Graph Report - finance (2026-09-23)
## Corpus Check
- 178 files · ~83,334 words
- 182 files · ~87,581 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 1567 nodes · 4903 edges · 85 communities (62 shown, 17 thin omitted)
- 1620 nodes · 5066 edges · 89 communities (67 shown, 16 thin omitted)
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 91 edges (avg confidence: 0.93)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `8c2b89c8`
- Built from commit: `db4dd757`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
## Community Hubs (Navigation)
- identity/service.py
- categorization/service.py
- budgeting/router.py
- Base
- exports/service.py
- imports/service.py
- imports/router.py
- alfa_xlsx.py
- system-account-picker.test.tsx
- types.ts
@@ -30,14 +30,14 @@
- BudgetPage.tsx
- @testing-library/user-event
- devDependencies
- Session
- AppError
- dependencies
- ImportDetailPage.tsx
- OperationsPage.tsx
- useAccounts.ts
- index.ts
- useOperations.ts
- apiErrors.ts
- test_adapter_contracts.py
- AccountsPage.tsx
- useBudget.ts
- client.ts
- fields.tsx
- Docker Compose Development Stack
@@ -59,8 +59,8 @@
- test_api.py
- i18n-parity.test.ts
- Expected Income
- helpers.tsx
- 20260922_0003_savings_system.py
- api
- set_space_context
- category-manager.test.tsx
- graphify
- @types/react-dom
@@ -70,42 +70,46 @@
- Favicon SVG (ledger lines icon)
- init-app-role.sh
- Payee
- spaces/router.py
- test_private_fixtures.py
- finance-backend
- LocalDirectoryBlobStore
- TestOzonDebitContract
- OzonDebitPdfAdapter
- Gitea CI and real statement imports
- 20260918_0002_hold_lifecycle.py
- eslint-plugin-react-hooks
- main.py
- current_session
- analytics/router.py
- TestAlfaDebitContract
- vite
- rocketbank_pdf.py
- mcc.py
- TestAlfaSavingsRule
- savings-convert-revert.test.tsx
- api
- exports/router.py
- ColumnChart.tsx
- get_settings
- identity/service.py
- AGENTS.md
- dependencies.py
- Category
- test_category_definitions.py
- budget-plan.test.tsx
- import-hold-resolution.test.tsx
- test_postgres_and_s3.py
- test_import_holds.py
- get_settings
- session_scope
- 20260923_0004_mcc_categories.py
- Session
- app_error_handler
- 20260923_0005_optional_hold_reason.py
## God Nodes (most connected - your core abstractions)
1. `Session` - 198 edges
2. `AppError` - 73 edges
3. `Base` - 47 edges
4. `conflict()` - 42 edges
5. `get_settings()` - 39 edges
6. `PkMixin` - 39 edges
1. `Session` - 209 edges
2. `AppError` - 72 edges
3. `Base` - 48 edges
4. `conflict()` - 43 edges
5. `get_settings()` - 40 edges
6. `PkMixin` - 40 edges
7. `record()` - 38 edges
8. `TimestampMixin` - 38 edges
9. `StatementDocument` - 36 edges
10. `create_expense()` - 35 edges
10. `create_expense()` - 36 edges
## Surprising Connections (you probably didn't know these)
- `Typed Same-Origin API Client` --conceptually_related_to--> `Same-Origin Reverse Proxy` [INFERRED]
@@ -127,31 +131,31 @@
- **Financial Space Tenancy Boundary** — context_financial_space, context_membership, docs_adr_0001_financial_space_ownership_and_rls_defense_in_depth_authorization, docs_adr_0001_financial_space_ownership_and_rls_transaction_local_rls, docs_adr_0001_financial_space_ownership_and_rls_cross_space_integrity [EXTRACTED 1.00]
- **Safe Statement Import Flow** — context_statement_import, context_parse_run, context_source_record, docs_adr_0003_versioned_statement_imports_immutable_statement_document, docs_adr_0003_versioned_statement_imports_conservative_deduplication, docs_adr_0003_versioned_statement_imports_atomic_confirm_revert [EXTRACTED 1.00]
## Communities (85 total, 17 thin omitted)
## Communities (89 total, 16 thin omitted)
### Community 0 - "identity/service.py"
Cohesion: 0.28
Nodes (18): unauthorized(), accept_invitation(), authenticate(), create_invitation(), create_password_reset(), create_session(), get_active_session(), normalize_email() (+10 more)
### Community 0 - "categorization/service.py"
Cohesion: 0.14
Nodes (24): CategorizationRule, CategoryMcc, IncomeSource, MccCode, Payee, PayeeAlias, Space-scoped MCC codes of a category: many codes per category, but every code…, Confirmed payee rule: auto-applies category to matching future operations. (+16 more)
### Community 1 - "budgeting/router.py"
Cohesion: 0.13
Nodes (48): balances(), cash_flow(), category_spend(), plan_fact(), Context, date, Db, get (+40 more)
Cohesion: 0.17
Nodes (41): AssignmentItem, assignments(), AssignmentsBody, categories(), category_definition_json(), category_definitions(), CategoryBody, CategoryDefinitionPatchBody (+33 more)
### Community 2 - "Base"
Cohesion: 0.07
Nodes (73): downgrade(), upgrade(), AuditEvent, Append-only domain/security event. Never stores raw financial payloads., ExpectedIncome, ExpectedIncomeMatch, FundingMovement, PeriodClosure (+65 more)
Cohesion: 0.13
Nodes (33): downgrade(), upgrade(), AuditEvent, Append-only domain/security event. Never stores raw financial payloads., ExpectedIncome, ExpectedIncomeMatch, FundingMovement, PeriodClosure (+25 more)
### Community 3 - "exports/service.py"
Cohesion: 0.10
Nodes (35): Any, Transactional scope for workers/endpoints that own the transaction lifecycle., session_scope(), download(), export_status(), Context, Csrf, Db (+27 more)
Cohesion: 0.19
Nodes (17): build_export(), cancel_deletion(), consume_export(), _csv_bytes(), get_export(), _json_rows(), datetime, UUID (+9 more)
### Community 4 - "imports/service.py"
Cohesion: 0.06
Nodes (85): datetime, utcnow(), magic_kind(), StatementDocument, confirm(), ConfirmBody, decide_row(), DecisionBody (+77 more)
### Community 4 - "imports/router.py"
Cohesion: 0.15
Nodes (35): confirm(), ConfirmBody, decide_row(), DecisionBody, delete_source(), detail(), HoldResolveBody, imports() (+27 more)
### Community 5 - "alfa_xlsx.py"
Cohesion: 0.15
Nodes (27): AlfaCreditXlsxAdapter, AlfaDebitXlsxAdapter, _AlfaHeaderMeta, AlfaXlsxAdapterBase, _cell_str(), _debit_find_header(), _debit_header_metadata(), _debit_header_value() (+19 more)
Cohesion: 0.17
Nodes (24): AlfaDebitXlsxAdapter, _AlfaHeaderMeta, AlfaXlsxAdapterBase, _cell_str(), _debit_find_header(), _debit_header_metadata(), _debit_header_value(), _debit_norm() (+16 more)
### Community 6 - "system-account-picker.test.tsx"
Cohesion: 0.15
@@ -159,11 +163,11 @@ Nodes (12): AdjustBalanceResult, BudgetCategory, Operation, Page, categoriesMock
### Community 7 - "types.ts"
Cohesion: 0.08
Nodes (35): AccountStatus, AccountType, BalanceItem, BalancesReport, BudgetStatus, BudgetWarning, CashFlowMonth, CategoriesBlocker (+27 more)
Nodes (34): AccountStatus, AccountType, AdjustBalancePayload, BalanceItem, BalancesReport, BudgetStatus, BudgetWarning, CashFlowMonth (+26 more)
### Community 8 - "router.tsx"
Cohesion: 0.07
Nodes (49): isApiError(), AcceptInvitePayload, LoginPayload, PasswordResetPayload, App(), queryClient, AcceptInvitePage, AccountsPage (+41 more)
Nodes (53): AcceptInvitePayload, LoginPayload, PasswordResetPayload, AcceptInvitePage, AccountsPage, AnalyticsPage, BudgetPage, ImportDetailPage (+45 more)
### Community 9 - "ledger/router.py"
Cohesion: 0.16
@@ -174,16 +178,16 @@ Cohesion: 0.07
Nodes (28): compilerOptions, baseUrl, esModuleInterop, forceConsistentCasingInFileNames, isolatedModules, jsx, lib, module (+20 more)
### Community 12 - "BudgetPage.tsx"
Cohesion: 0.08
Nodes (53): NotFoundPage, CategoryManagerModal(), CreateCategoryForm(), RenameModal(), PlanFactBars(), PlanFactBarsProps, PlanFactRow, MonthSwitcher() (+45 more)
Cohesion: 0.10
Nodes (42): NotFoundPage, PlanFactBars(), PlanFactBarsProps, PlanFactRow, MonthSwitcher(), MonthSwitcherProps, PageHeader(), PageHeaderProps (+34 more)
### Community 14 - "devDependencies"
Cohesion: 0.10
Nodes (21): eslint, @eslint/js, eslint-plugin-jsx-a11y, devDependencies, eslint, @eslint/js, eslint-plugin-jsx-a11y, globals (+13 more)
### Community 15 - "Session"
Cohesion: 0.05
Nodes (165): balances(), cash_flow(), category_spend(), plan_fact(), date, UUID, Any, record() (+157 more)
### Community 15 - "AppError"
Cohesion: 0.06
Nodes (141): Any, record(), BudgetPeriod, Calendar month budget period in the space timezone (month granularity)., add_expected_income(), archive_category(), _balance_until(), _close_blockers() (+133 more)
### Community 16 - "dependencies"
Cohesion: 0.09
@@ -194,32 +198,32 @@ Cohesion: 0.16
Nodes (23): HoldResolutionAction, ImportStatus, RowDecision, useToast(), useConfirmImport(), useImport(), useImportErrorToaster(), useReparseImport() (+15 more)
### Community 18 - "OperationsPage.tsx"
Cohesion: 0.12
Nodes (43): isSystemAccount(), Locale, useAccounts(), useActiveAccounts(), useUserAccounts(), useCategorizeOperation(), useConvertToSavings(), useCreateOperation() (+35 more)
Cohesion: 0.09
Nodes (54): Account, isSystemAccount(), Locale, Badge(), BadgeTone, EmptyState(), EmptyStateProps, MoneyField() (+46 more)
### Community 19 - "useAccounts.ts"
Cohesion: 0.16
Nodes (13): invalidationRoots, queryKeys, SESSION_ROOT, sessionInvalidationRoot, SPACE_ROOT, spaceInvalidationRoot, AdjustBalancePayload, CreateAccountPayload (+5 more)
### Community 19 - "useOperations.ts"
Cohesion: 0.15
Nodes (16): invalidationRoots, queryKeys, SESSION_ROOT, sessionInvalidationRoot, SPACE_ROOT, spaceInvalidationRoot, CreateOperationPayload, OperationFilters (+8 more)
### Community 20 - "index.ts"
Cohesion: 0.16
Nodes (13): BudgetBlocker, blockerCta(), blockerText(), BudgetBlockers(), BudgetBlockersProps, Cta, LangSwitcher(), createI18n() (+5 more)
### Community 20 - "apiErrors.ts"
Cohesion: 0.14
Nodes (16): isApiError(), BudgetBlocker, App(), queryClient, AppRoutes(), blockerCta(), blockerText(), BudgetBlockers() (+8 more)
### Community 21 - "test_adapter_contracts.py"
Cohesion: 0.12
Nodes (19): account_fingerprint(), amount_to_kopecks(), _mask_digits(), money_to_kopecks(), Parse statement money strings like '-1 234,56 RUR', '+ 500.00 ₽', '12 345'.…, SourceRow, StatementDraft, _first_page_text() (+11 more)
Cohesion: 0.11
Nodes (25): AlfaCreditXlsxAdapter, account_fingerprint(), amount_to_kopecks(), _mask_digits(), money_to_kopecks(), Protocol, Parse statement money strings like '-1 234,56 RUR', '+ 500.00 ₽', '12 345'.…, SourceRow (+17 more)
### Community 22 - "AccountsPage.tsx"
Cohesion: 0.12
Nodes (23): Account, CREATE_KINDS, Badge(), BadgeTone, Button(), ButtonProps, Variant, ConfirmDialog() (+15 more)
### Community 22 - "useBudget.ts"
Cohesion: 0.22
Nodes (15): CategoryManagerModal(), CREATE_KINDS, CreateCategoryForm(), MccCodesModal(), parseMccCodes(), RenameModal(), ConfirmDialog(), ConfirmDialogProps (+7 more)
### Community 23 - "client.ts"
Cohesion: 0.26
Nodes (10): apiClient, ApiError, buildUrl(), ErrorEnvelope, parseErrorEnvelope(), QueryValue, readCsrfToken(), request() (+2 more)
### Community 24 - "fields.tsx"
Cohesion: 0.23
Nodes (10): FieldChrome, InputProps, MoneyField(), MoneyFieldProps, SelectFieldProps, SelectProps, TextareaField(), TextareaFieldProps (+2 more)
Cohesion: 0.25
Nodes (9): FieldChrome, InputProps, MoneyFieldProps, SelectFieldProps, SelectProps, TextareaField(), TextareaFieldProps, TextareaProps (+1 more)
### Community 25 - "Docker Compose Development Stack"
Cohesion: 0.29
@@ -238,8 +242,8 @@ Cohesion: 0.29
Nodes (8): Budget Period, Funding, General Pool, Monthly Category, Plan Assignment, Savings Envelope, Immutable Closure Snapshots, Plan and Funding Separation
### Community 30 - "TestBaseContract"
Cohesion: 0.12
Nodes (7): Protocol, StatementAdapter, detect_adapter(), make_draft(), make_source_row(), TestBaseContract, Workbook
Cohesion: 0.21
Nodes (3): make_draft(), make_source_row(), TestBaseContract
### Community 31 - "Per-File Envelope Encryption"
Cohesion: 0.29
@@ -285,13 +289,13 @@ Nodes (5): components, $defs, operations, paths, webhooks
Cohesion: 0.67
Nodes (3): TestClient, test_invite_login_and_authenticated_space(), test_openapi_contains_critical_paths()
### Community 44 - "helpers.tsx"
Cohesion: 0.16
Nodes (13): User, ToastApi, ToastContext, ToastItem, ToastKind, ToastProvider(), openManager(), createTestQueryClient() (+5 more)
### Community 44 - "api"
Cohesion: 0.11
Nodes (18): api, User, ToastApi, ToastContext, ToastItem, ToastKind, ToastProvider(), adjustBalanceMock (+10 more)
### Community 45 - "20260922_0003_savings_system.py"
Cohesion: 0.32
Nodes (11): _check_constraints(), downgrade(), _has_column(), _has_row(), _has_unique_constraint(), _normalized_sqltext(), Savings system resources, adjustments and reversible conversions., Swap a check constraint for a new value list, dialect-safely. Fresh databases… (+3 more)
### Community 45 - "set_space_context"
Cohesion: 0.26
Nodes (13): _check_constraints(), downgrade(), _has_column(), _has_row(), _has_unique_constraint(), _normalized_sqltext(), Savings system resources, adjustments and reversible conversions., Swap a check constraint for a new value list, dialect-safely. Fresh databases… (+5 more)
### Community 46 - "category-manager.test.tsx"
Cohesion: 0.15
@@ -305,14 +309,18 @@ Nodes (11): command, enabled, timeout, type, mcp, graphify, plugin, $schema (+3
Cohesion: 0.10
Nodes (20): 1. Доменная основа накоплений, 2. Альфа debit: «Копилка для сдачи», 3. Управление категориями, 4. Понятные и устранимые блокировки, Backend-контракт, Frontend, Ближайший край: накопления, категории и блокировки бюджета, Бюджетная семантика (+12 more)
### Community 58 - "spaces/router.py"
Cohesion: 0.17
Nodes (20): forbidden(), cancel_deletion(), cancel_deletion(), current(), deletion_status(), DeletionBody, invite_member(), MemberInviteBody (+12 more)
### Community 58 - "test_private_fixtures.py"
Cohesion: 0.12
Nodes (22): detect_adapter(), Any, Reconcile parsed rows against declared bank controls with stable diagnostics.…, _reconciliation_summary(), _check(), _fail(), _load_entries(), parsed() (+14 more)
### Community 62 - "LocalDirectoryBlobStore"
Cohesion: 0.39
Nodes (3): LocalDirectoryBlobStore, Path, S3-neutral local store; production swaps in an S3-compatible client.
### Community 63 - "OzonDebitPdfAdapter"
Cohesion: 0.16
Nodes (7): _ozon_cell(), _ozon_normalize(), OzonDebitPdfAdapter, Ozon debit statement contract, evidenced against the real document. A 'Справка…, ozon_extracted(), TestOzonDebitContract, Pattern
### Community 64 - "Gitea CI and real statement imports"
Cohesion: 0.29
Nodes (6): Confirmed decisions, Gitea CI and real statement imports, Goal and constraints, Implementation plan, Principal risks, Validation and rollout
@@ -322,80 +330,96 @@ Cohesion: 0.80
Nodes (4): downgrade(), _has_check_constraint(), _has_column(), upgrade()
### Community 67 - "main.py"
Cohesion: 0.13
Nodes (23): get_db(), get_engine(), get_session_factory(), sessionmaker, app_error_handler(), health(), healthz(), get (+15 more)
Cohesion: 0.31
Nodes (9): get_db(), get_engine(), get_session_factory(), sessionmaker, health(), healthz(), get, readiness() (+1 more)
### Community 68 - "current_session"
Cohesion: 0.20
Nodes (10): current_session(), alias, Context, Db, Header, User, require_csrf(), Cookie (+2 more)
### Community 68 - "analytics/router.py"
Cohesion: 0.24
Nodes (14): balances(), cash_flow(), category_spend(), plan_fact(), Context, date, Db, get (+6 more)
### Community 69 - "TestAlfaDebitContract"
Cohesion: 0.18
Nodes (3): alfa_workbook_bytes(), synthetic_alfa_rows(), TestAlfaDebitContract
### Community 71 - "rocketbank_pdf.py"
### Community 71 - "mcc.py"
Cohesion: 0.24
Nodes (7): _date_from(), _PendingRecord, date, Rocketbank debit statement contract, evidenced against the real document.…, RocketbankDebitPdfAdapter, open_pdf(), _quota_error()
Nodes (12): _default_categories(), DefaultCategory, english_title(), _english_titles(), iter_mcc_dictionary(), MCC catalog backed by the iso18245 library with Russian labels. The global…, Curated Russian translation, else a Cyrillic transcription of the label., Yield ``(code, russian_title)`` for every MCC known to iso18245. (+4 more)
### Community 73 - "savings-convert-revert.test.tsx"
Cohesion: 0.20
Nodes (6): budgetCategoriesMock, listAccountsMock, listOperationsMock, operations, revertMock, savingsTransferMock
### Community 74 - "api"
Cohesion: 0.22
Nodes (5): api, adjustBalanceMock, listMock, createMock, listMock
### Community 74 - "exports/router.py"
Cohesion: 0.27
Nodes (10): download(), export_status(), Context, Csrf, Db, get, post, Response (+2 more)
### Community 75 - "ColumnChart.tsx"
Cohesion: 0.24
Nodes (7): CashFlowColumn, ColumnChart(), ColumnChartProps, BarRow, HorizontalBars(), HorizontalBarsProps, rows
### Community 76 - "get_settings"
Cohesion: 0.12
Nodes (31): get_settings(), Settings, send_token_email(), accept_invite(), AcceptInviteBody, confirm_password_reset(), create_invite(), InviteBody (+23 more)
### Community 76 - "identity/service.py"
Cohesion: 0.06
Nodes (80): current_context(), current_session(), alias, Context, CurrentSession, Db, Header, User (+72 more)
### Community 79 - "dependencies.py"
Cohesion: 0.26
Nodes (14): Transaction-local RLS context. No-op on SQLite (tests); fail-closed on…, set_space_context(), current_context(), CurrentSession, RequestContext, FinancialSpace, Membership, get_membership() (+6 more)
### Community 79 - "Category"
Cohesion: 0.23
Nodes (12): Pure localization key for system categories; None for user categories., resolve_category_name_key(), Account, Category, Operation, Budget category. Kept in ledger module to avoid circular imports., Financial account. Balance is always derived: opening + confirmed postings., User financial fact. amount_kopecks is always positive; direction lives in… (+4 more)
### Community 80 - "test_category_definitions.py"
Cohesion: 0.54
Nodes (7): bootstrap(), TestClient, HTTP contract for category definitions, system protections and CSRF., test_adjust_and_savings_conversion_http_contracts(), test_category_lifecycle_create_rename_archive_restore(), test_openapi_contains_new_contracts(), test_space_has_protected_savings_pair()
Cohesion: 0.50
Nodes (8): bootstrap(), TestClient, HTTP contract for category definitions, system protections and CSRF., test_adjust_and_savings_conversion_http_contracts(), test_categories_expose_and_update_multiple_mcc_codes(), test_category_lifecycle_create_rename_archive_restore(), test_openapi_contains_new_contracts(), test_space_has_protected_savings_pair()
### Community 81 - "budget-plan.test.tsx"
Cohesion: 0.25
Nodes (7): BudgetSummary, categories, categoriesMock, month, summary, summaryMock, updatePlanMock
### Community 82 - "import-hold-resolution.test.tsx"
Cohesion: 0.25
Nodes (5): ImportRow, getImportMock, resolvedHold, resolveHoldMock, unresolvedHold
Cohesion: 0.15
Nodes (11): ImportDetail, ImportRow, ImportSummary, confirmMock, detailFixture(), getImportMock, postedRow(), getImportMock (+3 more)
### Community 83 - "test_postgres_and_s3.py"
Cohesion: 0.17
Nodes (7): BlobStore, get_blob_store(), Protocol, S3BlobStore, test_postgres_rls_requires_matching_transaction_context(), test_s3_blob_round_trip(), skipif
### Community 83 - "get_settings"
Cohesion: 0.10
Nodes (12): get_settings(), Settings, send_token_email(), BlobStore, get_blob_store(), Protocol, S3BlobStore, create_celery_app() (+4 more)
### Community 86 - "test_import_holds.py"
Cohesion: 0.13
Nodes (41): normalize_description(), date, record_fingerprint(), add_record(), _draft(), hold_document(), make_document(), date (+33 more)
### Community 84 - "session_scope"
Cohesion: 0.31
Nodes (9): Any, Transactional scope for workers/endpoints that own the transaction lifecycle., session_scope(), purge_due_spaces(), build_export_task(), enqueue_export(), purge_due_spaces_task(), task (+1 more)
### Community 85 - "20260923_0004_mcc_categories.py"
Cohesion: 0.46
Nodes (7): _create_tenant_policy(), _default_keys(), downgrade(), _has_table(), _space_ids(), upgrade(), default_category_system_keys()
### Community 86 - "Session"
Cohesion: 0.06
Nodes (124): datetime, utcnow(), Session, detect_savings_transfer(), _savings_rule_text(), normalize_description(), date, record_fingerprint() (+116 more)
### Community 87 - "app_error_handler"
Cohesion: 0.32
Nodes (8): app_error_handler(), Request, security_headers(), stale_data_handler(), exception_handler, JSONResponse, middleware, StaleDataError
### Community 88 - "20260923_0005_optional_hold_reason.py"
Cohesion: 0.83
Nodes (3): downgrade(), _reason_constraint_name(), upgrade()
## Knowledge Gaps
- **248 isolated node(s):** `$schema`, `file:///home/k1nq/repos/finance/.kilo/plugins/graphify.js`, `type`, `/home/k1nq/.local/bin/graphify-mcp`, `/home/k1nq/repos/finance/graphify-out/graph.json` (+243 more)
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 429 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
- **17 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
- **250 isolated node(s):** `$schema`, `file:///home/k1nq/repos/finance/.kilo/plugins/graphify.js`, `type`, `/home/k1nq/.local/bin/graphify-mcp`, `/home/k1nq/repos/finance/graphify-out/graph.json` (+245 more)
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 443 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
- **16 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `Session` connect `Session` to `identity/service.py`, `Base`, `exports/service.py`, `main.py`, `imports/service.py`, `20260922_0003_savings_system.py`, `dependencies.py`, `test_postgres_and_s3.py`, `test_import_holds.py`, `spaces/router.py`?**
_High betweenness centrality (0.063) - this node is a cross-community bridge._
- **Why does `AppError` connect `Session` to `identity/service.py`, `Base`, `exports/service.py`, `imports/service.py`, `alfa_xlsx.py`, `main.py`, `rocketbank_pdf.py`, `TestRocketbankDebitContract`, `get_settings`, `test_adapter_contracts.py`, `test_import_holds.py`, `spaces/router.py`, `TestOzonDebitContract`?**
_High betweenness centrality (0.050) - this node is a cross-community bridge._
- **Why does `get_settings()` connect `get_settings` to `identity/service.py`, `Base`, `exports/service.py`, `main.py`, `alfa_xlsx.py`, `imports/service.py`, `rocketbank_pdf.py`, `dependencies.py`, `test_postgres_and_s3.py`?**
_High betweenness centrality (0.018) - this node is a cross-community bridge._
- **Are the 3 inferred relationships involving `Session` (e.g. with `upgrade()` and `UTCDateTime`) actually correct?**
_`Session` has 3 INFERRED edges - model-reasoned connections that need verification._
- **Are the 40 inferred relationships involving `AppError` (e.g. with `request_export()` and `login()`) actually correct?**
_`AppError` has 40 INFERRED edges - model-reasoned connections that need verification._
- **Why does `Session` connect `Session` to `categorization/service.py`, `budgeting/router.py`, `Base`, `main.py`, `analytics/router.py`, `exports/service.py`, `identity/service.py`, `set_space_context`, `AppError`, `Category`, `session_scope`, `20260923_0004_mcc_categories.py`?**
_High betweenness centrality (0.070) - this node is a cross-community bridge._
- **Why does `AppError` connect `AppError` to `exports/service.py`, `imports/router.py`, `alfa_xlsx.py`, `main.py`, `TestRocketbankDebitContract`, `exports/router.py`, `identity/service.py`, `test_adapter_contracts.py`, `Session`, `app_error_handler`, `OzonDebitPdfAdapter`?**
_High betweenness centrality (0.043) - this node is a cross-community bridge._
- **Why does `get_settings()` connect `get_settings` to `exports/service.py`, `main.py`, `alfa_xlsx.py`, `imports/router.py`, `identity/service.py`, `Category`, `test_adapter_contracts.py`, `Session`?**
_High betweenness centrality (0.021) - this node is a cross-community bridge._
- **Are the 4 inferred relationships involving `Session` (e.g. with `upgrade()` and `upgrade()`) actually correct?**
_`Session` has 4 INFERRED edges - model-reasoned connections that need verification._
- **Are the 39 inferred relationships involving `AppError` (e.g. with `request_export()` and `login()`) actually correct?**
_`AppError` has 39 INFERRED edges - model-reasoned connections that need verification._
- **Are the 3 inferred relationships involving `Base` (e.g. with `downgrade()` and `upgrade()`) actually correct?**
_`Base` has 3 INFERRED edges - model-reasoned connections that need verification._
- **What connects `$schema`, `file:///home/k1nq/repos/finance/.kilo/plugins/graphify.js`, `type` to the rest of the system?**
_248 weakly-connected nodes found - possible documentation gaps or missing edges._
_250 weakly-connected nodes found - possible documentation gaps or missing edges._
File diff suppressed because one or more lines are too long
+9438 -6909
View File
File diff suppressed because it is too large Load Diff
+116 -92
View File
@@ -66,15 +66,15 @@
"semantic_hash": "039081cab3d91704e8f8a7152a981304"
},
"backend/app/budgeting/router.py": {
"mtime": 1790097111.8510067,
"seen": 1790129548.0529666,
"ast_hash": "64e5050629404acc7877b8489944f28f",
"mtime": 1790140570.7235522,
"seen": 1790140596.4645138,
"ast_hash": "5c8ea9e79dbe5450c2891c40931f0f4d",
"semantic_hash": ""
},
"backend/app/budgeting/service.py": {
"mtime": 1790129321.9786477,
"seen": 1790129548.052968,
"ast_hash": "9e7273a5b90092b3df2fc7eb89c7bde5",
"mtime": 1790139967.779716,
"seen": 1790140596.4645145,
"ast_hash": "8b936c8c80b35123143fcc6bed627172",
"semantic_hash": ""
},
"backend/app/categorization/__init__.py": {
@@ -84,15 +84,15 @@
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"backend/app/categorization/models.py": {
"mtime": 1789130236.8679283,
"seen": 1789377303.0691943,
"ast_hash": "922083067aa2a24d67954296844db8f0",
"semantic_hash": "922083067aa2a24d67954296844db8f0"
"mtime": 1790139315.3002875,
"seen": 1790140596.464517,
"ast_hash": "6f7ad97ad5760e889ff3bf6c2e23a9e3",
"semantic_hash": ""
},
"backend/app/categorization/service.py": {
"mtime": 1790060937.0384295,
"seen": 1790096479.536434,
"ast_hash": "175c114e437a93f2c4deb76c7bd7df2c",
"mtime": 1790141279.1839979,
"seen": 1790141340.3614173,
"ast_hash": "91b8f18d6fd833a1b234b7b2f1685a88",
"semantic_hash": ""
},
"backend/app/config.py": {
@@ -114,10 +114,10 @@
"semantic_hash": "ec981c0f87b0470519cca7727172a950"
},
"backend/app/db/models.py": {
"mtime": 1789128813.706167,
"seen": 1789377303.0691986,
"ast_hash": "338516ce68785afd5a7fe37f06e1c56f",
"semantic_hash": "338516ce68785afd5a7fe37f06e1c56f"
"mtime": 1790139321.140364,
"seen": 1790140596.4645212,
"ast_hash": "9dd2183a84d4b0346b8fec960af20eba",
"semantic_hash": ""
},
"backend/app/db/session.py": {
"mtime": 1789128374.1340652,
@@ -210,9 +210,9 @@
"semantic_hash": ""
},
"backend/app/imports/adapters/alfa_xlsx.py": {
"mtime": 1790061232.3863332,
"seen": 1790096479.5364504,
"ast_hash": "6a3d3456eceef5ad3e2a79d2c43bcdc2",
"mtime": 1790139655.0636957,
"seen": 1790140596.4645343,
"ast_hash": "e1a47b79913cbb08cdda13e8feff04a1",
"semantic_hash": ""
},
"backend/app/imports/adapters/base.py": {
@@ -252,9 +252,9 @@
"semantic_hash": "c72d7849c6f46e6c9a9136f3ae9dc92f"
},
"backend/app/imports/models.py": {
"mtime": 1789730695.7355492,
"seen": 1789735433.1581326,
"ast_hash": "b99c93656a17603691838c9e7963d9ed",
"mtime": 1790138566.972772,
"seen": 1790140596.46454,
"ast_hash": "a82e93256d8c14f5e7be840d572262aa",
"semantic_hash": ""
},
"backend/app/imports/readers.py": {
@@ -264,15 +264,15 @@
"semantic_hash": "0904afccafd9eb544b4d6846fc6384c8"
},
"backend/app/imports/router.py": {
"mtime": 1790097243.6436644,
"seen": 1790129548.052993,
"ast_hash": "5c3be5ef9ee835a376b463b59132ecef",
"mtime": 1790141910.6661994,
"seen": 1790142012.6081262,
"ast_hash": "cd3c86a3d0cbd157da465edab752836b",
"semantic_hash": ""
},
"backend/app/imports/service.py": {
"mtime": 1790097243.6436644,
"seen": 1790129548.0529938,
"ast_hash": "2d46532c0c4294ac19adfbc4696506dd",
"mtime": 1790140440.0177455,
"seen": 1790140596.4645422,
"ast_hash": "d4afb66723127361ae78ad1e793cca7a",
"semantic_hash": ""
},
"backend/app/imports/tasks.py": {
@@ -300,9 +300,9 @@
"semantic_hash": ""
},
"backend/app/ledger/service.py": {
"mtime": 1790077834.4411142,
"seen": 1790096479.536463,
"ast_hash": "9d5895e92011706e835b582aca3878c8",
"mtime": 1790139917.4417093,
"seen": 1790140596.4645474,
"ast_hash": "a9d176c786237da53b3a2e6fb6d6b4e6",
"semantic_hash": ""
},
"backend/app/main.py": {
@@ -342,9 +342,9 @@
"semantic_hash": "073fa39a355b257a832ead3830942d8f"
},
"backend/app/spaces/service.py": {
"mtime": 1790060979.7579985,
"seen": 1790096479.5364685,
"ast_hash": "214a4094630b761ea2a64de83d59317a",
"mtime": 1790139400.2393951,
"seen": 1790140596.4645526,
"ast_hash": "ae39850843111710abad98657c253f62",
"semantic_hash": ""
},
"backend/app/worker.py": {
@@ -354,9 +354,9 @@
"semantic_hash": "4788addc575411056226ee892d1e2806"
},
"backend/pyproject.toml": {
"mtime": 1789731892.783645,
"seen": 1789735433.1581438,
"ast_hash": "6ae54b23d066a4a48b7edbbe964af75f",
"mtime": 1790139585.4503105,
"seen": 1790140596.464554,
"ast_hash": "5ed77663858f2555b4989d2e295b331e",
"semantic_hash": ""
},
"backend/tests/conftest.py": {
@@ -372,15 +372,15 @@
"semantic_hash": "bfec0778131788a91a0bc5c9ec2ad8ba"
},
"backend/tests/test_budget.py": {
"mtime": 1789358107.2584474,
"seen": 1789377303.0692346,
"ast_hash": "f6e5c4d1e1d0f2b1941acf8ec9f6678b",
"semantic_hash": "f6e5c4d1e1d0f2b1941acf8ec9f6678b"
"mtime": 1790141279.1849978,
"seen": 1790141340.361456,
"ast_hash": "81578f618d0758c7f9e2f00c921f2594",
"semantic_hash": ""
},
"backend/tests/test_imports.py": {
"mtime": 1789987559.2557845,
"seen": 1789987623.1673782,
"ast_hash": "775b6bb13c49cfd933fdfe981142dc41",
"mtime": 1790140440.029933,
"seen": 1790140596.4645612,
"ast_hash": "9e568b07fa4d77eb55c36ef748fb0f9b",
"semantic_hash": ""
},
"backend/tests/test_ledger_properties.py": {
@@ -444,9 +444,9 @@
"semantic_hash": ""
},
"frontend/src/api/types.ts": {
"mtime": 1790097111.8510067,
"seen": 1790129548.0530243,
"ast_hash": "744887bcd7ef47a37a6899108a5fcbc0",
"mtime": 1790140175.05337,
"seen": 1790140596.4645727,
"ast_hash": "7a2c079c721ef1966d181492a4624686",
"semantic_hash": ""
},
"frontend/src/app/router.tsx": {
@@ -576,9 +576,9 @@
"semantic_hash": "40f9f47b108ec0b484f9564bd1d319cb"
},
"frontend/src/hooks/useBudget.ts": {
"mtime": 1790077001.481125,
"seen": 1790096479.5365117,
"ast_hash": "2ab1d3e626a5771c11c6f35b39d9055f",
"mtime": 1790140175.05337,
"seen": 1790140596.464592,
"ast_hash": "ffe50957304981998e7b92838ae15bdb",
"semantic_hash": ""
},
"frontend/src/hooks/useImports.ts": {
@@ -606,15 +606,15 @@
"semantic_hash": "243512f6e095deeaa29393b7c831777f"
},
"frontend/src/i18n/locales/en.json": {
"mtime": 1790096831.314309,
"seen": 1790129548.0530467,
"ast_hash": "2d62cdde2e726baf5e6b9627d58372ec",
"mtime": 1790141684.2632692,
"seen": 1790142012.6081812,
"ast_hash": "7fb00b4761bc40813a3c81cc9346d92e",
"semantic_hash": ""
},
"frontend/src/i18n/locales/ru.json": {
"mtime": 1790096831.3153088,
"seen": 1790129548.0530472,
"ast_hash": "aebf731fc4a592f2f20abd2d309e9a97",
"mtime": 1790141684.2632692,
"seen": 1790142012.6081817,
"ast_hash": "d82c9fcf5dec8f9d417ee4ead2e2825f",
"semantic_hash": ""
},
"frontend/src/main.tsx": {
@@ -648,9 +648,9 @@
"semantic_hash": ""
},
"frontend/src/pages/ImportDetailPage.tsx": {
"mtime": 1790077605.334677,
"seen": 1790096479.5365288,
"ast_hash": "75923869914b7ef53b939676f9d5f095",
"mtime": 1790138988.0589676,
"seen": 1790140596.4646058,
"ast_hash": "38dc8d5253cf9622ec266804a9e907ef",
"semantic_hash": ""
},
"frontend/src/pages/ImportsPage.tsx": {
@@ -792,10 +792,10 @@
"semantic_hash": "c8211b75ee8dfba83e37f06ff219d2b1"
},
"README.md": {
"mtime": 1789358273.3585796,
"seen": 1789377303.0692983,
"ast_hash": "79eb6002a05286e6655e6fe3904f5b29",
"semantic_hash": "79eb6002a05286e6655e6fe3904f5b29"
"mtime": 1790139585.4512253,
"seen": 1790140596.4646337,
"ast_hash": "a4502b2cc4571860844ff035d31e5798",
"semantic_hash": ""
},
"docs/adr/0001-financial-space-ownership-and-rls.md": {
"mtime": 1789126779.7591345,
@@ -858,10 +858,10 @@
"semantic_hash": "42788af442bf05e487937f435141f84b"
},
"infra/README.md": {
"mtime": 1789357215.8087454,
"seen": 1789377303.0693076,
"ast_hash": "0a7bb82cce04631f36f2d561a1665478",
"semantic_hash": "0a7bb82cce04631f36f2d561a1665478"
"mtime": 1790139585.4515448,
"seen": 1790140596.4646425,
"ast_hash": "b4aa0d817fb75341937b44526cb943f6",
"semantic_hash": ""
},
"infra/docker-compose.yml": {
"mtime": 1790067636.943902,
@@ -894,9 +894,9 @@
"semantic_hash": ""
},
"backend/tests/test_import_holds.py": {
"mtime": 1789736201.2172022,
"seen": 1789736272.9455423,
"ast_hash": "6f14226a83e65efbaaee6446d4ec4cb6",
"mtime": 1790139806.356644,
"seen": 1790140596.4645593,
"ast_hash": "f483eaf3c70604ee3e931958f0b9736b",
"semantic_hash": ""
},
"backend/tests/test_private_fixtures.py": {
@@ -906,9 +906,9 @@
"semantic_hash": ""
},
".gitea/workflows/ci.yml": {
"mtime": 1789736949.8123386,
"seen": 1789737916.791613,
"ast_hash": "f0891b841c5e24c761f5067a4ea23637",
"mtime": 1790139585.4503105,
"seen": 1790140596.464629,
"ast_hash": "f06ce2f92ce51151ee2edf6879038f2e",
"semantic_hash": ""
},
".kilo/plans/1789727434954-gitea-ci-real-statement-imports.md": {
@@ -960,15 +960,15 @@
"semantic_hash": ""
},
"backend/tests/test_budget_savings.py": {
"mtime": 1790077946.050938,
"seen": 1790096479.5364738,
"ast_hash": "47280e68387aee190b930ac53ac79e93",
"mtime": 1790140489.3004284,
"seen": 1790140596.464558,
"ast_hash": "dba98c2706a6cdbbf988336ac51d248a",
"semantic_hash": ""
},
"backend/tests/test_category_definitions.py": {
"mtime": 1790078212.0062637,
"seen": 1790096479.5364745,
"ast_hash": "4d16f9374862cea5f66bec35ea4ffec9",
"mtime": 1790141779.8555074,
"seen": 1790142012.6081438,
"ast_hash": "f2b6219d9e5687ddee8ecc85093447cb",
"semantic_hash": ""
},
"backend/tests/test_import_savings.py": {
@@ -990,9 +990,9 @@
"semantic_hash": ""
},
"frontend/src/components/budget/CategoryManagerModal.tsx": {
"mtime": 1790078043.7267377,
"seen": 1790096479.5364919,
"ast_hash": "72a373612048b42b68405b965daa3153",
"mtime": 1790141684.261269,
"seen": 1790142012.6081603,
"ast_hash": "3320a39025aec0d67df0a5f8a4bf6d4b",
"semantic_hash": ""
},
"frontend/src/lib/accountLabel.ts": {
@@ -1056,15 +1056,15 @@
"semantic_hash": ""
},
"frontend/src/test/category-manager.test.tsx": {
"mtime": 1790078889.2937336,
"seen": 1790096479.5365384,
"ast_hash": "e32016dfb312c6fd11e9b485b9499798",
"mtime": 1790141753.6681683,
"seen": 1790142012.6082017,
"ast_hash": "9d2f35f1471a69a6a73d1a55cf81cb2a",
"semantic_hash": ""
},
"frontend/src/test/import-hold-resolution.test.tsx": {
"mtime": 1790078821.0050712,
"seen": 1790096479.5365412,
"ast_hash": "8d46975e4f292e69d2531486578b0e1e",
"mtime": 1790138988.0599678,
"seen": 1790140596.4646187,
"ast_hash": "817410814a07e97b717b74b39cbe438d",
"semantic_hash": ""
},
"frontend/src/test/review-queue-month.test.tsx": {
@@ -1092,9 +1092,33 @@
"semantic_hash": ""
},
"backend/tests/test_savings_migration.py": {
"mtime": 1790129378.2103765,
"seen": 1790129548.053016,
"ast_hash": "9ada17014ccdef9572af77bb00f2590b",
"mtime": 1790141939.6325738,
"seen": 1790142012.6081502,
"ast_hash": "b72fd7f10323c67152182b3316bcbe76",
"semantic_hash": ""
},
"backend/alembic/versions/20260923_0004_mcc_categories.py": {
"mtime": 1790141910.6651993,
"seen": 1790142012.6080875,
"ast_hash": "7124e3107ea58f73ad95fe1a18499c0d",
"semantic_hash": ""
},
"backend/alembic/versions/20260923_0005_optional_hold_reason.py": {
"mtime": 1790139704.955339,
"seen": 1790140596.464503,
"ast_hash": "29af3b5bcb808fc59a05688ea64ca134",
"semantic_hash": ""
},
"backend/app/categorization/mcc.py": {
"mtime": 1790140416.340453,
"seen": 1790140596.4645164,
"ast_hash": "3f34254e6900441ba4f2db78df7b98f1",
"semantic_hash": ""
},
"frontend/src/test/import-confirm.test.tsx": {
"mtime": 1790138988.0599678,
"seen": 1790140596.4646177,
"ast_hash": "c69052c781b488bfc9fd94c18145b3dd",
"semantic_hash": ""
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ These entry points are referenced by the compose file and CI; coordinate changes
through both sides:
**Backend** (`backend/`, build context of `Dockerfile.backend`):
- uv-managed project with `pyproject.toml` + `uv.lock`; lint `ruff`, types `mypy`, tests `pytest`.
- uv-managed project with `pyproject.toml` + `uv.lock`; lint `ruff`, types `ty`, tests `pytest`.
- API: `uvicorn app.main:app`, health endpoint `GET /healthz`.
- Worker: Celery app importable as `app.worker.celery`; tasks idempotent, `acks_late`, `prefetch=1`.
- Statement parsing happens **only** in the worker; the local compose worker is attached only