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 . run: uv run ruff check .
- name: Format (ruff format) - name: Format (ruff format)
run: uv run ruff format --check . run: uv run ruff format --check .
- name: Type check (mypy) - name: Type check (ty)
run: uv run mypy . run: uv run ty check
- name: Create restricted app role and migrate - name: Create restricted app role and migrate
run: | 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()" 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 ```bash
uv sync --project backend --all-groups --locked uv sync --project backend --all-groups --locked
uv run --project backend ruff check backend 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 uv run --project backend pytest backend
corepack pnpm --dir frontend install --frozen-lockfile 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 = ''))",
)
+42 -4
View File
@@ -1,4 +1,5 @@
import uuid import uuid
from collections import defaultdict
from datetime import date from datetime import date
from typing import Annotated, Literal from typing import Annotated, Literal
@@ -7,6 +8,7 @@ from pydantic import BaseModel, Field
from app.budgeting import service from app.budgeting import service
from app.categorization import service as categorization from app.categorization import service as categorization
from app.categorization.models import CategoryMcc
from app.dependencies import Context, Csrf, Db from app.dependencies import Context, Csrf, Db
router = APIRouter(prefix="/budget", tags=["budget"]) router = APIRouter(prefix="/budget", tags=["budget"])
@@ -15,6 +17,7 @@ router = APIRouter(prefix="/budget", tags=["budget"])
class CategoryBody(BaseModel): class CategoryBody(BaseModel):
name: str = Field(min_length=1, max_length=200) name: str = Field(min_length=1, max_length=200)
kind: Literal["monthly", "savings_envelope"] = "monthly" kind: Literal["monthly", "savings_envelope"] = "monthly"
mcc_codes: list[int] = Field(default_factory=list, alias="mccCodes")
class AssignmentItem(BaseModel): class AssignmentItem(BaseModel):
@@ -144,7 +147,7 @@ def categories(month: str, db: Db, context: Context) -> list[dict]:
return result return result
def category_definition_json(category) -> dict: def category_definition_json(category, mcc_codes: list[int]) -> dict:
return { return {
"id": category.id, "id": category.id,
"name": categorization.resolve_category_name(category), "name": categorization.resolve_category_name(category),
@@ -153,6 +156,7 @@ def category_definition_json(category) -> dict:
"systemKey": category.system_key, "systemKey": category.system_key,
"linkedAccountId": category.linked_account_id, "linkedAccountId": category.linked_account_id,
"archived": category.archived, "archived": category.archived,
"mccCodes": mcc_codes,
} }
@@ -165,12 +169,20 @@ def category_definitions(
items = service.list_categories( items = service.list_categories(
db, space_id=context.space.id, include_archived=include_archived 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): class CategoryDefinitionPatchBody(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=200) name: str | None = Field(default=None, min_length=1, max_length=200)
archived: bool | None = None archived: bool | None = None
mcc_codes: list[int] | None = Field(default=None, alias="mccCodes")
@router.patch("/category-definitions/{category_id}") @router.patch("/category-definitions/{category_id}")
@@ -181,6 +193,8 @@ def patch_category_definition(
context: Context, context: Context,
_csrf: Csrf, _csrf: Csrf,
) -> dict: ) -> dict:
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( category = service.update_category_definition(
db, db,
space_id=context.space.id, space_id=context.space.id,
@@ -189,7 +203,19 @@ def patch_category_definition(
name=body.name, name=body.name,
archived=body.archived, archived=body.archived,
) )
return category_definition_json(category) 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
),
)
@router.post("/categories", status_code=201) @router.post("/categories", status_code=201)
@@ -201,7 +227,19 @@ def create_category(body: CategoryBody, db: Db, context: Context, _csrf: Csrf) -
kind=body.kind, kind=body.kind,
name=body.name, 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}") @router.patch("/categories/{category_id}")
+4 -2
View File
@@ -16,6 +16,7 @@ from app.budgeting.models import (
PeriodClosure, PeriodClosure,
PlanAssignment, PlanAssignment,
) )
from app.categorization import mcc
from app.categorization.service import resolve_category_name from app.categorization.service import resolve_category_name
from app.errors import bad_request, conflict, not_found from app.errors import bad_request, conflict, not_found
from app.identity.models import User 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), "category_id": str(cat.id),
"name": resolve_category_name(cat), "name": resolve_category_name(cat),
"name_key": cat.system_key, "name_key": cat.system_key,
"system": not mcc.is_budgetable_category_key(cat.system_key),
"archived": cat.archived, "archived": cat.archived,
"planned": 0, "planned": 0,
"funded": funded_period[cat.id], "funded": funded_period[cat.id],
@@ -681,7 +683,7 @@ def set_assignments(
result = [] result = []
for item in items: for item in items:
category = get_category(db, space_id=space_id, category_id=item["category_id"]) 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") raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be planned")
if category.archived: if category.archived:
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived") raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived")
@@ -800,7 +802,7 @@ def fund(
if amount_kopecks <= 0: if amount_kopecks <= 0:
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive") raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
category = get_category(db, space_id=space_id, category_id=category_id) 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") raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be funded")
if category.archived: if category.archived:
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is 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) 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): class BankCategoryMapping(Base, PkMixin):
"""Maps a bank's raw category string to an MCC for suggestion purposes.""" """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 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.errors import conflict, not_found
from app.ledger.models import Category 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 rule.category_id = category_id
db.flush() db.flush()
return rule 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 ( from app.categorization.models import (
BankCategoryMapping, BankCategoryMapping,
CategorizationRule, CategorizationRule,
CategoryMcc,
IncomeSource, IncomeSource,
MccCode, MccCode,
Payee, Payee,
@@ -35,6 +36,7 @@ __all__ = [
"BankCategoryMapping", "BankCategoryMapping",
"BudgetPeriod", "BudgetPeriod",
"Category", "Category",
"CategoryMcc",
"CategorizationRule", "CategorizationRule",
"DataExport", "DataExport",
"DedupCandidate", "DedupCandidate",
+3 -3
View File
@@ -214,9 +214,9 @@ def _find_header(rows: list[tuple]) -> tuple[int, dict[str, int]]:
("category", CATEGORY_HEADERS), ("category", CATEGORY_HEADERS),
("external", ("id операции", "номер операции")), ("external", ("id операции", "номер операции")),
): ):
columns[key] = next( # type: ignore[assignment] optional_col = next((i for i, c in enumerate(cells) if c in synonyms), None)
(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 return index, columns
raise UnsupportedStatementLayout("statement header row with date/amount columns not found") 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 The extracted HOLD row itself is never mutated. At most one resolution
exists per hold source record; automatic matches (`matched_posted`) are exists per hold source record; automatic matches (`matched_posted`) are
created only when a later statement is confirmed, and manual actions 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" __tablename__ = "hold_resolutions"
@@ -231,11 +231,6 @@ class HoldResolution(Base, PkMixin, TimestampMixin):
"resolution IN ('matched_posted', 'confirmed_as_posted', 'discarded_as_cancelled')", "resolution IN ('matched_posted', 'confirmed_as_posted', 'discarded_as_cancelled')",
name="resolution_valid", name="resolution_valid",
), ),
CheckConstraint(
"NOT (resolution IN ('confirmed_as_posted', 'discarded_as_cancelled') "
"AND (reason IS NULL OR reason = ''))",
name="manual_reason_required",
),
ForeignKeyConstraint( ForeignKeyConstraint(
["space_id", "hold_source_record_id"], ["space_id", "hold_source_record_id"],
["source_records.space_id", "source_records.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( confirmed_without_full_reconciliation: bool = Field(
default=False, alias="confirmedWithoutFullReconciliation" default=False, alias="confirmedWithoutFullReconciliation"
) )
reason: str | None = None
class HoldResolveBody(BaseModel): class HoldResolveBody(BaseModel):
model_config = ConfigDict(populate_by_name=True) model_config = ConfigDict(populate_by_name=True)
action: str action: str
reason: str reason: str | None = None
account_id: uuid.UUID | None = Field(default=None, alias="accountId") 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"], "direction": item["direction"],
"bankStatus": item.get("bank_status", "posted"), "bankStatus": item.get("bank_status", "posted"),
"description": item["description"], "description": item["description"],
"mcc": item["mcc"],
"mccName": item["mcc_name"],
"payeeHint": None, "payeeHint": None,
"warnings": (item["warnings"] or {}).get("codes", []), "warnings": (item["warnings"] or {}).get("codes", []),
"decision": "pending" "decision": "pending"
@@ -306,7 +307,7 @@ def confirm(
document_id=document_id, document_id=document_id,
account_id=document.account_id, account_id=document.account_id,
confirmed_without_full_reconciliation=True, confirmed_without_full_reconciliation=True,
reason=body.reason, reason=None,
) )
document = service.confirm_document( document = service.confirm_document(
db, space_id=context.space.id, document_id=document_id, user=context.user 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 sqlalchemy.orm import Session
from app.audit import service as audit 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.config import get_settings
from app.db.base import utcnow from app.db.base import utcnow
from app.db.session import session_scope 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]: def _reconciliation_summary(draft: Any) -> dict[str, Any]:
"""Reconcile parsed rows against declared bank controls with stable diagnostics. """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.adapter_id = adapter.adapter_id
document.account_fingerprint = draft.account_fingerprint document.account_fingerprint = draft.account_fingerprint
document.active_parse_run_id = run.id 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: if document.account_id is None and draft.account_fingerprint:
matches = ( matches = (
db.query(Account) 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() db.query(SourceRecord.id).filter_by(parse_run_id=run.id, critical=True).first()
) )
document.needs_account_mapping = document.account_id is None document.needs_account_mapping = document.account_id is None
needs_manual_reconciliation = not document.reconciliation["full"] needs_manual_reconciliation = not reconciliation["full"]
document.status = ( document.status = (
"review_required" "review_required"
if critical if critical
or document.needs_account_mapping or document.needs_account_mapping
or needs_manual_reconciliation or needs_manual_reconciliation
or has_candidates or has_candidates
or document.reconciliation.get("matched") is False or reconciliation.get("matched") is False
else "ready" else "ready"
) )
except AppError as exc: except AppError as exc:
@@ -601,6 +614,15 @@ def document_detail(db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID)
.all() .all()
) )
record_ids = [record.id for record in records] 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]] = {} resolutions: dict[uuid.UUID, dict[str, Any]] = {}
candidates: dict[uuid.UUID, list[dict[str, Any]]] = {} candidates: dict[uuid.UUID, list[dict[str, Any]]] = {}
if record_ids: if record_ids:
@@ -656,6 +678,9 @@ def document_detail(db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID)
"direction": item.direction, "direction": item.direction,
"bank_status": item.bank_status, "bank_status": item.bank_status,
"description": item.description, "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, "critical": item.critical,
"warnings": item.field_warnings, "warnings": item.field_warnings,
"review_decision": item.review_decision, "review_decision": item.review_decision,
@@ -755,7 +780,7 @@ def set_review_confirmation(
document_id: uuid.UUID, document_id: uuid.UUID,
account_id: uuid.UUID, account_id: uuid.UUID,
confirmed_without_full_reconciliation: bool, confirmed_without_full_reconciliation: bool,
reason: str | None, reason: str | None = None,
) -> StatementDocument: ) -> StatementDocument:
document = _document(db, space_id=space_id, document_id=document_id) 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() 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", "IMPORT_SYSTEM_ACCOUNT_INVALID",
"System accounts cannot be used as statement accounts", "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.account_id = account.id
document.needs_account_mapping = False document.needs_account_mapping = False
reconciliation = dict(document.reconciliation or {}) reconciliation = dict(document.reconciliation or {})
reconciliation["confirmed_without_full_reconciliation"] = confirmed_without_full_reconciliation 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 document.reconciliation = reconciliation
critical = ( critical = (
db.query(SourceRecord.id) db.query(SourceRecord.id)
@@ -801,7 +824,7 @@ def resolve_hold(
source_record_id: uuid.UUID, source_record_id: uuid.UUID,
user: User, user: User,
action: str, action: str,
reason: str | None, reason: str | None = None,
account_id: uuid.UUID | None = None, account_id: uuid.UUID | None = None,
) -> HoldResolution: ) -> HoldResolution:
"""Manually settle one HOLD: book it as a normal operation or discard it.""" """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: if action not in HOLD_MANUAL_ACTIONS:
raise bad_request("IMPORT_HOLD_ACTION_INVALID", "Unknown hold resolution action") raise bad_request("IMPORT_HOLD_ACTION_INVALID", "Unknown hold resolution action")
if reason is None or not reason.strip(): reason = reason.strip() or None if reason is not None else None
raise conflict("IMPORT_HOLD_REASON_REQUIRED", "A resolution reason is required")
source = ( source = (
db.query(SourceRecord) db.query(SourceRecord)
.filter_by( .filter_by(
@@ -842,7 +864,7 @@ def resolve_hold(
if action == "confirm_as_posted" if action == "confirm_as_posted"
else "discarded_as_cancelled", else "discarded_as_cancelled",
resolved_by_user_id=user.id, resolved_by_user_id=user.id,
reason=reason.strip(), reason=reason,
resolved_at=utcnow(), resolved_at=utcnow(),
) )
if action == "confirm_as_posted": if action == "confirm_as_posted":
@@ -874,6 +896,7 @@ def resolve_hold(
user=user, user=user,
account_id=account.id, account_id=account.id,
amount_kopecks=source.amount_kopecks, amount_kopecks=source.amount_kopecks,
category_id=_category_id_for_source(db, space_id=space_id, source=source),
occurred_on=source.occurred_on, occurred_on=source.occurred_on,
posted_on=source.posted_on, posted_on=source.posted_on,
description=source.description, description=source.description,
@@ -900,7 +923,7 @@ def resolve_hold(
detail={ detail={
"document_id": str(document.id), "document_id": str(document.id),
"source_record_id": str(source.id), "source_record_id": str(source.id),
"reason": reason.strip(), "reason": reason,
}, },
) )
return resolution return resolution
@@ -1073,9 +1096,7 @@ def confirm_document(
if source.review_decision == "savings_transfer": if source.review_decision == "savings_transfer":
from app.ledger.savings import ensure_savings_resources from app.ledger.savings import ensure_savings_resources
savings_account, _savings_category = ensure_savings_resources( savings_account, _savings_category = ensure_savings_resources(db, space_id=space_id)
db, space_id=space_id
)
if document.account_id == savings_account.id: if document.account_id == savings_account.id:
raise conflict( raise conflict(
"IMPORT_SAVINGS_SOURCE_INVALID", "IMPORT_SAVINGS_SOURCE_INVALID",
@@ -1160,6 +1181,7 @@ def confirm_document(
user=user, user=user,
account_id=document.account_id, account_id=document.account_id,
amount_kopecks=source.amount_kopecks, amount_kopecks=source.amount_kopecks,
category_id=_category_id_for_source(db, space_id=space_id, source=source),
occurred_on=source.occurred_on, occurred_on=source.occurred_on,
posted_on=source.posted_on, posted_on=source.posted_on,
description=source.description, description=source.description,
+2 -1
View File
@@ -7,6 +7,7 @@ from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.audit import service as audit from app.audit import service as audit
from app.categorization import mcc
from app.categorization import service as categorization from app.categorization import service as categorization
from app.errors import bad_request, conflict, not_found from app.errors import bad_request, conflict, not_found
from app.identity.models import User 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") raise not_found("BUDGET_CATEGORY_NOT_FOUND", "Category not found")
if category.archived: if category.archived:
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is 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( raise conflict(
"BUDGET_CATEGORY_SYSTEM", "BUDGET_CATEGORY_SYSTEM",
"System categories cannot be assigned to regular operations", "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() db.flush()
set_space_context(db, space.id) set_space_context(db, space.id)
db.add(Membership(space_id=space.id, user_id=user.id, role="owner")) 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 from app.ledger.savings import ensure_savings_resources
ensure_savings_resources(db, space_id=space.id) 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) audit.record(db, event_type="space.created", space_id=space.id, user_id=user.id)
return space return space
+5 -10
View File
@@ -21,14 +21,15 @@ dependencies = [
"pdfplumber>=0.11", "pdfplumber>=0.11",
"python-multipart>=0.0.9", "python-multipart>=0.0.9",
"boto3>=1.35", "boto3>=1.35",
"iso18245>=1.4.1",
] ]
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pytest>=8.2", "pytest>=8.2",
"httpx>=0.27", "httpx2>=2.0",
"ruff>=0.5", "ruff>=0.5",
"mypy>=1.10", "ty>=0.0.18",
"hypothesis>=6.112", "hypothesis>=6.112",
] ]
@@ -52,11 +53,5 @@ target-version = "py314"
select = ["E", "F", "I", "UP", "B"] select = ["E", "F", "I", "UP", "B"]
ignore = ["B008"] ignore = ["B008"]
[tool.mypy] [tool.ty.src]
python_version = "3.14" include = ["app"]
ignore_missing_imports = true
check_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
disable_error_code = ["import-untyped"]
exclude = ["alembic/", "tests/"]
+55
View File
@@ -13,8 +13,10 @@ from app.budgeting.service import (
reopen_period, reopen_period,
set_assignments, set_assignments,
) )
from app.categorization import service as categorization
from app.errors import AppError from app.errors import AppError
from app.identity.models import User from app.identity.models import User
from app.ledger.models import Category
from app.ledger.service import create_account, create_expense from app.ledger.service import create_account, create_expense
from app.spaces.service import create_space 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" 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: def test_copy_previous_month_clamps_expected_day(db: Session) -> None:
from app.budgeting.service import add_expected_income 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. # The adjustment reconciles the account but never enters the budget pool.
assert state["pool"] == 300_000 assert state["pool"] == 300_000
assert state["uncategorized_expenses"] == 0 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: 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 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: def test_category_lifecycle_create_rename_archive_restore(client: TestClient) -> None:
csrf = bootstrap(client) csrf = bootstrap(client)
created = client.post( created = client.post(
@@ -163,9 +210,7 @@ def test_adjust_and_savings_conversion_http_contracts(client: TestClient) -> Non
"occurredOn": "2026-09-10", "occurredOn": "2026-09-10",
"reason": "Statement reconciliation", "reason": "Statement reconciliation",
} }
denied = client.post( denied = client.post(f"/api/v1/accounts/{account_id}/adjust-balance", json=adjustment_body)
f"/api/v1/accounts/{account_id}/adjust-balance", json=adjustment_body
)
assert denied.status_code == 403 assert denied.status_code == 403
adjusted = client.post( adjusted = client.post(
f"/api/v1/accounts/{account_id}/adjust-balance", f"/api/v1/accounts/{account_id}/adjust-balance",
+66 -26
View File
@@ -1,12 +1,19 @@
import uuid import uuid
from datetime import date from datetime import date
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
import pytest 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 fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session 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.budgeting.service import close_period, compute_period_state, get_or_create_period
from app.errors import AppError from app.errors import AppError
from app.identity.models import User 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) 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( resolution = service.resolve_hold(
db, db,
space_id=space.id, 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, source_record_id=hold.id,
user=user, user=user,
action="discard_as_cancelled", action="discard_as_cancelled",
reason="Cancelled by the bank",
) )
db.flush() db.flush()
assert resolution.resolution == "discarded_as_cancelled" 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 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) 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) 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) 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) 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) 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" 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) user, space, _account, document, hold = hold_document(db)
for reason in (None, ""): resolution = service.resolve_hold(
with pytest.raises(AppError) as exc:
service.resolve_hold(
db, db,
space_id=space.id, space_id=space.id,
document_id=document.id, document_id=document.id,
source_record_id=hold.id, source_record_id=hold.id,
user=user, user=user,
action="confirm_as_posted", action="confirm_as_posted",
reason=reason, reason=" ",
) )
assert exc.value.code == "IMPORT_HOLD_REASON_REQUIRED" db.flush()
assert db.query(HoldResolution).count() == 0 assert resolution.reason is None
assert resolution.operation_id is not None
def test_hold_cannot_be_resolved_before_document_confirmation(db: Session) -> 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() 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) _user, space, _account, _document, hold = hold_document(db)
db.add( db.add(
HoldResolution( HoldResolution(
@@ -746,8 +739,8 @@ def test_manual_resolution_requires_reason_at_database_level(db: Session) -> Non
reason=None, 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() db.rollback()
_user, space, _account, _document, hold = hold_document(db) _user, space, _account, _document, hold = hold_document(db)
db.add( db.add(
@@ -761,6 +754,49 @@ def test_manual_resolution_requires_reason_at_database_level(db: Session) -> Non
db.flush() 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): def _draft(rows, *, opening=1_000, closing=1_200, **declared):
return SimpleNamespace( return SimpleNamespace(
currency="RUB", currency="RUB",
@@ -920,6 +956,7 @@ def test_resolve_endpoint_requires_csrf_and_resolves_hold(client: TestClient, db
space = SimpleNamespace(id=space_id) space = SimpleNamespace(id=space_id)
document, run = make_document(db, space, account, user=user) document, run = make_document(db, space, account, user=user)
hold = add_record(db, document, run, bank_status="hold") 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) service.confirm_document(db, space_id=space_id, document_id=document.id, user=user)
db.commit() db.commit()
@@ -932,14 +969,17 @@ def test_resolve_endpoint_requires_csrf_and_resolves_hold(client: TestClient, db
resolved = client.post( resolved = client.post(
f"/api/v1/imports/{document.id}/rows/{hold.id}/resolve", f"/api/v1/imports/{document.id}/rows/{hold.id}/resolve",
headers={"X-CSRF-Token": csrf}, 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 assert resolved.status_code == 200, resolved.text
rows = resolved.json()["rows"] rows = resolved.json()["rows"]
row = next(item for item in rows if item["id"] == str(hold.id)) row = next(item for item in rows if item["id"] == str(hold.id))
assert row["bankStatus"] == "hold" assert row["bankStatus"] == "hold"
assert row["mcc"] == 5411
assert row["mccName"] == "Продуктовые магазины и супермаркеты"
assert row["holdResolution"]["resolution"] == "confirmed_as_posted" 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 assert row["holdResolution"]["operationId"] is not None
operations = db.query(Operation).filter_by(space_id=space_id).all() operations = db.query(Operation).filter_by(space_id=space_id).all()
assert len(operations) == 1 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, SourceRecordLink,
StatementDocument, StatementDocument,
) )
from app.ledger.models import Operation from app.ledger.models import Category, Operation
from app.ledger.service import create_account from app.ledger.service import create_account
from app.spaces.service import create_space 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 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: def test_confirm_rejects_arithmetic_mismatch(db: Session) -> None:
user, space, document = ready_document(db) user, space, document = ready_document(db)
document.reconciliation = {"full": True, "matched": False} 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: def test_duplicate_candidates_ignore_rows_from_same_parse_run(db: Session) -> None:
user, space, document = ready_document(db) user, space, document = ready_document(db)
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user) service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
first_source = ( first_source = db.query(SourceRecord).filter_by(parse_run_id=document.active_parse_run_id).one()
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() existing_link = db.query(SourceRecordLink).filter_by(source_record_id=first_source.id).one()
second_source = SourceRecord( second_source = SourceRecord(
space_id=first_source.space_id, space_id=first_source.space_id,
+34 -3
View File
@@ -2,10 +2,11 @@ from datetime import date
from pathlib import Path from pathlib import Path
from alembic.config import Config from alembic.config import Config
from sqlalchemy import create_engine from sqlalchemy import create_engine, inspect
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from alembic import command from alembic import command
from app.categorization.models import CategoryMcc
from app.config import get_settings from app.config import get_settings
from app.identity.models import User from app.identity.models import User
from app.ledger.models import Account, Category 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( def test_upgrade_backfills_one_pair_without_claiming_same_named_rows(
tmp_path: Path, monkeypatch, tmp_path: Path,
monkeypatch,
) -> None: ) -> None:
database_url = f"sqlite:///{tmp_path / 'migration.db'}" database_url = f"sqlite:///{tmp_path / 'migration.db'}"
monkeypatch.setenv("DATABASE_URL", database_url) 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 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 == "Savings" and item.system_key is None for item in accounts)
assert any( 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 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: finally:
get_settings.cache_clear() 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" }, { 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]] [[package]]
name = "billiard" name = "billiard"
version = "4.2.4" version = "4.2.4"
@@ -228,15 +165,6 @@ redis = [
{ name = "kombu", extra = ["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]] [[package]]
name = "cffi" name = "cffi"
version = "2.1.1" version = "2.1.1"
@@ -508,6 +436,7 @@ dependencies = [
{ name = "defusedxml" }, { name = "defusedxml" },
{ name = "email-validator" }, { name = "email-validator" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "iso18245" },
{ name = "openpyxl" }, { name = "openpyxl" },
{ name = "pdfplumber" }, { name = "pdfplumber" },
{ name = "psycopg", extra = ["binary"] }, { name = "psycopg", extra = ["binary"] },
@@ -521,11 +450,11 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "httpx" }, { name = "httpx2" },
{ name = "hypothesis" }, { name = "hypothesis" },
{ name = "mypy" },
{ name = "pytest" }, { name = "pytest" },
{ name = "ruff" }, { name = "ruff" },
{ name = "ty" },
] ]
[package.metadata] [package.metadata]
@@ -538,6 +467,7 @@ requires-dist = [
{ name = "defusedxml", specifier = ">=0.7" }, { name = "defusedxml", specifier = ">=0.7" },
{ name = "email-validator", specifier = ">=2.1" }, { name = "email-validator", specifier = ">=2.1" },
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "iso18245", specifier = ">=1.4.1" },
{ name = "openpyxl", specifier = ">=3.1" }, { name = "openpyxl", specifier = ">=3.1" },
{ name = "pdfplumber", specifier = ">=0.11" }, { name = "pdfplumber", specifier = ">=0.11" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
@@ -551,11 +481,11 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "httpx", specifier = ">=0.27" }, { name = "httpx2", specifier = ">=2.0" },
{ name = "hypothesis", specifier = ">=6.112" }, { name = "hypothesis", specifier = ">=6.112" },
{ name = "mypy", specifier = ">=1.10" },
{ name = "pytest", specifier = ">=8.2" }, { name = "pytest", specifier = ">=8.2" },
{ name = "ruff", specifier = ">=0.5" }, { name = "ruff", specifier = ">=0.5" },
{ name = "ty", specifier = ">=0.0.18" },
] ]
[[package]] [[package]]
@@ -591,16 +521,16 @@ wheels = [
] ]
[[package]] [[package]]
name = "httpcore" name = "httpcore2"
version = "1.0.9" version = "2.13.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "certifi" },
{ name = "h11" }, { 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 = [ 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]] [[package]]
@@ -626,18 +556,28 @@ wheels = [
] ]
[[package]] [[package]]
name = "httpx" name = "httpx2"
version = "0.28.1" version = "2.13.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio", marker = "sys_platform != 'emscripten'" },
{ name = "certifi" }, { name = "httpcore2", marker = "sys_platform != 'emscripten'" },
{ name = "httpcore" }, { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
{ name = "idna" }, { 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 = [ 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]] [[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" }, { 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]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.3.0" 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" }, { 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]] [[package]]
name = "jmespath" name = "jmespath"
version = "1.1.0" version = "1.1.0"
@@ -745,45 +706,6 @@ redis = [
{ name = "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]] [[package]]
name = "mako" name = "mako"
version = "1.4.1" 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" }, { 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]] [[package]]
name = "openpyxl" name = "openpyxl"
version = "3.1.5" 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" }, { 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]] [[package]]
name = "pdfminer-six" name = "pdfminer-six"
version = "20260107" 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" }, { 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]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.16.0" version = "4.16.0"
+6 -2
View File
@@ -218,16 +218,19 @@ export interface CategoryDefinition {
systemKey: string | null systemKey: string | null
linkedAccountId: string | null linkedAccountId: string | null
archived: boolean archived: boolean
mccCodes: number[]
} }
export interface CreateCategoryPayload { export interface CreateCategoryPayload {
name: string name: string
kind: CreatableCategoryKind kind: CreatableCategoryKind
mccCodes?: number[]
} }
export interface PatchCategoryDefinitionPayload { export interface PatchCategoryDefinitionPayload {
name?: string name?: string
archived?: boolean archived?: boolean
mccCodes?: number[]
} }
export type ImportStatus = export type ImportStatus =
@@ -291,6 +294,8 @@ export interface ImportRow {
direction: 'in' | 'out' direction: 'in' | 'out'
bankStatus: RowBankStatus bankStatus: RowBankStatus
description: string | null description: string | null
mcc: number | null
mccName: string | null
payeeHint: string | null payeeHint: string | null
warnings: string[] warnings: string[]
decision: RowDecision decision: RowDecision
@@ -322,13 +327,12 @@ export interface SaveRowDecisionPayload {
export interface ResolveHoldPayload { export interface ResolveHoldPayload {
action: HoldResolutionAction action: HoldResolutionAction
reason: string reason?: string
accountId?: string accountId?: string
} }
export interface ConfirmImportPayload { export interface ConfirmImportPayload {
confirmedWithoutFullReconciliation?: boolean confirmedWithoutFullReconciliation?: boolean
reason?: string
} }
export interface CategorySpendItem { export interface CategorySpendItem {
@@ -19,26 +19,42 @@ import { categoryLabel } from '@/lib/categoryLabel'
const CREATE_KINDS: readonly CreatableCategoryKind[] = ['monthly', 'savings_envelope'] const CREATE_KINDS: readonly CreatableCategoryKind[] = ['monthly', 'savings_envelope']
function parseMccCodes(value: string): number[] {
return value.split(',').map((code) => Number(code.trim()))
}
function CreateCategoryForm() { function CreateCategoryForm() {
const { t } = useTranslation() const { t } = useTranslation()
const create = useCreateCategory() const create = useCreateCategory()
const schema = z.object({ const schema = z.object({
name: z.string().trim().min(1, t('common.requiredField')), name: z.string().trim().min(1, t('common.requiredField')),
kind: z.enum(['monthly', 'savings_envelope']), 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> type Values = z.infer<typeof schema>
const { const {
register, register,
handleSubmit, handleSubmit,
reset, reset,
watch,
formState: { errors }, formState: { errors },
} = useForm<Values>({ } = useForm<Values>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { name: '', kind: 'monthly' }, defaultValues: { name: '', kind: 'monthly', mccCodes: '' },
shouldUnregister: true,
}) })
const submit = handleSubmit((values) => { 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 ( return (
@@ -55,6 +71,14 @@ function CreateCategoryForm() {
</option> </option>
))} ))}
</SelectField> </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}> <Button type="submit" disabled={create.isPending}>
{create.isPending ? t('common.saving') : t('common.create')} {create.isPending ? t('common.saving') : t('common.create')}
</Button> </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 }) { export function CategoryManagerModal({ onClose }: { onClose: () => void }) {
const { t } = useTranslation() const { t } = useTranslation()
const definitions = useCategoryDefinitions() const definitions = useCategoryDefinitions()
const update = useUpdateCategoryDefinition() const update = useUpdateCategoryDefinition()
const [tab, setTab] = useState<'active' | 'archived'>('active') const [tab, setTab] = useState<'active' | 'archived'>('active')
const [renameTarget, setRenameTarget] = useState<CategoryDefinition | null>(null) const [renameTarget, setRenameTarget] = useState<CategoryDefinition | null>(null)
const [mccTarget, setMccTarget] = useState<CategoryDefinition | null>(null)
const [archiveTarget, setArchiveTarget] = useState<CategoryDefinition | null>(null) const [archiveTarget, setArchiveTarget] = useState<CategoryDefinition | null>(null)
return ( return (
<Modal open onClose={onClose} title={t('budget.manager.title')}> <Modal open onClose={onClose} title={t('budget.manager.title')}>
@@ -161,11 +232,18 @@ export function CategoryManagerModal({ onClose }: { onClose: () => void }) {
{t(`budget.category.kind.${definition.kind}`)} {t(`budget.category.kind.${definition.kind}`)}
{isSystem ? ` · ${t('budget.manager.systemProtected')}` : ''} {isSystem ? ` · ${t('budget.manager.systemProtected')}` : ''}
</span> </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> </div>
{!isSystem ? ( {!isSystem || (definition.kind === 'monthly' && !definition.archived) ? (
<div className="inline-actions manager-row-actions"> <div className="inline-actions manager-row-actions">
{!definition.archived ? ( {!definition.archived ? (
<> <>
{!isSystem ? (
<Button <Button
variant="subtle" variant="subtle"
disabled={update.isPending} disabled={update.isPending}
@@ -173,6 +251,17 @@ export function CategoryManagerModal({ onClose }: { onClose: () => void }) {
> >
{t('budget.manager.rename')} {t('budget.manager.rename')}
</Button> </Button>
) : null}
{definition.kind === 'monthly' ? (
<Button
variant="subtle"
disabled={update.isPending}
onClick={() => setMccTarget(definition)}
>
{t('budget.manager.editMccCodes')}
</Button>
) : null}
{!isSystem ? (
<Button <Button
variant="subtle" variant="subtle"
disabled={update.isPending} disabled={update.isPending}
@@ -180,6 +269,7 @@ export function CategoryManagerModal({ onClose }: { onClose: () => void }) {
> >
{t('budget.manager.archive')} {t('budget.manager.archive')}
</Button> </Button>
) : null}
</> </>
) : ( ) : (
<Button <Button
@@ -204,6 +294,9 @@ export function CategoryManagerModal({ onClose }: { onClose: () => void }) {
{renameTarget ? ( {renameTarget ? (
<RenameModal definition={renameTarget} onClose={() => setRenameTarget(null)} /> <RenameModal definition={renameTarget} onClose={() => setRenameTarget(null)} />
) : null} ) : null}
{mccTarget ? (
<MccCodesModal definition={mccTarget} onClose={() => setMccTarget(null)} />
) : null}
<ConfirmDialog <ConfirmDialog
open={archiveTarget !== null} open={archiveTarget !== null}
danger danger
+2
View File
@@ -67,6 +67,8 @@ export function useUpdateCategoryDefinition() {
toast.push('success', t('toast.categoryArchived')) toast.push('success', t('toast.categoryArchived'))
} else if (variables.payload.archived === false) { } else if (variables.payload.archived === false) {
toast.push('success', t('toast.categoryRestored')) toast.push('success', t('toast.categoryRestored'))
} else if (variables.payload.mccCodes !== undefined) {
toast.push('success', t('toast.categoryMccUpdated'))
} else { } else {
toast.push('success', t('toast.categoryRenamed', { name: definition.name ?? '' })) toast.push('success', t('toast.categoryRenamed', { name: definition.name ?? '' }))
} }
+24 -2
View File
@@ -254,6 +254,12 @@
"systemBadge": "System", "systemBadge": "System",
"systemProtected": "renaming and archiving are unavailable", "systemProtected": "renaming and archiving are unavailable",
"rename": "Rename", "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", "archive": "Archive",
"restore": "Restore", "restore": "Restore",
"renameTitle": "Rename category", "renameTitle": "Rename category",
@@ -368,7 +374,7 @@
"holdCancelledTitle": "Operation cancelled", "holdCancelledTitle": "Operation cancelled",
"holdCancelledBody": "The held operation of {{date}} for {{amount}} will be marked cancelled and never enters the ledger.", "holdCancelledBody": "The held operation of {{date}} for {{amount}} will be marked cancelled and never enters the ledger.",
"holdReason": "Resolution reason", "holdReason": "Resolution reason",
"holdReasonHint": "Required; kept in the audit journal", "holdReasonHint": "Optional; you can leave it blank",
"holdResolution.confirmed_as_posted": "Posted", "holdResolution.confirmed_as_posted": "Posted",
"holdResolution.discarded_as_cancelled": "Cancelled", "holdResolution.discarded_as_cancelled": "Cancelled",
"warnings": "Warnings ({{count}})", "warnings": "Warnings ({{count}})",
@@ -389,6 +395,7 @@
"rowsTitle": "Statement rows", "rowsTitle": "Statement rows",
"noRows": "No rows.", "noRows": "No rows.",
"rowWarnings": "Notes", "rowWarnings": "Notes",
"mcc": "MCC",
"rowWarnings.amount": "amount", "rowWarnings.amount": "amount",
"rowWarnings.date": "date", "rowWarnings.date": "date",
"rowWarnings.direction": "direction", "rowWarnings.direction": "direction",
@@ -405,7 +412,6 @@
"confirmTitle": "Apply import", "confirmTitle": "Apply import",
"confirmBody": "Operations will be created from the selected rows. Applying is atomic and can be reverted.", "confirmBody": "Operations will be created from the selected rows. Applying is atomic and can be reverted.",
"confirmWithout": "I confirm applying without full balance reconciliation", "confirmWithout": "I confirm applying without full balance reconciliation",
"confirmReason": "Reason",
"reparse": "Reparse", "reparse": "Reparse",
"revert": "Revert import", "revert": "Revert import",
"revertTitle": "Revert import", "revertTitle": "Revert import",
@@ -463,6 +469,7 @@
"savingsConversionReverted": "Transfer reverted to expense", "savingsConversionReverted": "Transfer reverted to expense",
"categoryCreated": "Category “{{name}}” created", "categoryCreated": "Category “{{name}}” created",
"categoryRenamed": "Category renamed to “{{name}}”", "categoryRenamed": "Category renamed to “{{name}}”",
"categoryMccUpdated": "Category MCC codes updated",
"categoryArchived": "Category archived", "categoryArchived": "Category archived",
"categoryRestored": "Category restored", "categoryRestored": "Category restored",
"planUpdated": "Plan updated", "planUpdated": "Plan updated",
@@ -491,12 +498,27 @@
"categories": { "categories": {
"uncategorized": "Uncategorized", "uncategorized": "Uncategorized",
"groceries": "Groceries", "groceries": "Groceries",
"cafes": "Cafes and restaurants",
"transport": "Transport", "transport": "Transport",
"fuel": "Fuel and gas stations",
"housing": "Housing", "housing": "Housing",
"utilities": "Utilities", "utilities": "Utilities",
"health": "Health", "health": "Health",
"electronics": "Electronics",
"clothing": "Clothing and footwear",
"beauty": "Beauty and personal care",
"education": "Education", "education": "Education",
"entertainment": "Entertainment", "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", "eating_out": "Eating out",
"clothes": "Clothes", "clothes": "Clothes",
"personal": "Personal", "personal": "Personal",
+24 -2
View File
@@ -254,6 +254,12 @@
"systemBadge": "Системная", "systemBadge": "Системная",
"systemProtected": "переименование и архивирование недоступны", "systemProtected": "переименование и архивирование недоступны",
"rename": "Переименовать", "rename": "Переименовать",
"mccCodes": "Коды МСС",
"mccCodesHint": "Необязательно: 3–4-значные коды через запятую",
"mccCodesInvalid": "Укажите 3–4-значные коды МСС через запятую",
"mccCodesCount": "Коды МСС ({{count}})",
"editMccCodes": "Изменить МСС",
"editMccCodesTitle": "Коды МСС категории",
"archive": "В архив", "archive": "В архив",
"restore": "Восстановить", "restore": "Восстановить",
"renameTitle": "Переименовать категорию", "renameTitle": "Переименовать категорию",
@@ -368,7 +374,7 @@
"holdCancelledTitle": "Операция отменена", "holdCancelledTitle": "Операция отменена",
"holdCancelledBody": "Замороженная операция от {{date}} на {{amount}} будет помечена отменённой и не попадёт в операции.", "holdCancelledBody": "Замороженная операция от {{date}} на {{amount}} будет помечена отменённой и не попадёт в операции.",
"holdReason": "Причина решения", "holdReason": "Причина решения",
"holdReasonHint": "Обязательно; попадёт в журнал аудита", "holdReasonHint": "Необязательно, можно оставить пустым",
"holdResolution.confirmed_as_posted": "Проведена", "holdResolution.confirmed_as_posted": "Проведена",
"holdResolution.discarded_as_cancelled": "Отменена", "holdResolution.discarded_as_cancelled": "Отменена",
"warnings": "Предупреждения ({{count}})", "warnings": "Предупреждения ({{count}})",
@@ -389,6 +395,7 @@
"rowsTitle": "Строки выписки", "rowsTitle": "Строки выписки",
"noRows": "Строк нет.", "noRows": "Строк нет.",
"rowWarnings": "Замечания", "rowWarnings": "Замечания",
"mcc": "МСС",
"rowWarnings.amount": "сумма", "rowWarnings.amount": "сумма",
"rowWarnings.date": "дата", "rowWarnings.date": "дата",
"rowWarnings.direction": "направление", "rowWarnings.direction": "направление",
@@ -405,7 +412,6 @@
"confirmTitle": "Применить импорт", "confirmTitle": "Применить импорт",
"confirmBody": "Будут созданы операции по выбранным строкам. Применение атомарно, его можно отменить.", "confirmBody": "Будут созданы операции по выбранным строкам. Применение атомарно, его можно отменить.",
"confirmWithout": "Подтверждаю применение без полной сверки остатков", "confirmWithout": "Подтверждаю применение без полной сверки остатков",
"confirmReason": "Причина",
"reparse": "Переразобрать", "reparse": "Переразобрать",
"revert": "Отменить применение", "revert": "Отменить применение",
"revertTitle": "Отменить применение импорта", "revertTitle": "Отменить применение импорта",
@@ -465,6 +471,7 @@
"savingsConversionReverted": "Перевод возвращён в расходы", "savingsConversionReverted": "Перевод возвращён в расходы",
"categoryCreated": "Категория «{{name}}» создана", "categoryCreated": "Категория «{{name}}» создана",
"categoryRenamed": "Категория переименована в «{{name}}»", "categoryRenamed": "Категория переименована в «{{name}}»",
"categoryMccUpdated": "Коды МСС категории обновлены",
"categoryArchived": "Категория отправлена в архив", "categoryArchived": "Категория отправлена в архив",
"categoryRestored": "Категория восстановлена", "categoryRestored": "Категория восстановлена",
"planUpdated": "План обновлён", "planUpdated": "План обновлён",
@@ -493,12 +500,27 @@
"categories": { "categories": {
"uncategorized": "Без категории", "uncategorized": "Без категории",
"groceries": "Продукты", "groceries": "Продукты",
"cafes": "Кафе и рестораны",
"transport": "Транспорт", "transport": "Транспорт",
"fuel": "Топливо и АЗС",
"housing": "Жильё", "housing": "Жильё",
"utilities": "Коммунальные", "utilities": "Коммунальные",
"health": "Здоровье", "health": "Здоровье",
"electronics": "Электроника",
"clothing": "Одежда и обувь",
"beauty": "Красота и уход",
"education": "Образование", "education": "Образование",
"entertainment": "Развлечения", "entertainment": "Развлечения",
"sport": "Спорт и фитнес",
"travel": "Путешествия",
"home": "Дом и ремонт",
"telecom": "Связь и интернет",
"kids": "Дети",
"pets": "Питомцы",
"gifts": "Подарки и хобби",
"auto": "Авто",
"finance": "Финансы и страхование",
"others": "Остальное",
"eating_out": "Кафе и рестораны", "eating_out": "Кафе и рестораны",
"clothes": "Одежда", "clothes": "Одежда",
"personal": "Личное", "personal": "Личное",
+15 -32
View File
@@ -1,14 +1,11 @@
import { useState } from 'react' import { useState, type FormEvent } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { z } from 'zod'
import type { HoldResolutionAction, ImportRow, RowDecision } from '@/api/types' import type { HoldResolutionAction, ImportRow, RowDecision } from '@/api/types'
import { Badge } from '@/components/ui/Badge' import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog' 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 { Modal } from '@/components/ui/Modal'
import { PageHeader } from '@/components/ui/PageHeader' import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate' import { QueryGate } from '@/components/ui/QueryGate'
@@ -56,25 +53,15 @@ function HoldResolveModal({
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language) const locale = currentLocale(i18n.language)
const resolve = useResolveHold(importId) const resolve = useResolveHold(importId)
const schema = z.object({ const [reason, setReason] = useState('')
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 submit = handleSubmit((values) => { const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
resolve.mutate( resolve.mutate(
{ rowId: row.id, payload: { action, reason: values.reason } }, { rowId: row.id, payload: { action, reason: reason.trim() || undefined } },
{ onSuccess: onClose }, { onSuccess: onClose },
) )
}) }
return ( return (
<Modal <Modal
@@ -101,8 +88,8 @@ function HoldResolveModal({
<TextareaField <TextareaField
label={t('imports.holdReason')} label={t('imports.holdReason')}
hint={t('imports.holdReasonHint')} hint={t('imports.holdReasonHint')}
error={errors.reason?.message} value={reason}
{...register('reason')} onChange={(event) => setReason(event.target.value)}
/> />
<Button type="submit" variant="primary" disabled={resolve.isPending}> <Button type="submit" variant="primary" disabled={resolve.isPending}>
{resolve.isPending ? t('common.saving') : t('common.confirm')} {resolve.isPending ? t('common.saving') : t('common.confirm')}
@@ -124,7 +111,6 @@ function ConfirmModal({
const { t } = useTranslation() const { t } = useTranslation()
const confirm = useConfirmImport(importId) const confirm = useConfirmImport(importId)
const [acknowledged, setAcknowledged] = useState(false) const [acknowledged, setAcknowledged] = useState(false)
const [reason, setReason] = useState('')
const disabled = needsConfirmation && !acknowledged const disabled = needsConfirmation && !acknowledged
@@ -142,20 +128,14 @@ function ConfirmModal({
/> />
{t('imports.confirmWithout')} {t('imports.confirmWithout')}
</label> </label>
<TextField
label={t('imports.confirmReason')}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</> </>
) : null} ) : null}
<Button <Button
disabled={disabled || confirm.isPending} disabled={disabled || confirm.isPending}
onClick={() => onClick={() =>
confirm.mutate( confirm.mutate(needsConfirmation ? { confirmedWithoutFullReconciliation: true } : {}, {
needsConfirmation ? { confirmedWithoutFullReconciliation: true, reason: reason || undefined } : {}, onSuccess: onClose,
{ onSuccess: onClose }, })
)
} }
> >
{t('imports.confirm')} {t('imports.confirm')}
@@ -334,6 +314,9 @@ export function ImportDetailPage() {
<td data-label={t('operations.description')} className="statement-description"> <td data-label={t('operations.description')} className="statement-description">
{row.description ?? '—'} {row.description ?? '—'}
{row.payeeHint ? <span className="muted"> · {row.payeeHint}</span> : null} {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>
<td <td
data-label={t('common.amount')} data-label={t('common.amount')}
@@ -70,10 +70,21 @@ const diningDefinition: CategoryDefinition = {
systemKey: null, systemKey: null,
linkedAccountId: null, linkedAccountId: null,
archived: false, archived: false,
mccCodes: [5999, 5994],
} }
const definitions: CategoryDefinition[] = [ const definitions: CategoryDefinition[] = [
diningDefinition, diningDefinition,
{
id: 'def-groceries',
name: 'Продукты и супермаркеты',
nameKey: 'groceries',
kind: 'monthly',
systemKey: 'groceries',
linkedAccountId: null,
archived: false,
mccCodes: [5411, 5422],
},
{ {
id: 'def-savings', id: 'def-savings',
name: 'Накопления', name: 'Накопления',
@@ -82,6 +93,7 @@ const definitions: CategoryDefinition[] = [
systemKey: 'savings', systemKey: 'savings',
linkedAccountId: 'a-sys', linkedAccountId: 'a-sys',
archived: false, archived: false,
mccCodes: [],
}, },
{ {
id: 'def-old-gym', id: 'def-old-gym',
@@ -91,6 +103,7 @@ const definitions: CategoryDefinition[] = [
systemKey: null, systemKey: null,
linkedAccountId: null, linkedAccountId: null,
archived: true, archived: true,
mccCodes: [],
}, },
] ]
@@ -129,6 +142,75 @@ describe('BudgetPage category manager lifecycle', () => {
expect(await screen.findByText('Category “Dining” created')).toBeInTheDocument() 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 () => { it('renames and archives a user category through inline actions', async () => {
updateDefinitionMock.mockResolvedValue(definition({ name: 'Eating out' })) updateDefinitionMock.mockResolvedValue(definition({ name: 'Eating out' }))
const { screen, dialog } = await openManager() 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', direction: 'out',
bankStatus: 'posted', bankStatus: 'posted',
description: 'Coffee beans', description: 'Coffee beans',
mcc: null,
mccName: null,
payeeHint: null, payeeHint: null,
warnings: [], warnings: [],
decision: 'create', decision: 'create',
@@ -124,7 +126,15 @@ describe('ImportDetailPage HOLD resolution', () => {
expect(screen.getByRole('button', { name: 'Operation cancelled' })).toBeInTheDocument() 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])) resolveHoldMock.mockResolvedValue(detailFixture([row({}), resolvedHold]))
const screen = renderWithProviders(<ImportDetailPage />) const screen = renderWithProviders(<ImportDetailPage />)
await screen.findByRole('button', { name: 'Operation posted' }) await screen.findByRole('button', { name: 'Operation posted' })
@@ -132,17 +142,30 @@ describe('ImportDetailPage HOLD resolution', () => {
await userEvent.click(screen.getByRole('button', { name: 'Operation posted' })) await userEvent.click(screen.getByRole('button', { name: 'Operation posted' }))
const dialog = await screen.findByRole('dialog', { 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' })) await userEvent.click(within(dialog).getByRole('button', { name: 'Confirm' }))
expect(resolveHoldMock).toHaveBeenCalledTimes(1) expect(resolveHoldMock).toHaveBeenCalledTimes(1)
expect(resolveHoldMock).toHaveBeenCalledWith('imp-1', 'row-hold', { expect(resolveHoldMock).toHaveBeenCalledWith('imp-1', 'row-hold', {
action: 'confirm_as_posted', 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", "1": "budgeting/router.py",
"2": "Base", "2": "Base",
"3": "exports/service.py", "3": "exports/service.py",
"4": "imports/service.py", "4": "imports/router.py",
"5": "alfa_xlsx.py", "5": "alfa_xlsx.py",
"6": "system-account-picker.test.tsx", "6": "system-account-picker.test.tsx",
"7": "types.ts", "7": "types.ts",
@@ -14,14 +14,14 @@
"12": "BudgetPage.tsx", "12": "BudgetPage.tsx",
"13": "@testing-library/user-event", "13": "@testing-library/user-event",
"14": "devDependencies", "14": "devDependencies",
"15": "Session", "15": "AppError",
"16": "dependencies", "16": "dependencies",
"17": "ImportDetailPage.tsx", "17": "ImportDetailPage.tsx",
"18": "OperationsPage.tsx", "18": "OperationsPage.tsx",
"19": "useAccounts.ts", "19": "useOperations.ts",
"20": "index.ts", "20": "apiErrors.ts",
"21": "test_adapter_contracts.py", "21": "test_adapter_contracts.py",
"22": "AccountsPage.tsx", "22": "useBudget.ts",
"23": "client.ts", "23": "client.ts",
"24": "fields.tsx", "24": "fields.tsx",
"25": "Docker Compose Development Stack", "25": "Docker Compose Development Stack",
@@ -43,8 +43,8 @@
"41": "test_api.py", "41": "test_api.py",
"42": "i18n-parity.test.ts", "42": "i18n-parity.test.ts",
"43": "Expected Income", "43": "Expected Income",
"44": "helpers.tsx", "44": "api",
"45": "20260922_0003_savings_system.py", "45": "set_space_context",
"46": "category-manager.test.tsx", "46": "category-manager.test.tsx",
"47": "graphify", "47": "graphify",
"48": "@types/react-dom", "48": "@types/react-dom",
@@ -57,31 +57,35 @@
"55": "spaces/__init__.py", "55": "spaces/__init__.py",
"56": "Payee", "56": "Payee",
"57": "eslint.config.js", "57": "eslint.config.js",
"58": "spaces/router.py", "58": "test_private_fixtures.py",
"59": "vite-env.d.ts", "59": "vite-env.d.ts",
"60": "vite.config.ts", "60": "vite.config.ts",
"61": "finance-backend", "61": "finance-backend",
"62": "LocalDirectoryBlobStore", "62": "LocalDirectoryBlobStore",
"63": "TestOzonDebitContract", "63": "OzonDebitPdfAdapter",
"64": "Gitea CI and real statement imports", "64": "Gitea CI and real statement imports",
"65": "20260918_0002_hold_lifecycle.py", "65": "20260918_0002_hold_lifecycle.py",
"66": "eslint-plugin-react-hooks", "66": "eslint-plugin-react-hooks",
"67": "main.py", "67": "main.py",
"68": "current_session", "68": "analytics/router.py",
"69": "TestAlfaDebitContract", "69": "TestAlfaDebitContract",
"70": "vite", "70": "vite",
"71": "rocketbank_pdf.py", "71": "mcc.py",
"72": "TestAlfaSavingsRule", "72": "TestAlfaSavingsRule",
"73": "savings-convert-revert.test.tsx", "73": "savings-convert-revert.test.tsx",
"74": "api", "74": "exports/router.py",
"75": "ColumnChart.tsx", "75": "ColumnChart.tsx",
"76": "get_settings", "76": "identity/service.py",
"77": "AGENTS.md", "77": "AGENTS.md",
"78": "graphify.js", "78": "graphify.js",
"79": "dependencies.py", "79": "Category",
"80": "test_category_definitions.py", "80": "test_category_definitions.py",
"81": "budget-plan.test.tsx", "81": "budget-plan.test.tsx",
"82": "import-hold-resolution.test.tsx", "82": "import-hold-resolution.test.tsx",
"83": "test_postgres_and_s3.py", "83": "get_settings",
"86": "test_import_holds.py" "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) # Graph Report - finance (2026-09-23)
## Corpus Check ## Corpus Check
- 178 files · ~83,334 words - 182 files · ~87,581 words
- Verdict: corpus is large enough that graph structure adds value. - Verdict: corpus is large enough that graph structure adds value.
## Summary ## 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) - Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 91 edges (avg confidence: 0.93)
- Token cost: 0 input · 0 output - Token cost: 0 input · 0 output
## Graph Freshness ## 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 `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost). - Run `graphify update .` after code changes (no API cost).
## Community Hubs (Navigation) ## Community Hubs (Navigation)
- identity/service.py - categorization/service.py
- budgeting/router.py - budgeting/router.py
- Base - Base
- exports/service.py - exports/service.py
- imports/service.py - imports/router.py
- alfa_xlsx.py - alfa_xlsx.py
- system-account-picker.test.tsx - system-account-picker.test.tsx
- types.ts - types.ts
@@ -30,14 +30,14 @@
- BudgetPage.tsx - BudgetPage.tsx
- @testing-library/user-event - @testing-library/user-event
- devDependencies - devDependencies
- Session - AppError
- dependencies - dependencies
- ImportDetailPage.tsx - ImportDetailPage.tsx
- OperationsPage.tsx - OperationsPage.tsx
- useAccounts.ts - useOperations.ts
- index.ts - apiErrors.ts
- test_adapter_contracts.py - test_adapter_contracts.py
- AccountsPage.tsx - useBudget.ts
- client.ts - client.ts
- fields.tsx - fields.tsx
- Docker Compose Development Stack - Docker Compose Development Stack
@@ -59,8 +59,8 @@
- test_api.py - test_api.py
- i18n-parity.test.ts - i18n-parity.test.ts
- Expected Income - Expected Income
- helpers.tsx - api
- 20260922_0003_savings_system.py - set_space_context
- category-manager.test.tsx - category-manager.test.tsx
- graphify - graphify
- @types/react-dom - @types/react-dom
@@ -70,42 +70,46 @@
- Favicon SVG (ledger lines icon) - Favicon SVG (ledger lines icon)
- init-app-role.sh - init-app-role.sh
- Payee - Payee
- spaces/router.py - test_private_fixtures.py
- finance-backend - finance-backend
- LocalDirectoryBlobStore - LocalDirectoryBlobStore
- TestOzonDebitContract - OzonDebitPdfAdapter
- Gitea CI and real statement imports - Gitea CI and real statement imports
- 20260918_0002_hold_lifecycle.py - 20260918_0002_hold_lifecycle.py
- eslint-plugin-react-hooks - eslint-plugin-react-hooks
- main.py - main.py
- current_session - analytics/router.py
- TestAlfaDebitContract - TestAlfaDebitContract
- vite - vite
- rocketbank_pdf.py - mcc.py
- TestAlfaSavingsRule - TestAlfaSavingsRule
- savings-convert-revert.test.tsx - savings-convert-revert.test.tsx
- api - exports/router.py
- ColumnChart.tsx - ColumnChart.tsx
- get_settings - identity/service.py
- AGENTS.md - AGENTS.md
- dependencies.py - Category
- test_category_definitions.py - test_category_definitions.py
- budget-plan.test.tsx - budget-plan.test.tsx
- import-hold-resolution.test.tsx - import-hold-resolution.test.tsx
- test_postgres_and_s3.py - get_settings
- test_import_holds.py - session_scope
- 20260923_0004_mcc_categories.py
- Session
- app_error_handler
- 20260923_0005_optional_hold_reason.py
## God Nodes (most connected - your core abstractions) ## God Nodes (most connected - your core abstractions)
1. `Session` - 198 edges 1. `Session` - 209 edges
2. `AppError` - 73 edges 2. `AppError` - 72 edges
3. `Base` - 47 edges 3. `Base` - 48 edges
4. `conflict()` - 42 edges 4. `conflict()` - 43 edges
5. `get_settings()` - 39 edges 5. `get_settings()` - 40 edges
6. `PkMixin` - 39 edges 6. `PkMixin` - 40 edges
7. `record()` - 38 edges 7. `record()` - 38 edges
8. `TimestampMixin` - 38 edges 8. `TimestampMixin` - 38 edges
9. `StatementDocument` - 36 edges 9. `StatementDocument` - 36 edges
10. `create_expense()` - 35 edges 10. `create_expense()` - 36 edges
## Surprising Connections (you probably didn't know these) ## Surprising Connections (you probably didn't know these)
- `Typed Same-Origin API Client` --conceptually_related_to--> `Same-Origin Reverse Proxy` [INFERRED] - `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] - **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] - **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" ### Community 0 - "categorization/service.py"
Cohesion: 0.28 Cohesion: 0.14
Nodes (18): unauthorized(), accept_invitation(), authenticate(), create_invitation(), create_password_reset(), create_session(), get_active_session(), normalize_email() (+10 more) 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" ### Community 1 - "budgeting/router.py"
Cohesion: 0.13 Cohesion: 0.17
Nodes (48): balances(), cash_flow(), category_spend(), plan_fact(), Context, date, Db, get (+40 more) Nodes (41): AssignmentItem, assignments(), AssignmentsBody, categories(), category_definition_json(), category_definitions(), CategoryBody, CategoryDefinitionPatchBody (+33 more)
### Community 2 - "Base" ### Community 2 - "Base"
Cohesion: 0.07 Cohesion: 0.13
Nodes (73): downgrade(), upgrade(), AuditEvent, Append-only domain/security event. Never stores raw financial payloads., ExpectedIncome, ExpectedIncomeMatch, FundingMovement, PeriodClosure (+65 more) 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" ### Community 3 - "exports/service.py"
Cohesion: 0.10 Cohesion: 0.19
Nodes (35): Any, Transactional scope for workers/endpoints that own the transaction lifecycle., session_scope(), download(), export_status(), Context, Csrf, Db (+27 more) Nodes (17): build_export(), cancel_deletion(), consume_export(), _csv_bytes(), get_export(), _json_rows(), datetime, UUID (+9 more)
### Community 4 - "imports/service.py" ### Community 4 - "imports/router.py"
Cohesion: 0.06 Cohesion: 0.15
Nodes (85): datetime, utcnow(), magic_kind(), StatementDocument, confirm(), ConfirmBody, decide_row(), DecisionBody (+77 more) Nodes (35): confirm(), ConfirmBody, decide_row(), DecisionBody, delete_source(), detail(), HoldResolveBody, imports() (+27 more)
### Community 5 - "alfa_xlsx.py" ### Community 5 - "alfa_xlsx.py"
Cohesion: 0.15 Cohesion: 0.17
Nodes (27): AlfaCreditXlsxAdapter, AlfaDebitXlsxAdapter, _AlfaHeaderMeta, AlfaXlsxAdapterBase, _cell_str(), _debit_find_header(), _debit_header_metadata(), _debit_header_value() (+19 more) 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" ### Community 6 - "system-account-picker.test.tsx"
Cohesion: 0.15 Cohesion: 0.15
@@ -159,11 +163,11 @@ Nodes (12): AdjustBalanceResult, BudgetCategory, Operation, Page, categoriesMock
### Community 7 - "types.ts" ### Community 7 - "types.ts"
Cohesion: 0.08 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" ### Community 8 - "router.tsx"
Cohesion: 0.07 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" ### Community 9 - "ledger/router.py"
Cohesion: 0.16 Cohesion: 0.16
@@ -174,16 +178,16 @@ Cohesion: 0.07
Nodes (28): compilerOptions, baseUrl, esModuleInterop, forceConsistentCasingInFileNames, isolatedModules, jsx, lib, module (+20 more) Nodes (28): compilerOptions, baseUrl, esModuleInterop, forceConsistentCasingInFileNames, isolatedModules, jsx, lib, module (+20 more)
### Community 12 - "BudgetPage.tsx" ### Community 12 - "BudgetPage.tsx"
Cohesion: 0.08 Cohesion: 0.10
Nodes (53): NotFoundPage, CategoryManagerModal(), CreateCategoryForm(), RenameModal(), PlanFactBars(), PlanFactBarsProps, PlanFactRow, MonthSwitcher() (+45 more) Nodes (42): NotFoundPage, PlanFactBars(), PlanFactBarsProps, PlanFactRow, MonthSwitcher(), MonthSwitcherProps, PageHeader(), PageHeaderProps (+34 more)
### Community 14 - "devDependencies" ### Community 14 - "devDependencies"
Cohesion: 0.10 Cohesion: 0.10
Nodes (21): eslint, @eslint/js, eslint-plugin-jsx-a11y, devDependencies, eslint, @eslint/js, eslint-plugin-jsx-a11y, globals (+13 more) Nodes (21): eslint, @eslint/js, eslint-plugin-jsx-a11y, devDependencies, eslint, @eslint/js, eslint-plugin-jsx-a11y, globals (+13 more)
### Community 15 - "Session" ### Community 15 - "AppError"
Cohesion: 0.05 Cohesion: 0.06
Nodes (165): balances(), cash_flow(), category_spend(), plan_fact(), date, UUID, Any, record() (+157 more) 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" ### Community 16 - "dependencies"
Cohesion: 0.09 Cohesion: 0.09
@@ -194,32 +198,32 @@ Cohesion: 0.16
Nodes (23): HoldResolutionAction, ImportStatus, RowDecision, useToast(), useConfirmImport(), useImport(), useImportErrorToaster(), useReparseImport() (+15 more) Nodes (23): HoldResolutionAction, ImportStatus, RowDecision, useToast(), useConfirmImport(), useImport(), useImportErrorToaster(), useReparseImport() (+15 more)
### Community 18 - "OperationsPage.tsx" ### Community 18 - "OperationsPage.tsx"
Cohesion: 0.12 Cohesion: 0.09
Nodes (43): isSystemAccount(), Locale, useAccounts(), useActiveAccounts(), useUserAccounts(), useCategorizeOperation(), useConvertToSavings(), useCreateOperation() (+35 more) Nodes (54): Account, isSystemAccount(), Locale, Badge(), BadgeTone, EmptyState(), EmptyStateProps, MoneyField() (+46 more)
### Community 19 - "useAccounts.ts" ### Community 19 - "useOperations.ts"
Cohesion: 0.16 Cohesion: 0.15
Nodes (13): invalidationRoots, queryKeys, SESSION_ROOT, sessionInvalidationRoot, SPACE_ROOT, spaceInvalidationRoot, AdjustBalancePayload, CreateAccountPayload (+5 more) Nodes (16): invalidationRoots, queryKeys, SESSION_ROOT, sessionInvalidationRoot, SPACE_ROOT, spaceInvalidationRoot, CreateOperationPayload, OperationFilters (+8 more)
### Community 20 - "index.ts" ### Community 20 - "apiErrors.ts"
Cohesion: 0.16 Cohesion: 0.14
Nodes (13): BudgetBlocker, blockerCta(), blockerText(), BudgetBlockers(), BudgetBlockersProps, Cta, LangSwitcher(), createI18n() (+5 more) Nodes (16): isApiError(), BudgetBlocker, App(), queryClient, AppRoutes(), blockerCta(), blockerText(), BudgetBlockers() (+8 more)
### Community 21 - "test_adapter_contracts.py" ### Community 21 - "test_adapter_contracts.py"
Cohesion: 0.12 Cohesion: 0.11
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) 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" ### Community 22 - "useBudget.ts"
Cohesion: 0.12 Cohesion: 0.22
Nodes (23): Account, CREATE_KINDS, Badge(), BadgeTone, Button(), ButtonProps, Variant, ConfirmDialog() (+15 more) Nodes (15): CategoryManagerModal(), CREATE_KINDS, CreateCategoryForm(), MccCodesModal(), parseMccCodes(), RenameModal(), ConfirmDialog(), ConfirmDialogProps (+7 more)
### Community 23 - "client.ts" ### Community 23 - "client.ts"
Cohesion: 0.26 Cohesion: 0.26
Nodes (10): apiClient, ApiError, buildUrl(), ErrorEnvelope, parseErrorEnvelope(), QueryValue, readCsrfToken(), request() (+2 more) Nodes (10): apiClient, ApiError, buildUrl(), ErrorEnvelope, parseErrorEnvelope(), QueryValue, readCsrfToken(), request() (+2 more)
### Community 24 - "fields.tsx" ### Community 24 - "fields.tsx"
Cohesion: 0.23 Cohesion: 0.25
Nodes (10): FieldChrome, InputProps, MoneyField(), MoneyFieldProps, SelectFieldProps, SelectProps, TextareaField(), TextareaFieldProps (+2 more) Nodes (9): FieldChrome, InputProps, MoneyFieldProps, SelectFieldProps, SelectProps, TextareaField(), TextareaFieldProps, TextareaProps (+1 more)
### Community 25 - "Docker Compose Development Stack" ### Community 25 - "Docker Compose Development Stack"
Cohesion: 0.29 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 Nodes (8): Budget Period, Funding, General Pool, Monthly Category, Plan Assignment, Savings Envelope, Immutable Closure Snapshots, Plan and Funding Separation
### Community 30 - "TestBaseContract" ### Community 30 - "TestBaseContract"
Cohesion: 0.12 Cohesion: 0.21
Nodes (7): Protocol, StatementAdapter, detect_adapter(), make_draft(), make_source_row(), TestBaseContract, Workbook Nodes (3): make_draft(), make_source_row(), TestBaseContract
### Community 31 - "Per-File Envelope Encryption" ### Community 31 - "Per-File Envelope Encryption"
Cohesion: 0.29 Cohesion: 0.29
@@ -285,13 +289,13 @@ Nodes (5): components, $defs, operations, paths, webhooks
Cohesion: 0.67 Cohesion: 0.67
Nodes (3): TestClient, test_invite_login_and_authenticated_space(), test_openapi_contains_critical_paths() Nodes (3): TestClient, test_invite_login_and_authenticated_space(), test_openapi_contains_critical_paths()
### Community 44 - "helpers.tsx" ### Community 44 - "api"
Cohesion: 0.16 Cohesion: 0.11
Nodes (13): User, ToastApi, ToastContext, ToastItem, ToastKind, ToastProvider(), openManager(), createTestQueryClient() (+5 more) Nodes (18): api, User, ToastApi, ToastContext, ToastItem, ToastKind, ToastProvider(), adjustBalanceMock (+10 more)
### Community 45 - "20260922_0003_savings_system.py" ### Community 45 - "set_space_context"
Cohesion: 0.32 Cohesion: 0.26
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) 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" ### Community 46 - "category-manager.test.tsx"
Cohesion: 0.15 Cohesion: 0.15
@@ -305,14 +309,18 @@ Nodes (11): command, enabled, timeout, type, mcp, graphify, plugin, $schema (+3
Cohesion: 0.10 Cohesion: 0.10
Nodes (20): 1. Доменная основа накоплений, 2. Альфа debit: «Копилка для сдачи», 3. Управление категориями, 4. Понятные и устранимые блокировки, Backend-контракт, Frontend, Ближайший край: накопления, категории и блокировки бюджета, Бюджетная семантика (+12 more) Nodes (20): 1. Доменная основа накоплений, 2. Альфа debit: «Копилка для сдачи», 3. Управление категориями, 4. Понятные и устранимые блокировки, Backend-контракт, Frontend, Ближайший край: накопления, категории и блокировки бюджета, Бюджетная семантика (+12 more)
### Community 58 - "spaces/router.py" ### Community 58 - "test_private_fixtures.py"
Cohesion: 0.17 Cohesion: 0.12
Nodes (20): forbidden(), cancel_deletion(), cancel_deletion(), current(), deletion_status(), DeletionBody, invite_member(), MemberInviteBody (+12 more) 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" ### Community 62 - "LocalDirectoryBlobStore"
Cohesion: 0.39 Cohesion: 0.39
Nodes (3): LocalDirectoryBlobStore, Path, S3-neutral local store; production swaps in an S3-compatible client. 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" ### Community 64 - "Gitea CI and real statement imports"
Cohesion: 0.29 Cohesion: 0.29
Nodes (6): Confirmed decisions, Gitea CI and real statement imports, Goal and constraints, Implementation plan, Principal risks, Validation and rollout 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() Nodes (4): downgrade(), _has_check_constraint(), _has_column(), upgrade()
### Community 67 - "main.py" ### Community 67 - "main.py"
Cohesion: 0.13 Cohesion: 0.31
Nodes (23): get_db(), get_engine(), get_session_factory(), sessionmaker, app_error_handler(), health(), healthz(), get (+15 more) Nodes (9): get_db(), get_engine(), get_session_factory(), sessionmaker, health(), healthz(), get, readiness() (+1 more)
### Community 68 - "current_session" ### Community 68 - "analytics/router.py"
Cohesion: 0.20 Cohesion: 0.24
Nodes (10): current_session(), alias, Context, Db, Header, User, require_csrf(), Cookie (+2 more) Nodes (14): balances(), cash_flow(), category_spend(), plan_fact(), Context, date, Db, get (+6 more)
### Community 69 - "TestAlfaDebitContract" ### Community 69 - "TestAlfaDebitContract"
Cohesion: 0.18 Cohesion: 0.18
Nodes (3): alfa_workbook_bytes(), synthetic_alfa_rows(), TestAlfaDebitContract Nodes (3): alfa_workbook_bytes(), synthetic_alfa_rows(), TestAlfaDebitContract
### Community 71 - "rocketbank_pdf.py" ### Community 71 - "mcc.py"
Cohesion: 0.24 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" ### Community 73 - "savings-convert-revert.test.tsx"
Cohesion: 0.20 Cohesion: 0.20
Nodes (6): budgetCategoriesMock, listAccountsMock, listOperationsMock, operations, revertMock, savingsTransferMock Nodes (6): budgetCategoriesMock, listAccountsMock, listOperationsMock, operations, revertMock, savingsTransferMock
### Community 74 - "api" ### Community 74 - "exports/router.py"
Cohesion: 0.22 Cohesion: 0.27
Nodes (5): api, adjustBalanceMock, listMock, createMock, listMock Nodes (10): download(), export_status(), Context, Csrf, Db, get, post, Response (+2 more)
### Community 75 - "ColumnChart.tsx" ### Community 75 - "ColumnChart.tsx"
Cohesion: 0.24 Cohesion: 0.24
Nodes (7): CashFlowColumn, ColumnChart(), ColumnChartProps, BarRow, HorizontalBars(), HorizontalBarsProps, rows Nodes (7): CashFlowColumn, ColumnChart(), ColumnChartProps, BarRow, HorizontalBars(), HorizontalBarsProps, rows
### Community 76 - "get_settings" ### Community 76 - "identity/service.py"
Cohesion: 0.12 Cohesion: 0.06
Nodes (31): get_settings(), Settings, send_token_email(), accept_invite(), AcceptInviteBody, confirm_password_reset(), create_invite(), InviteBody (+23 more) Nodes (80): current_context(), current_session(), alias, Context, CurrentSession, Db, Header, User (+72 more)
### Community 79 - "dependencies.py" ### Community 79 - "Category"
Cohesion: 0.26 Cohesion: 0.23
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) 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" ### Community 80 - "test_category_definitions.py"
Cohesion: 0.54 Cohesion: 0.50
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() 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" ### Community 81 - "budget-plan.test.tsx"
Cohesion: 0.25 Cohesion: 0.25
Nodes (7): BudgetSummary, categories, categoriesMock, month, summary, summaryMock, updatePlanMock Nodes (7): BudgetSummary, categories, categoriesMock, month, summary, summaryMock, updatePlanMock
### Community 82 - "import-hold-resolution.test.tsx" ### Community 82 - "import-hold-resolution.test.tsx"
Cohesion: 0.25 Cohesion: 0.15
Nodes (5): ImportRow, getImportMock, resolvedHold, resolveHoldMock, unresolvedHold Nodes (11): ImportDetail, ImportRow, ImportSummary, confirmMock, detailFixture(), getImportMock, postedRow(), getImportMock (+3 more)
### Community 83 - "test_postgres_and_s3.py" ### Community 83 - "get_settings"
Cohesion: 0.17 Cohesion: 0.10
Nodes (7): BlobStore, get_blob_store(), Protocol, S3BlobStore, test_postgres_rls_requires_matching_transaction_context(), test_s3_blob_round_trip(), skipif 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" ### Community 84 - "session_scope"
Cohesion: 0.13 Cohesion: 0.31
Nodes (41): normalize_description(), date, record_fingerprint(), add_record(), _draft(), hold_document(), make_document(), date (+33 more) 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 ## 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) - **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; 429 node(s) total have ≤1 connection when file, concept and rationale nodes are included.) 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.)
- **17 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes. - **16 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions ## Suggested Questions
_Questions this graph is uniquely positioned to answer:_ _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`?** - **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.063) - this node is a cross-community bridge._ _High betweenness centrality (0.070) - 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`?** - **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.050) - this node is a cross-community bridge._ _High betweenness centrality (0.043) - 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`?** - **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.018) - this node is a cross-community bridge._ _High betweenness centrality (0.021) - this node is a cross-community bridge._
- **Are the 3 inferred relationships involving `Session` (e.g. with `upgrade()` and `UTCDateTime`) actually correct?** - **Are the 4 inferred relationships involving `Session` (e.g. with `upgrade()` and `upgrade()`) actually correct?**
_`Session` has 3 INFERRED edges - model-reasoned connections that need verification._ _`Session` has 4 INFERRED edges - model-reasoned connections that need verification._
- **Are the 40 inferred relationships involving `AppError` (e.g. with `request_export()` and `login()`) actually correct?** - **Are the 39 inferred relationships involving `AppError` (e.g. with `request_export()` and `login()`) actually correct?**
_`AppError` has 40 INFERRED edges - model-reasoned connections that need verification._ _`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?** - **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._ _`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?** - **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" "semantic_hash": "039081cab3d91704e8f8a7152a981304"
}, },
"backend/app/budgeting/router.py": { "backend/app/budgeting/router.py": {
"mtime": 1790097111.8510067, "mtime": 1790140570.7235522,
"seen": 1790129548.0529666, "seen": 1790140596.4645138,
"ast_hash": "64e5050629404acc7877b8489944f28f", "ast_hash": "5c8ea9e79dbe5450c2891c40931f0f4d",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/budgeting/service.py": { "backend/app/budgeting/service.py": {
"mtime": 1790129321.9786477, "mtime": 1790139967.779716,
"seen": 1790129548.052968, "seen": 1790140596.4645145,
"ast_hash": "9e7273a5b90092b3df2fc7eb89c7bde5", "ast_hash": "8b936c8c80b35123143fcc6bed627172",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/categorization/__init__.py": { "backend/app/categorization/__init__.py": {
@@ -84,15 +84,15 @@
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e" "semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
}, },
"backend/app/categorization/models.py": { "backend/app/categorization/models.py": {
"mtime": 1789130236.8679283, "mtime": 1790139315.3002875,
"seen": 1789377303.0691943, "seen": 1790140596.464517,
"ast_hash": "922083067aa2a24d67954296844db8f0", "ast_hash": "6f7ad97ad5760e889ff3bf6c2e23a9e3",
"semantic_hash": "922083067aa2a24d67954296844db8f0" "semantic_hash": ""
}, },
"backend/app/categorization/service.py": { "backend/app/categorization/service.py": {
"mtime": 1790060937.0384295, "mtime": 1790141279.1839979,
"seen": 1790096479.536434, "seen": 1790141340.3614173,
"ast_hash": "175c114e437a93f2c4deb76c7bd7df2c", "ast_hash": "91b8f18d6fd833a1b234b7b2f1685a88",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/config.py": { "backend/app/config.py": {
@@ -114,10 +114,10 @@
"semantic_hash": "ec981c0f87b0470519cca7727172a950" "semantic_hash": "ec981c0f87b0470519cca7727172a950"
}, },
"backend/app/db/models.py": { "backend/app/db/models.py": {
"mtime": 1789128813.706167, "mtime": 1790139321.140364,
"seen": 1789377303.0691986, "seen": 1790140596.4645212,
"ast_hash": "338516ce68785afd5a7fe37f06e1c56f", "ast_hash": "9dd2183a84d4b0346b8fec960af20eba",
"semantic_hash": "338516ce68785afd5a7fe37f06e1c56f" "semantic_hash": ""
}, },
"backend/app/db/session.py": { "backend/app/db/session.py": {
"mtime": 1789128374.1340652, "mtime": 1789128374.1340652,
@@ -210,9 +210,9 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/imports/adapters/alfa_xlsx.py": { "backend/app/imports/adapters/alfa_xlsx.py": {
"mtime": 1790061232.3863332, "mtime": 1790139655.0636957,
"seen": 1790096479.5364504, "seen": 1790140596.4645343,
"ast_hash": "6a3d3456eceef5ad3e2a79d2c43bcdc2", "ast_hash": "e1a47b79913cbb08cdda13e8feff04a1",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/imports/adapters/base.py": { "backend/app/imports/adapters/base.py": {
@@ -252,9 +252,9 @@
"semantic_hash": "c72d7849c6f46e6c9a9136f3ae9dc92f" "semantic_hash": "c72d7849c6f46e6c9a9136f3ae9dc92f"
}, },
"backend/app/imports/models.py": { "backend/app/imports/models.py": {
"mtime": 1789730695.7355492, "mtime": 1790138566.972772,
"seen": 1789735433.1581326, "seen": 1790140596.46454,
"ast_hash": "b99c93656a17603691838c9e7963d9ed", "ast_hash": "a82e93256d8c14f5e7be840d572262aa",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/imports/readers.py": { "backend/app/imports/readers.py": {
@@ -264,15 +264,15 @@
"semantic_hash": "0904afccafd9eb544b4d6846fc6384c8" "semantic_hash": "0904afccafd9eb544b4d6846fc6384c8"
}, },
"backend/app/imports/router.py": { "backend/app/imports/router.py": {
"mtime": 1790097243.6436644, "mtime": 1790141910.6661994,
"seen": 1790129548.052993, "seen": 1790142012.6081262,
"ast_hash": "5c3be5ef9ee835a376b463b59132ecef", "ast_hash": "cd3c86a3d0cbd157da465edab752836b",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/imports/service.py": { "backend/app/imports/service.py": {
"mtime": 1790097243.6436644, "mtime": 1790140440.0177455,
"seen": 1790129548.0529938, "seen": 1790140596.4645422,
"ast_hash": "2d46532c0c4294ac19adfbc4696506dd", "ast_hash": "d4afb66723127361ae78ad1e793cca7a",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/imports/tasks.py": { "backend/app/imports/tasks.py": {
@@ -300,9 +300,9 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/ledger/service.py": { "backend/app/ledger/service.py": {
"mtime": 1790077834.4411142, "mtime": 1790139917.4417093,
"seen": 1790096479.536463, "seen": 1790140596.4645474,
"ast_hash": "9d5895e92011706e835b582aca3878c8", "ast_hash": "a9d176c786237da53b3a2e6fb6d6b4e6",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/main.py": { "backend/app/main.py": {
@@ -342,9 +342,9 @@
"semantic_hash": "073fa39a355b257a832ead3830942d8f" "semantic_hash": "073fa39a355b257a832ead3830942d8f"
}, },
"backend/app/spaces/service.py": { "backend/app/spaces/service.py": {
"mtime": 1790060979.7579985, "mtime": 1790139400.2393951,
"seen": 1790096479.5364685, "seen": 1790140596.4645526,
"ast_hash": "214a4094630b761ea2a64de83d59317a", "ast_hash": "ae39850843111710abad98657c253f62",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/worker.py": { "backend/app/worker.py": {
@@ -354,9 +354,9 @@
"semantic_hash": "4788addc575411056226ee892d1e2806" "semantic_hash": "4788addc575411056226ee892d1e2806"
}, },
"backend/pyproject.toml": { "backend/pyproject.toml": {
"mtime": 1789731892.783645, "mtime": 1790139585.4503105,
"seen": 1789735433.1581438, "seen": 1790140596.464554,
"ast_hash": "6ae54b23d066a4a48b7edbbe964af75f", "ast_hash": "5ed77663858f2555b4989d2e295b331e",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/conftest.py": { "backend/tests/conftest.py": {
@@ -372,15 +372,15 @@
"semantic_hash": "bfec0778131788a91a0bc5c9ec2ad8ba" "semantic_hash": "bfec0778131788a91a0bc5c9ec2ad8ba"
}, },
"backend/tests/test_budget.py": { "backend/tests/test_budget.py": {
"mtime": 1789358107.2584474, "mtime": 1790141279.1849978,
"seen": 1789377303.0692346, "seen": 1790141340.361456,
"ast_hash": "f6e5c4d1e1d0f2b1941acf8ec9f6678b", "ast_hash": "81578f618d0758c7f9e2f00c921f2594",
"semantic_hash": "f6e5c4d1e1d0f2b1941acf8ec9f6678b" "semantic_hash": ""
}, },
"backend/tests/test_imports.py": { "backend/tests/test_imports.py": {
"mtime": 1789987559.2557845, "mtime": 1790140440.029933,
"seen": 1789987623.1673782, "seen": 1790140596.4645612,
"ast_hash": "775b6bb13c49cfd933fdfe981142dc41", "ast_hash": "9e568b07fa4d77eb55c36ef748fb0f9b",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_ledger_properties.py": { "backend/tests/test_ledger_properties.py": {
@@ -444,9 +444,9 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/api/types.ts": { "frontend/src/api/types.ts": {
"mtime": 1790097111.8510067, "mtime": 1790140175.05337,
"seen": 1790129548.0530243, "seen": 1790140596.4645727,
"ast_hash": "744887bcd7ef47a37a6899108a5fcbc0", "ast_hash": "7a2c079c721ef1966d181492a4624686",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/app/router.tsx": { "frontend/src/app/router.tsx": {
@@ -576,9 +576,9 @@
"semantic_hash": "40f9f47b108ec0b484f9564bd1d319cb" "semantic_hash": "40f9f47b108ec0b484f9564bd1d319cb"
}, },
"frontend/src/hooks/useBudget.ts": { "frontend/src/hooks/useBudget.ts": {
"mtime": 1790077001.481125, "mtime": 1790140175.05337,
"seen": 1790096479.5365117, "seen": 1790140596.464592,
"ast_hash": "2ab1d3e626a5771c11c6f35b39d9055f", "ast_hash": "ffe50957304981998e7b92838ae15bdb",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/hooks/useImports.ts": { "frontend/src/hooks/useImports.ts": {
@@ -606,15 +606,15 @@
"semantic_hash": "243512f6e095deeaa29393b7c831777f" "semantic_hash": "243512f6e095deeaa29393b7c831777f"
}, },
"frontend/src/i18n/locales/en.json": { "frontend/src/i18n/locales/en.json": {
"mtime": 1790096831.314309, "mtime": 1790141684.2632692,
"seen": 1790129548.0530467, "seen": 1790142012.6081812,
"ast_hash": "2d62cdde2e726baf5e6b9627d58372ec", "ast_hash": "7fb00b4761bc40813a3c81cc9346d92e",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/i18n/locales/ru.json": { "frontend/src/i18n/locales/ru.json": {
"mtime": 1790096831.3153088, "mtime": 1790141684.2632692,
"seen": 1790129548.0530472, "seen": 1790142012.6081817,
"ast_hash": "aebf731fc4a592f2f20abd2d309e9a97", "ast_hash": "d82c9fcf5dec8f9d417ee4ead2e2825f",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/main.tsx": { "frontend/src/main.tsx": {
@@ -648,9 +648,9 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/pages/ImportDetailPage.tsx": { "frontend/src/pages/ImportDetailPage.tsx": {
"mtime": 1790077605.334677, "mtime": 1790138988.0589676,
"seen": 1790096479.5365288, "seen": 1790140596.4646058,
"ast_hash": "75923869914b7ef53b939676f9d5f095", "ast_hash": "38dc8d5253cf9622ec266804a9e907ef",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/pages/ImportsPage.tsx": { "frontend/src/pages/ImportsPage.tsx": {
@@ -792,10 +792,10 @@
"semantic_hash": "c8211b75ee8dfba83e37f06ff219d2b1" "semantic_hash": "c8211b75ee8dfba83e37f06ff219d2b1"
}, },
"README.md": { "README.md": {
"mtime": 1789358273.3585796, "mtime": 1790139585.4512253,
"seen": 1789377303.0692983, "seen": 1790140596.4646337,
"ast_hash": "79eb6002a05286e6655e6fe3904f5b29", "ast_hash": "a4502b2cc4571860844ff035d31e5798",
"semantic_hash": "79eb6002a05286e6655e6fe3904f5b29" "semantic_hash": ""
}, },
"docs/adr/0001-financial-space-ownership-and-rls.md": { "docs/adr/0001-financial-space-ownership-and-rls.md": {
"mtime": 1789126779.7591345, "mtime": 1789126779.7591345,
@@ -858,10 +858,10 @@
"semantic_hash": "42788af442bf05e487937f435141f84b" "semantic_hash": "42788af442bf05e487937f435141f84b"
}, },
"infra/README.md": { "infra/README.md": {
"mtime": 1789357215.8087454, "mtime": 1790139585.4515448,
"seen": 1789377303.0693076, "seen": 1790140596.4646425,
"ast_hash": "0a7bb82cce04631f36f2d561a1665478", "ast_hash": "b4aa0d817fb75341937b44526cb943f6",
"semantic_hash": "0a7bb82cce04631f36f2d561a1665478" "semantic_hash": ""
}, },
"infra/docker-compose.yml": { "infra/docker-compose.yml": {
"mtime": 1790067636.943902, "mtime": 1790067636.943902,
@@ -894,9 +894,9 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_import_holds.py": { "backend/tests/test_import_holds.py": {
"mtime": 1789736201.2172022, "mtime": 1790139806.356644,
"seen": 1789736272.9455423, "seen": 1790140596.4645593,
"ast_hash": "6f14226a83e65efbaaee6446d4ec4cb6", "ast_hash": "f483eaf3c70604ee3e931958f0b9736b",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_private_fixtures.py": { "backend/tests/test_private_fixtures.py": {
@@ -906,9 +906,9 @@
"semantic_hash": "" "semantic_hash": ""
}, },
".gitea/workflows/ci.yml": { ".gitea/workflows/ci.yml": {
"mtime": 1789736949.8123386, "mtime": 1790139585.4503105,
"seen": 1789737916.791613, "seen": 1790140596.464629,
"ast_hash": "f0891b841c5e24c761f5067a4ea23637", "ast_hash": "f06ce2f92ce51151ee2edf6879038f2e",
"semantic_hash": "" "semantic_hash": ""
}, },
".kilo/plans/1789727434954-gitea-ci-real-statement-imports.md": { ".kilo/plans/1789727434954-gitea-ci-real-statement-imports.md": {
@@ -960,15 +960,15 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_budget_savings.py": { "backend/tests/test_budget_savings.py": {
"mtime": 1790077946.050938, "mtime": 1790140489.3004284,
"seen": 1790096479.5364738, "seen": 1790140596.464558,
"ast_hash": "47280e68387aee190b930ac53ac79e93", "ast_hash": "dba98c2706a6cdbbf988336ac51d248a",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_category_definitions.py": { "backend/tests/test_category_definitions.py": {
"mtime": 1790078212.0062637, "mtime": 1790141779.8555074,
"seen": 1790096479.5364745, "seen": 1790142012.6081438,
"ast_hash": "4d16f9374862cea5f66bec35ea4ffec9", "ast_hash": "f2b6219d9e5687ddee8ecc85093447cb",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_import_savings.py": { "backend/tests/test_import_savings.py": {
@@ -990,9 +990,9 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/components/budget/CategoryManagerModal.tsx": { "frontend/src/components/budget/CategoryManagerModal.tsx": {
"mtime": 1790078043.7267377, "mtime": 1790141684.261269,
"seen": 1790096479.5364919, "seen": 1790142012.6081603,
"ast_hash": "72a373612048b42b68405b965daa3153", "ast_hash": "3320a39025aec0d67df0a5f8a4bf6d4b",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/lib/accountLabel.ts": { "frontend/src/lib/accountLabel.ts": {
@@ -1056,15 +1056,15 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/test/category-manager.test.tsx": { "frontend/src/test/category-manager.test.tsx": {
"mtime": 1790078889.2937336, "mtime": 1790141753.6681683,
"seen": 1790096479.5365384, "seen": 1790142012.6082017,
"ast_hash": "e32016dfb312c6fd11e9b485b9499798", "ast_hash": "9d2f35f1471a69a6a73d1a55cf81cb2a",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/test/import-hold-resolution.test.tsx": { "frontend/src/test/import-hold-resolution.test.tsx": {
"mtime": 1790078821.0050712, "mtime": 1790138988.0599678,
"seen": 1790096479.5365412, "seen": 1790140596.4646187,
"ast_hash": "8d46975e4f292e69d2531486578b0e1e", "ast_hash": "817410814a07e97b717b74b39cbe438d",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/test/review-queue-month.test.tsx": { "frontend/src/test/review-queue-month.test.tsx": {
@@ -1092,9 +1092,33 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_savings_migration.py": { "backend/tests/test_savings_migration.py": {
"mtime": 1790129378.2103765, "mtime": 1790141939.6325738,
"seen": 1790129548.053016, "seen": 1790142012.6081502,
"ast_hash": "9ada17014ccdef9572af77bb00f2590b", "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": "" "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: through both sides:
**Backend** (`backend/`, build context of `Dockerfile.backend`): **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`. - API: `uvicorn app.main:app`, health endpoint `GET /healthz`.
- Worker: Celery app importable as `app.worker.celery`; tasks idempotent, `acks_late`, `prefetch=1`. - 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 - Statement parsing happens **only** in the worker; the local compose worker is attached only