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:
@@ -83,8 +83,8 @@ jobs:
|
||||
run: uv run ruff check .
|
||||
- name: Format (ruff format)
|
||||
run: uv run ruff format --check .
|
||||
- name: Type check (mypy)
|
||||
run: uv run mypy .
|
||||
- name: Type check (ty)
|
||||
run: uv run ty check
|
||||
- name: Create restricted app role and migrate
|
||||
run: |
|
||||
uv run python -c "import os; from psycopg import sql; from sqlalchemy import create_engine,text; e=create_engine(os.environ['DATABASE_URL']); c=e.connect(); raw=c.connection.driver_connection; role=sql.SQL('CREATE ROLE finance_app_test LOGIN PASSWORD {} NOSUPERUSER NOBYPASSRLS').format(sql.Literal(os.environ['CI_RLS_DATABASE_PASSWORD'])).as_string(raw); c.exec_driver_sql(role); c.execute(text('GRANT CONNECT ON DATABASE finance_test TO finance_app_test')); c.execute(text('GRANT USAGE ON SCHEMA public TO finance_app_test')); c.execute(text('ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO finance_app_test')); c.commit(); c.close(); e.dispose()"
|
||||
|
||||
@@ -30,7 +30,7 @@ docker compose --env-file infra/.env -f infra/docker-compose.yml run --rm migrat
|
||||
```bash
|
||||
uv sync --project backend --all-groups --locked
|
||||
uv run --project backend ruff check backend
|
||||
uv run --project backend mypy --config-file backend/pyproject.toml backend/app
|
||||
uv run --project backend ty check --project backend
|
||||
uv run --project backend pytest backend
|
||||
|
||||
corepack pnpm --dir frontend install --frozen-lockfile
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""MCC dictionary tables, category MCC links and default category seeding."""
|
||||
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0004"
|
||||
down_revision = "0003"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TENANT_TABLE = "category_mcc_codes"
|
||||
GLOBAL_TABLES = ("mcc_codes", "bank_category_mappings")
|
||||
|
||||
|
||||
def _has_table(bind, table: str) -> bool:
|
||||
return sa.inspect(bind).has_table(table)
|
||||
|
||||
|
||||
def _space_ids(bind) -> list[str]:
|
||||
return [str(row[0]) for row in bind.execute(sa.text("SELECT id FROM financial_spaces"))]
|
||||
|
||||
|
||||
def _create_tenant_policy(table: str) -> None:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
|
||||
op.execute(
|
||||
f"CREATE POLICY tenant_isolation ON {table} "
|
||||
"USING (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid) "
|
||||
"WITH CHECK (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid)"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
# Revision 0001 materializes the schema from live model metadata, so a
|
||||
# database created fresh already carries these objects; guard every step.
|
||||
if not _has_table(bind, "mcc_codes"):
|
||||
op.create_table(
|
||||
"mcc_codes",
|
||||
sa.Column("code", sa.Integer(), nullable=False),
|
||||
sa.Column("title", sa.String(length=300), nullable=False),
|
||||
sa.Column("dictionary_version", sa.Integer(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("code", name="pk_mcc_codes"),
|
||||
)
|
||||
if not _has_table(bind, "bank_category_mappings"):
|
||||
op.create_table(
|
||||
"bank_category_mappings",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("bank_code", sa.String(length=32), nullable=False),
|
||||
sa.Column("bank_category", sa.String(length=200), nullable=False),
|
||||
sa.Column("mcc_code", sa.Integer(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id", name="pk_bank_category_mappings"),
|
||||
sa.UniqueConstraint(
|
||||
"bank_code", "bank_category", name="uq_bank_mappings_bank_category"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_bank_category_mappings_bank_code",
|
||||
"bank_category_mappings",
|
||||
["bank_code"],
|
||||
)
|
||||
if not _has_table(bind, TENANT_TABLE):
|
||||
op.create_table(
|
||||
TENANT_TABLE,
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("space_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("category_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("mcc_code", sa.Integer(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="pk_category_mcc_codes"),
|
||||
sa.UniqueConstraint("space_id", "mcc_code", name="uq_category_mcc_space_code"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["space_id", "category_id"],
|
||||
["categories.space_id", "categories.id"],
|
||||
ondelete="CASCADE",
|
||||
name="fk_category_mcc_category_tenant",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["mcc_code"],
|
||||
["mcc_codes.code"],
|
||||
ondelete="RESTRICT",
|
||||
name="fk_category_mcc_dictionary",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_category_mcc_codes_space_id", TENANT_TABLE, ["space_id"])
|
||||
op.create_index("ix_category_mcc_codes_category_id", TENANT_TABLE, ["category_id"])
|
||||
if bind.dialect.name == "postgresql":
|
||||
_create_tenant_policy(TENANT_TABLE)
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.categorization.service import ensure_default_categories, ensure_mcc_dictionary
|
||||
from app.db.session import set_space_context
|
||||
|
||||
session = Session(bind=bind)
|
||||
try:
|
||||
ensure_mcc_dictionary(session)
|
||||
for space_id in _space_ids(bind):
|
||||
set_space_context(session, space_id)
|
||||
ensure_default_categories(session, space_id=uuid.UUID(hex=space_id))
|
||||
session.flush()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
keys = _default_keys()
|
||||
if inspector.has_table("categories"):
|
||||
dependent = (
|
||||
("operations", "operations o JOIN categories c ON c.id = o.category_id"),
|
||||
("funding movements", "funding_movements f JOIN categories c ON c.id = f.category_id"),
|
||||
("plan assignments", "plan_assignments p JOIN categories c ON c.id = p.category_id"),
|
||||
(
|
||||
"categorization rules",
|
||||
"categorization_rules r JOIN categories c ON c.id = r.category_id",
|
||||
),
|
||||
)
|
||||
blocked_by = []
|
||||
for label, join in dependent:
|
||||
query = f"SELECT 1 FROM {join} WHERE c.system_key IN :keys LIMIT 1"
|
||||
if not inspector.has_table(join.split()[0]):
|
||||
continue
|
||||
stmt = sa.text(query).bindparams(sa.bindparam("keys", expanding=True))
|
||||
if bind.execute(stmt, {"keys": keys}).first() is not None:
|
||||
blocked_by.append(label)
|
||||
if blocked_by:
|
||||
raise RuntimeError(
|
||||
"Cannot downgrade MCC migration while default categories hold history: "
|
||||
+ ", ".join(blocked_by)
|
||||
)
|
||||
bind.execute(
|
||||
sa.text("DELETE FROM categories WHERE system_key IN :keys").bindparams(
|
||||
sa.bindparam("keys", expanding=True)
|
||||
),
|
||||
{"keys": keys},
|
||||
)
|
||||
if _has_table(bind, TENANT_TABLE):
|
||||
op.drop_table(TENANT_TABLE)
|
||||
|
||||
|
||||
def _default_keys() -> list[str]:
|
||||
from app.categorization.mcc import default_category_system_keys
|
||||
|
||||
return list(default_category_system_keys())
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Allow manual HOLD resolutions without a reason."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0005"
|
||||
down_revision = "0004"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _reason_constraint_name() -> str | None:
|
||||
return next(
|
||||
(
|
||||
name
|
||||
for item in sa.inspect(op.get_bind()).get_check_constraints("hold_resolutions")
|
||||
if (name := item.get("name")) and name.endswith("manual_reason_required")
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if name := _reason_constraint_name():
|
||||
with op.batch_alter_table("hold_resolutions") as batch_op:
|
||||
batch_op.drop_constraint(op.f(name), type_="check")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _reason_constraint_name():
|
||||
return
|
||||
bind = op.get_bind()
|
||||
missing_reason = bind.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM hold_resolutions WHERE resolution IN "
|
||||
"('confirmed_as_posted', 'discarded_as_cancelled') "
|
||||
"AND (reason IS NULL OR reason = '') LIMIT 1"
|
||||
)
|
||||
).first()
|
||||
if missing_reason:
|
||||
raise RuntimeError("Cannot require reasons while reasonless HOLD resolutions exist")
|
||||
with op.batch_alter_table("hold_resolutions") as batch_op:
|
||||
batch_op.create_check_constraint(
|
||||
op.f("ck_hold_resolutions_manual_reason_required"),
|
||||
"NOT (resolution IN ('confirmed_as_posted', 'discarded_as_cancelled') "
|
||||
"AND (reason IS NULL OR reason = ''))",
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
from typing import Annotated, Literal
|
||||
|
||||
@@ -7,6 +8,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.budgeting import service
|
||||
from app.categorization import service as categorization
|
||||
from app.categorization.models import CategoryMcc
|
||||
from app.dependencies import Context, Csrf, Db
|
||||
|
||||
router = APIRouter(prefix="/budget", tags=["budget"])
|
||||
@@ -15,6 +17,7 @@ router = APIRouter(prefix="/budget", tags=["budget"])
|
||||
class CategoryBody(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
kind: Literal["monthly", "savings_envelope"] = "monthly"
|
||||
mcc_codes: list[int] = Field(default_factory=list, alias="mccCodes")
|
||||
|
||||
|
||||
class AssignmentItem(BaseModel):
|
||||
@@ -144,7 +147,7 @@ def categories(month: str, db: Db, context: Context) -> list[dict]:
|
||||
return result
|
||||
|
||||
|
||||
def category_definition_json(category) -> dict:
|
||||
def category_definition_json(category, mcc_codes: list[int]) -> dict:
|
||||
return {
|
||||
"id": category.id,
|
||||
"name": categorization.resolve_category_name(category),
|
||||
@@ -153,6 +156,7 @@ def category_definition_json(category) -> dict:
|
||||
"systemKey": category.system_key,
|
||||
"linkedAccountId": category.linked_account_id,
|
||||
"archived": category.archived,
|
||||
"mccCodes": mcc_codes,
|
||||
}
|
||||
|
||||
|
||||
@@ -165,12 +169,20 @@ def category_definitions(
|
||||
items = service.list_categories(
|
||||
db, space_id=context.space.id, include_archived=include_archived
|
||||
)
|
||||
return [category_definition_json(item) for item in items]
|
||||
codes_by_category: dict[uuid.UUID, list[int]] = defaultdict(list)
|
||||
for category_id, code in (
|
||||
db.query(CategoryMcc.category_id, CategoryMcc.mcc_code)
|
||||
.filter_by(space_id=context.space.id)
|
||||
.order_by(CategoryMcc.mcc_code)
|
||||
):
|
||||
codes_by_category[category_id].append(code)
|
||||
return [category_definition_json(item, codes_by_category[item.id]) for item in items]
|
||||
|
||||
|
||||
class CategoryDefinitionPatchBody(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
archived: bool | None = None
|
||||
mcc_codes: list[int] | None = Field(default=None, alias="mccCodes")
|
||||
|
||||
|
||||
@router.patch("/category-definitions/{category_id}")
|
||||
@@ -181,6 +193,8 @@ def patch_category_definition(
|
||||
context: Context,
|
||||
_csrf: Csrf,
|
||||
) -> 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(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
@@ -189,7 +203,19 @@ def patch_category_definition(
|
||||
name=body.name,
|
||||
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)
|
||||
@@ -201,7 +227,19 @@ def create_category(body: CategoryBody, db: Db, context: Context, _csrf: Csrf) -
|
||||
kind=body.kind,
|
||||
name=body.name,
|
||||
)
|
||||
return category_definition_json(category)
|
||||
if body.mcc_codes:
|
||||
categorization.set_category_mcc_codes(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
category_id=category.id,
|
||||
codes=body.mcc_codes,
|
||||
)
|
||||
return category_definition_json(
|
||||
category,
|
||||
categorization.get_category_mcc_codes(
|
||||
db, space_id=context.space.id, category_id=category.id
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/categories/{category_id}")
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.budgeting.models import (
|
||||
PeriodClosure,
|
||||
PlanAssignment,
|
||||
)
|
||||
from app.categorization import mcc
|
||||
from app.categorization.service import resolve_category_name
|
||||
from app.errors import bad_request, conflict, not_found
|
||||
from app.identity.models import User
|
||||
@@ -351,6 +352,7 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri
|
||||
"category_id": str(cat.id),
|
||||
"name": resolve_category_name(cat),
|
||||
"name_key": cat.system_key,
|
||||
"system": not mcc.is_budgetable_category_key(cat.system_key),
|
||||
"archived": cat.archived,
|
||||
"planned": 0,
|
||||
"funded": funded_period[cat.id],
|
||||
@@ -681,7 +683,7 @@ def set_assignments(
|
||||
result = []
|
||||
for item in items:
|
||||
category = get_category(db, space_id=space_id, category_id=item["category_id"])
|
||||
if category.system_key is not None:
|
||||
if not mcc.is_budgetable_category_key(category.system_key):
|
||||
raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be planned")
|
||||
if category.archived:
|
||||
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived")
|
||||
@@ -800,7 +802,7 @@ def fund(
|
||||
if amount_kopecks <= 0:
|
||||
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
|
||||
category = get_category(db, space_id=space_id, category_id=category_id)
|
||||
if category.system_key is not None:
|
||||
if not mcc.is_budgetable_category_key(category.system_key):
|
||||
raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be funded")
|
||||
if category.archived:
|
||||
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived")
|
||||
|
||||
@@ -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()
|
||||
@@ -100,6 +100,34 @@ class MccCode(Base):
|
||||
dictionary_version: Mapped[int] = mapped_column(nullable=False, default=1)
|
||||
|
||||
|
||||
class CategoryMcc(Base, PkMixin):
|
||||
"""Space-scoped MCC codes of a category: many codes per category, but every
|
||||
code maps to at most one category inside the space."""
|
||||
|
||||
__tablename__ = "category_mcc_codes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("space_id", "mcc_code", name="uq_category_mcc_space_code"),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "category_id"],
|
||||
["categories.space_id", "categories.id"],
|
||||
ondelete="CASCADE",
|
||||
name="fk_category_mcc_category_tenant",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["mcc_code"],
|
||||
["mcc_codes.code"],
|
||||
ondelete="RESTRICT",
|
||||
name="fk_category_mcc_dictionary",
|
||||
),
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
category_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
|
||||
mcc_code: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
|
||||
class BankCategoryMapping(Base, PkMixin):
|
||||
"""Maps a bank's raw category string to an MCC for suggestion purposes."""
|
||||
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import uuid
|
||||
from collections.abc import Iterable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.categorization.models import CategorizationRule, IncomeSource, Payee, PayeeAlias
|
||||
from app.categorization import mcc
|
||||
from app.categorization.models import (
|
||||
CategorizationRule,
|
||||
CategoryMcc,
|
||||
IncomeSource,
|
||||
MccCode,
|
||||
Payee,
|
||||
PayeeAlias,
|
||||
)
|
||||
from app.errors import conflict, not_found
|
||||
from app.ledger.models import Category
|
||||
|
||||
@@ -102,3 +113,143 @@ def set_payee_rule(db: Session, *, space_id, payee_id, category_id, user_id) ->
|
||||
rule.category_id = category_id
|
||||
db.flush()
|
||||
return rule
|
||||
|
||||
|
||||
def ensure_mcc_dictionary(db: Session) -> None:
|
||||
"""Idempotently seed the global ``mcc_codes`` table from iso18245."""
|
||||
existing = {code for (code,) in db.query(MccCode.code).all()}
|
||||
for code, title in mcc.iter_mcc_dictionary():
|
||||
if code in existing:
|
||||
continue
|
||||
db.add(MccCode(code=code, title=title[:300], dictionary_version=mcc.MCC_DICTIONARY_VERSION))
|
||||
db.flush()
|
||||
|
||||
|
||||
def ensure_default_categories(db: Session, *, space_id: uuid.UUID) -> dict[str, Category]:
|
||||
"""Idempotently pre-create the 20 popular categories plus «Остальное».
|
||||
|
||||
Each default category is keyed by ``system_key`` and carries its MCC code
|
||||
links; every link is only created while its MCC code is unclaimed inside
|
||||
the space, so user re-mappings are never overwritten.
|
||||
"""
|
||||
ensure_mcc_dictionary(db)
|
||||
keys = mcc.default_category_system_keys()
|
||||
existing: dict[str, Category] = {
|
||||
category.system_key: category
|
||||
for category in db.query(Category)
|
||||
.filter(Category.space_id == space_id, Category.system_key.in_(keys))
|
||||
.all()
|
||||
if category.system_key is not None
|
||||
}
|
||||
result: dict[str, Category] = {}
|
||||
claimed = {
|
||||
code for (code,) in db.query(CategoryMcc.mcc_code).filter_by(space_id=space_id).all()
|
||||
}
|
||||
for definition in mcc.default_categories():
|
||||
category = existing.get(definition.system_key)
|
||||
if category is None:
|
||||
category = Category(
|
||||
space_id=space_id,
|
||||
kind="monthly",
|
||||
system_key=definition.system_key,
|
||||
custom_name=definition.name,
|
||||
)
|
||||
db.add(category)
|
||||
db.flush()
|
||||
for code in definition.mcc_codes:
|
||||
if code in claimed:
|
||||
continue
|
||||
db.add(CategoryMcc(space_id=space_id, category_id=category.id, mcc_code=code))
|
||||
claimed.add(code)
|
||||
result[definition.system_key] = category
|
||||
others = existing.get(mcc.OTHERS_SYSTEM_KEY)
|
||||
if others is None:
|
||||
others = Category(
|
||||
space_id=space_id,
|
||||
kind="monthly",
|
||||
system_key=mcc.OTHERS_SYSTEM_KEY,
|
||||
custom_name=mcc.OTHERS_CATEGORY_NAME,
|
||||
)
|
||||
db.add(others)
|
||||
db.flush()
|
||||
result[mcc.OTHERS_SYSTEM_KEY] = others
|
||||
db.flush()
|
||||
return result
|
||||
|
||||
|
||||
def category_for_mcc(db: Session, *, space_id: uuid.UUID, code: int) -> Category | None:
|
||||
"""Category owning the MCC code, else the system «Остальное» fallback."""
|
||||
link = db.query(CategoryMcc).filter_by(space_id=space_id, mcc_code=code).one_or_none()
|
||||
if link is not None:
|
||||
category = db.get(Category, link.category_id)
|
||||
if category is not None and not category.archived:
|
||||
return category
|
||||
return (
|
||||
db.query(Category)
|
||||
.filter_by(space_id=space_id, system_key=mcc.OTHERS_SYSTEM_KEY)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
|
||||
def get_category_mcc_codes(
|
||||
db: Session, *, space_id: uuid.UUID, category_id: uuid.UUID
|
||||
) -> list[int]:
|
||||
codes = (
|
||||
db.query(CategoryMcc.mcc_code)
|
||||
.filter_by(space_id=space_id, category_id=category_id)
|
||||
.order_by(CategoryMcc.mcc_code)
|
||||
.all()
|
||||
)
|
||||
return [code for (code,) in codes]
|
||||
|
||||
|
||||
def set_category_mcc_codes(
|
||||
db: Session,
|
||||
*,
|
||||
space_id: uuid.UUID,
|
||||
category_id: uuid.UUID,
|
||||
codes: Iterable[int],
|
||||
) -> list[int]:
|
||||
"""Replace the category's MCC code set; codes are unique per space."""
|
||||
category = db.query(Category).filter_by(id=category_id, space_id=space_id).one_or_none()
|
||||
if category is None:
|
||||
raise not_found("BUDGET_CATEGORY_NOT_FOUND", "Category not found")
|
||||
wanted = sorted({int(code) for code in codes})
|
||||
if wanted and category.archived:
|
||||
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Archived categories cannot have MCC codes")
|
||||
if wanted and category.kind != "monthly":
|
||||
raise conflict("CATEGORY_MCC_KIND_INVALID", "Only monthly categories can have MCC codes")
|
||||
for code in wanted:
|
||||
if db.get(MccCode, code) is None:
|
||||
raise not_found("MCC_CODE_NOT_FOUND", f"Unknown MCC code {code}")
|
||||
current = {
|
||||
row for row in db.query(CategoryMcc).filter_by(space_id=space_id, category_id=category_id)
|
||||
}
|
||||
wanted_set = set(wanted)
|
||||
for row in current:
|
||||
if row.mcc_code not in wanted_set:
|
||||
db.delete(row)
|
||||
owned_elsewhere = {
|
||||
code
|
||||
for (code,) in (
|
||||
db.query(CategoryMcc.mcc_code)
|
||||
.filter(
|
||||
CategoryMcc.space_id == space_id,
|
||||
CategoryMcc.category_id != category_id,
|
||||
CategoryMcc.mcc_code.in_(wanted_set),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
}
|
||||
if owned_elsewhere:
|
||||
raise conflict(
|
||||
"CATEGORY_MCC_TAKEN",
|
||||
"MCC codes already mapped to another category",
|
||||
details={"mcc_codes": sorted(owned_elsewhere)},
|
||||
)
|
||||
current_codes = {row.mcc_code for row in current}
|
||||
for code in wanted:
|
||||
if code not in current_codes:
|
||||
db.add(CategoryMcc(space_id=space_id, category_id=category_id, mcc_code=code))
|
||||
db.flush()
|
||||
return wanted
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.budgeting.models import (
|
||||
from app.categorization.models import (
|
||||
BankCategoryMapping,
|
||||
CategorizationRule,
|
||||
CategoryMcc,
|
||||
IncomeSource,
|
||||
MccCode,
|
||||
Payee,
|
||||
@@ -35,6 +36,7 @@ __all__ = [
|
||||
"BankCategoryMapping",
|
||||
"BudgetPeriod",
|
||||
"Category",
|
||||
"CategoryMcc",
|
||||
"CategorizationRule",
|
||||
"DataExport",
|
||||
"DedupCandidate",
|
||||
|
||||
@@ -214,9 +214,9 @@ def _find_header(rows: list[tuple]) -> tuple[int, dict[str, int]]:
|
||||
("category", CATEGORY_HEADERS),
|
||||
("external", ("id операции", "номер операции")),
|
||||
):
|
||||
columns[key] = next( # type: ignore[assignment]
|
||||
(i for i, c in enumerate(cells) if c in synonyms), None
|
||||
)
|
||||
optional_col = next((i for i, c in enumerate(cells) if c in synonyms), None)
|
||||
if optional_col is not None:
|
||||
columns[key] = optional_col
|
||||
return index, columns
|
||||
raise UnsupportedStatementLayout("statement header row with date/amount columns not found")
|
||||
|
||||
|
||||
@@ -222,7 +222,7 @@ class HoldResolution(Base, PkMixin, TimestampMixin):
|
||||
The extracted HOLD row itself is never mutated. At most one resolution
|
||||
exists per hold source record; automatic matches (`matched_posted`) are
|
||||
created only when a later statement is confirmed, and manual actions
|
||||
(`confirmed_as_posted`, `discarded_as_cancelled`) always carry a reason.
|
||||
(`confirmed_as_posted`, `discarded_as_cancelled`) may carry a reason.
|
||||
"""
|
||||
|
||||
__tablename__ = "hold_resolutions"
|
||||
@@ -231,11 +231,6 @@ class HoldResolution(Base, PkMixin, TimestampMixin):
|
||||
"resolution IN ('matched_posted', 'confirmed_as_posted', 'discarded_as_cancelled')",
|
||||
name="resolution_valid",
|
||||
),
|
||||
CheckConstraint(
|
||||
"NOT (resolution IN ('confirmed_as_posted', 'discarded_as_cancelled') "
|
||||
"AND (reason IS NULL OR reason = ''))",
|
||||
name="manual_reason_required",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "hold_source_record_id"],
|
||||
["source_records.space_id", "source_records.id"],
|
||||
|
||||
@@ -30,14 +30,13 @@ class ConfirmBody(BaseModel):
|
||||
confirmed_without_full_reconciliation: bool = Field(
|
||||
default=False, alias="confirmedWithoutFullReconciliation"
|
||||
)
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class HoldResolveBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
action: str
|
||||
reason: str
|
||||
reason: str | None = None
|
||||
account_id: uuid.UUID | None = Field(default=None, alias="accountId")
|
||||
|
||||
|
||||
@@ -160,6 +159,8 @@ def detail(document_id: uuid.UUID, db: Db, context: Context) -> dict:
|
||||
"direction": item["direction"],
|
||||
"bankStatus": item.get("bank_status", "posted"),
|
||||
"description": item["description"],
|
||||
"mcc": item["mcc"],
|
||||
"mccName": item["mcc_name"],
|
||||
"payeeHint": None,
|
||||
"warnings": (item["warnings"] or {}).get("codes", []),
|
||||
"decision": "pending"
|
||||
@@ -306,7 +307,7 @@ def confirm(
|
||||
document_id=document_id,
|
||||
account_id=document.account_id,
|
||||
confirmed_without_full_reconciliation=True,
|
||||
reason=body.reason,
|
||||
reason=None,
|
||||
)
|
||||
document = service.confirm_document(
|
||||
db, space_id=context.space.id, document_id=document_id, user=context.user
|
||||
|
||||
@@ -9,6 +9,9 @@ from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.audit import service as audit
|
||||
from app.categorization import mcc
|
||||
from app.categorization import service as categorization
|
||||
from app.categorization.models import MccCode
|
||||
from app.config import get_settings
|
||||
from app.db.base import utcnow
|
||||
from app.db.session import session_scope
|
||||
@@ -169,6 +172,15 @@ def _source_from_row(
|
||||
)
|
||||
|
||||
|
||||
def _category_id_for_source(
|
||||
db: Session, *, space_id: uuid.UUID, source: SourceRecord
|
||||
) -> uuid.UUID | None:
|
||||
if source.mcc is None:
|
||||
return None
|
||||
category = categorization.category_for_mcc(db, space_id=space_id, code=source.mcc)
|
||||
return category.id if category is not None else None
|
||||
|
||||
|
||||
def _reconciliation_summary(draft: Any) -> dict[str, Any]:
|
||||
"""Reconcile parsed rows against declared bank controls with stable diagnostics.
|
||||
|
||||
@@ -471,7 +483,8 @@ def process_statement_document(document_id: uuid.UUID, space_id: uuid.UUID) -> s
|
||||
document.adapter_id = adapter.adapter_id
|
||||
document.account_fingerprint = draft.account_fingerprint
|
||||
document.active_parse_run_id = run.id
|
||||
document.reconciliation = _reconciliation_summary(draft)
|
||||
reconciliation = _reconciliation_summary(draft)
|
||||
document.reconciliation = reconciliation
|
||||
if document.account_id is None and draft.account_fingerprint:
|
||||
matches = (
|
||||
db.query(Account)
|
||||
@@ -492,14 +505,14 @@ def process_statement_document(document_id: uuid.UUID, space_id: uuid.UUID) -> s
|
||||
db.query(SourceRecord.id).filter_by(parse_run_id=run.id, critical=True).first()
|
||||
)
|
||||
document.needs_account_mapping = document.account_id is None
|
||||
needs_manual_reconciliation = not document.reconciliation["full"]
|
||||
needs_manual_reconciliation = not reconciliation["full"]
|
||||
document.status = (
|
||||
"review_required"
|
||||
if critical
|
||||
or document.needs_account_mapping
|
||||
or needs_manual_reconciliation
|
||||
or has_candidates
|
||||
or document.reconciliation.get("matched") is False
|
||||
or reconciliation.get("matched") is False
|
||||
else "ready"
|
||||
)
|
||||
except AppError as exc:
|
||||
@@ -601,6 +614,15 @@ def document_detail(db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID)
|
||||
.all()
|
||||
)
|
||||
record_ids = [record.id for record in records]
|
||||
codes = {record.mcc for record in records if record.mcc is not None}
|
||||
mcc_names: dict[int, str] = {
|
||||
code: title
|
||||
for code, title in (
|
||||
db.query(MccCode.code, MccCode.title).filter(MccCode.code.in_(codes)).all()
|
||||
if codes
|
||||
else []
|
||||
)
|
||||
}
|
||||
resolutions: dict[uuid.UUID, dict[str, Any]] = {}
|
||||
candidates: dict[uuid.UUID, list[dict[str, Any]]] = {}
|
||||
if record_ids:
|
||||
@@ -656,6 +678,9 @@ def document_detail(db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID)
|
||||
"direction": item.direction,
|
||||
"bank_status": item.bank_status,
|
||||
"description": item.description,
|
||||
"mcc": item.mcc,
|
||||
"mcc_name": mcc_names.get(item.mcc)
|
||||
or (mcc.russian_title(item.mcc) if item.mcc is not None else None),
|
||||
"critical": item.critical,
|
||||
"warnings": item.field_warnings,
|
||||
"review_decision": item.review_decision,
|
||||
@@ -755,7 +780,7 @@ def set_review_confirmation(
|
||||
document_id: uuid.UUID,
|
||||
account_id: uuid.UUID,
|
||||
confirmed_without_full_reconciliation: bool,
|
||||
reason: str | None,
|
||||
reason: str | None = None,
|
||||
) -> StatementDocument:
|
||||
document = _document(db, space_id=space_id, document_id=document_id)
|
||||
account = db.query(Account).filter_by(id=account_id, space_id=space_id).one_or_none()
|
||||
@@ -766,13 +791,11 @@ def set_review_confirmation(
|
||||
"IMPORT_SYSTEM_ACCOUNT_INVALID",
|
||||
"System accounts cannot be used as statement accounts",
|
||||
)
|
||||
if confirmed_without_full_reconciliation and not reason:
|
||||
raise conflict("IMPORT_RECONCILIATION_REASON_REQUIRED", "A confirmation reason is required")
|
||||
document.account_id = account.id
|
||||
document.needs_account_mapping = False
|
||||
reconciliation = dict(document.reconciliation or {})
|
||||
reconciliation["confirmed_without_full_reconciliation"] = confirmed_without_full_reconciliation
|
||||
reconciliation["reason"] = reason
|
||||
reconciliation["reason"] = reason.strip() or None if reason is not None else None
|
||||
document.reconciliation = reconciliation
|
||||
critical = (
|
||||
db.query(SourceRecord.id)
|
||||
@@ -801,7 +824,7 @@ def resolve_hold(
|
||||
source_record_id: uuid.UUID,
|
||||
user: User,
|
||||
action: str,
|
||||
reason: str | None,
|
||||
reason: str | None = None,
|
||||
account_id: uuid.UUID | None = None,
|
||||
) -> HoldResolution:
|
||||
"""Manually settle one HOLD: book it as a normal operation or discard it."""
|
||||
@@ -813,8 +836,7 @@ def resolve_hold(
|
||||
)
|
||||
if action not in HOLD_MANUAL_ACTIONS:
|
||||
raise bad_request("IMPORT_HOLD_ACTION_INVALID", "Unknown hold resolution action")
|
||||
if reason is None or not reason.strip():
|
||||
raise conflict("IMPORT_HOLD_REASON_REQUIRED", "A resolution reason is required")
|
||||
reason = reason.strip() or None if reason is not None else None
|
||||
source = (
|
||||
db.query(SourceRecord)
|
||||
.filter_by(
|
||||
@@ -842,7 +864,7 @@ def resolve_hold(
|
||||
if action == "confirm_as_posted"
|
||||
else "discarded_as_cancelled",
|
||||
resolved_by_user_id=user.id,
|
||||
reason=reason.strip(),
|
||||
reason=reason,
|
||||
resolved_at=utcnow(),
|
||||
)
|
||||
if action == "confirm_as_posted":
|
||||
@@ -874,6 +896,7 @@ def resolve_hold(
|
||||
user=user,
|
||||
account_id=account.id,
|
||||
amount_kopecks=source.amount_kopecks,
|
||||
category_id=_category_id_for_source(db, space_id=space_id, source=source),
|
||||
occurred_on=source.occurred_on,
|
||||
posted_on=source.posted_on,
|
||||
description=source.description,
|
||||
@@ -900,7 +923,7 @@ def resolve_hold(
|
||||
detail={
|
||||
"document_id": str(document.id),
|
||||
"source_record_id": str(source.id),
|
||||
"reason": reason.strip(),
|
||||
"reason": reason,
|
||||
},
|
||||
)
|
||||
return resolution
|
||||
@@ -1073,9 +1096,7 @@ def confirm_document(
|
||||
if source.review_decision == "savings_transfer":
|
||||
from app.ledger.savings import ensure_savings_resources
|
||||
|
||||
savings_account, _savings_category = ensure_savings_resources(
|
||||
db, space_id=space_id
|
||||
)
|
||||
savings_account, _savings_category = ensure_savings_resources(db, space_id=space_id)
|
||||
if document.account_id == savings_account.id:
|
||||
raise conflict(
|
||||
"IMPORT_SAVINGS_SOURCE_INVALID",
|
||||
@@ -1160,6 +1181,7 @@ def confirm_document(
|
||||
user=user,
|
||||
account_id=document.account_id,
|
||||
amount_kopecks=source.amount_kopecks,
|
||||
category_id=_category_id_for_source(db, space_id=space_id, source=source),
|
||||
occurred_on=source.occurred_on,
|
||||
posted_on=source.posted_on,
|
||||
description=source.description,
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.audit import service as audit
|
||||
from app.categorization import mcc
|
||||
from app.categorization import service as categorization
|
||||
from app.errors import bad_request, conflict, not_found
|
||||
from app.identity.models import User
|
||||
@@ -67,7 +68,7 @@ def _validate_category(db: Session, *, space_id: uuid.UUID, category_id: uuid.UU
|
||||
raise not_found("BUDGET_CATEGORY_NOT_FOUND", "Category not found")
|
||||
if category.archived:
|
||||
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived")
|
||||
if category.system_key is not None:
|
||||
if not mcc.is_budgetable_category_key(category.system_key):
|
||||
raise conflict(
|
||||
"BUDGET_CATEGORY_SYSTEM",
|
||||
"System categories cannot be assigned to regular operations",
|
||||
|
||||
@@ -15,9 +15,11 @@ def create_space(db: Session, *, user: User, name: str) -> FinancialSpace:
|
||||
db.flush()
|
||||
set_space_context(db, space.id)
|
||||
db.add(Membership(space_id=space.id, user_id=user.id, role="owner"))
|
||||
from app.categorization.service import ensure_default_categories
|
||||
from app.ledger.savings import ensure_savings_resources
|
||||
|
||||
ensure_savings_resources(db, space_id=space.id)
|
||||
ensure_default_categories(db, space_id=space.id)
|
||||
audit.record(db, event_type="space.created", space_id=space.id, user_id=user.id)
|
||||
return space
|
||||
|
||||
|
||||
+5
-10
@@ -21,14 +21,15 @@ dependencies = [
|
||||
"pdfplumber>=0.11",
|
||||
"python-multipart>=0.0.9",
|
||||
"boto3>=1.35",
|
||||
"iso18245>=1.4.1",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.2",
|
||||
"httpx>=0.27",
|
||||
"httpx2>=2.0",
|
||||
"ruff>=0.5",
|
||||
"mypy>=1.10",
|
||||
"ty>=0.0.18",
|
||||
"hypothesis>=6.112",
|
||||
]
|
||||
|
||||
@@ -52,11 +53,5 @@ target-version = "py314"
|
||||
select = ["E", "F", "I", "UP", "B"]
|
||||
ignore = ["B008"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.14"
|
||||
ignore_missing_imports = true
|
||||
check_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
warn_redundant_casts = true
|
||||
disable_error_code = ["import-untyped"]
|
||||
exclude = ["alembic/", "tests/"]
|
||||
[tool.ty.src]
|
||||
include = ["app"]
|
||||
|
||||
@@ -13,8 +13,10 @@ from app.budgeting.service import (
|
||||
reopen_period,
|
||||
set_assignments,
|
||||
)
|
||||
from app.categorization import service as categorization
|
||||
from app.errors import AppError
|
||||
from app.identity.models import User
|
||||
from app.ledger.models import Category
|
||||
from app.ledger.service import create_account, create_expense
|
||||
from app.spaces.service import create_space
|
||||
|
||||
@@ -51,6 +53,59 @@ def test_uncategorized_expense_blocks_close(db: Session) -> None:
|
||||
assert exc.value.code == "BUDGET_CLOSE_BLOCKED"
|
||||
|
||||
|
||||
def test_default_mcc_categories_are_budgetable_and_editable_mapping(db: Session) -> None:
|
||||
user, space = seed(db)
|
||||
defaults = db.query(Category).filter_by(space_id=space.id).all()
|
||||
by_key = {category.system_key: category for category in defaults}
|
||||
assert len([key for key in by_key if key not in {None, "savings"}]) == 21
|
||||
groceries = by_key["groceries"]
|
||||
others = by_key["others"]
|
||||
assert {5411, 5422}.issubset(
|
||||
categorization.get_category_mcc_codes(db, space_id=space.id, category_id=groceries.id)
|
||||
)
|
||||
period = get_or_create_period(db, space_id=space.id, year=2026, month=5)
|
||||
set_assignments(
|
||||
db,
|
||||
space_id=space.id,
|
||||
user=user,
|
||||
period=period,
|
||||
items=[{"category_id": groceries.id, "planned_kopecks": 2_000}],
|
||||
)
|
||||
account = create_account(
|
||||
db,
|
||||
space_id=space.id,
|
||||
user=user,
|
||||
name="Cash",
|
||||
kind="cash",
|
||||
opened_on=date(2026, 5, 1),
|
||||
)
|
||||
create_expense(
|
||||
db,
|
||||
space_id=space.id,
|
||||
user=user,
|
||||
account_id=account.id,
|
||||
amount_kopecks=500,
|
||||
occurred_on=date(2026, 5, 2),
|
||||
category_id=others.id,
|
||||
)
|
||||
assert categorization.category_for_mcc(db, space_id=space.id, code=5999).id == others.id
|
||||
|
||||
custom = create_category(db, space_id=space.id, user=user, kind="monthly", name="My shops")
|
||||
codes = categorization.set_category_mcc_codes(
|
||||
db, space_id=space.id, category_id=custom.id, codes=[5999, 5994]
|
||||
)
|
||||
assert codes == [5994, 5999]
|
||||
assert categorization.category_for_mcc(db, space_id=space.id, code=5999).id == custom.id
|
||||
custom.archived = True
|
||||
assert categorization.category_for_mcc(db, space_id=space.id, code=5999).id == others.id
|
||||
custom.archived = False
|
||||
with pytest.raises(AppError) as exc:
|
||||
categorization.set_category_mcc_codes(
|
||||
db, space_id=space.id, category_id=custom.id, codes=[5411]
|
||||
)
|
||||
assert exc.value.code == "CATEGORY_MCC_TAKEN"
|
||||
|
||||
|
||||
def test_copy_previous_month_clamps_expected_day(db: Session) -> None:
|
||||
from app.budgeting.service import add_expected_income
|
||||
|
||||
|
||||
@@ -167,7 +167,8 @@ def test_adjustment_on_regular_account_keeps_pool_and_totals(db: Session) -> Non
|
||||
# The adjustment reconciles the account but never enters the budget pool.
|
||||
assert state["pool"] == 300_000
|
||||
assert state["uncategorized_expenses"] == 0
|
||||
assert state["monthly_categories"] == []
|
||||
assert len(state["monthly_categories"]) == 21
|
||||
assert all(row["spent"] == 0 for row in state["monthly_categories"])
|
||||
|
||||
|
||||
def test_savings_envelope_available_uses_month_end_balance(db: Session) -> None:
|
||||
|
||||
@@ -71,6 +71,53 @@ def test_space_has_protected_savings_pair(client: TestClient) -> None:
|
||||
assert delete_response.status_code == 409
|
||||
|
||||
|
||||
def test_categories_expose_and_update_multiple_mcc_codes(client: TestClient) -> None:
|
||||
csrf = bootstrap(client)
|
||||
definitions = client.get("/api/v1/budget/category-definitions").json()
|
||||
groceries = next(item for item in definitions if item["systemKey"] == "groceries")
|
||||
assert {5411, 5422}.issubset(groceries["mccCodes"])
|
||||
assert any(item["systemKey"] == "others" for item in definitions)
|
||||
|
||||
created = client.post(
|
||||
"/api/v1/budget/categories",
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
json={"name": "Shops", "kind": "monthly", "mccCodes": [5999, 5994]},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
assert created.json()["mccCodes"] == [5994, 5999]
|
||||
category_id = created.json()["id"]
|
||||
|
||||
updated = client.patch(
|
||||
f"/api/v1/budget/category-definitions/{category_id}",
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
json={"mccCodes": [5999]},
|
||||
)
|
||||
assert updated.status_code == 200, updated.text
|
||||
assert updated.json()["mccCodes"] == [5999]
|
||||
|
||||
taken = client.patch(
|
||||
f"/api/v1/budget/category-definitions/{category_id}",
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
json={"mccCodes": [5411]},
|
||||
)
|
||||
assert taken.status_code == 409
|
||||
assert taken.json()["error"]["code"] == "CATEGORY_MCC_TAKEN"
|
||||
|
||||
released = client.patch(
|
||||
f"/api/v1/budget/category-definitions/{groceries['id']}",
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
json={"mccCodes": [code for code in groceries["mccCodes"] if code != 5411]},
|
||||
)
|
||||
assert released.status_code == 200, released.text
|
||||
remapped = client.patch(
|
||||
f"/api/v1/budget/category-definitions/{category_id}",
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
json={"mccCodes": [5411, 5999]},
|
||||
)
|
||||
assert remapped.status_code == 200, remapped.text
|
||||
assert remapped.json()["mccCodes"] == [5411, 5999]
|
||||
|
||||
|
||||
def test_category_lifecycle_create_rename_archive_restore(client: TestClient) -> None:
|
||||
csrf = bootstrap(client)
|
||||
created = client.post(
|
||||
@@ -163,9 +210,7 @@ def test_adjust_and_savings_conversion_http_contracts(client: TestClient) -> Non
|
||||
"occurredOn": "2026-09-10",
|
||||
"reason": "Statement reconciliation",
|
||||
}
|
||||
denied = client.post(
|
||||
f"/api/v1/accounts/{account_id}/adjust-balance", json=adjustment_body
|
||||
)
|
||||
denied = client.post(f"/api/v1/accounts/{account_id}/adjust-balance", json=adjustment_body)
|
||||
assert denied.status_code == 403
|
||||
adjusted = client.post(
|
||||
f"/api/v1/accounts/{account_id}/adjust-balance",
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from alembic.config import Config
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from alembic import command
|
||||
from app.budgeting.service import close_period, compute_period_state, get_or_create_period
|
||||
from app.errors import AppError
|
||||
from app.identity.models import User
|
||||
@@ -200,20 +207,8 @@ def test_unresolved_hold_blocks_only_its_occurred_month(db: Session) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_discard_as_cancelled_requires_reason_and_unblocks_month(db: Session) -> None:
|
||||
def test_discard_as_cancelled_without_reason_unblocks_month(db: Session) -> None:
|
||||
user, space, _account, document, hold = hold_document(db)
|
||||
with pytest.raises(AppError) as exc:
|
||||
service.resolve_hold(
|
||||
db,
|
||||
space_id=space.id,
|
||||
document_id=document.id,
|
||||
source_record_id=hold.id,
|
||||
user=user,
|
||||
action="discard_as_cancelled",
|
||||
reason=" ",
|
||||
)
|
||||
assert exc.value.code == "IMPORT_HOLD_REASON_REQUIRED"
|
||||
assert db.query(HoldResolution).count() == 0
|
||||
resolution = service.resolve_hold(
|
||||
db,
|
||||
space_id=space.id,
|
||||
@@ -221,7 +216,6 @@ def test_discard_as_cancelled_requires_reason_and_unblocks_month(db: Session) ->
|
||||
source_record_id=hold.id,
|
||||
user=user,
|
||||
action="discard_as_cancelled",
|
||||
reason="Cancelled by the bank",
|
||||
)
|
||||
db.flush()
|
||||
assert resolution.resolution == "discarded_as_cancelled"
|
||||
@@ -230,7 +224,7 @@ def test_discard_as_cancelled_requires_reason_and_unblocks_month(db: Session) ->
|
||||
assert db.query(Operation).filter_by(space_id=space.id).count() == 0
|
||||
detail = service.document_detail(db, space_id=space.id, document_id=document.id)
|
||||
hold_row = next(item for item in detail["records"] if item["id"] == hold.id)
|
||||
assert hold_row["hold_resolution"]["reason"] == "Cancelled by the bank"
|
||||
assert hold_row["hold_resolution"]["reason"] is None
|
||||
april = get_or_create_period(db, space_id=space.id, year=2026, month=4)
|
||||
close_period(db, space_id=space.id, user=user, period=april)
|
||||
may = get_or_create_period(db, space_id=space.id, year=2026, month=5)
|
||||
@@ -238,21 +232,20 @@ def test_discard_as_cancelled_requires_reason_and_unblocks_month(db: Session) ->
|
||||
assert may.status == "closed"
|
||||
|
||||
|
||||
def test_confirm_as_posted_requires_reason(db: Session) -> None:
|
||||
def test_confirm_as_posted_accepts_blank_reason(db: Session) -> None:
|
||||
user, space, _account, document, hold = hold_document(db)
|
||||
for reason in (None, ""):
|
||||
with pytest.raises(AppError) as exc:
|
||||
service.resolve_hold(
|
||||
resolution = service.resolve_hold(
|
||||
db,
|
||||
space_id=space.id,
|
||||
document_id=document.id,
|
||||
source_record_id=hold.id,
|
||||
user=user,
|
||||
action="confirm_as_posted",
|
||||
reason=reason,
|
||||
reason=" ",
|
||||
)
|
||||
assert exc.value.code == "IMPORT_HOLD_REASON_REQUIRED"
|
||||
assert db.query(HoldResolution).count() == 0
|
||||
db.flush()
|
||||
assert resolution.reason is None
|
||||
assert resolution.operation_id is not None
|
||||
|
||||
|
||||
def test_hold_cannot_be_resolved_before_document_confirmation(db: Session) -> None:
|
||||
@@ -736,7 +729,7 @@ def test_hold_resolution_rejects_unknown_kind(db: Session) -> None:
|
||||
db.rollback()
|
||||
|
||||
|
||||
def test_manual_resolution_requires_reason_at_database_level(db: Session) -> None:
|
||||
def test_manual_resolution_accepts_no_reason_at_database_level(db: Session) -> None:
|
||||
_user, space, _account, _document, hold = hold_document(db)
|
||||
db.add(
|
||||
HoldResolution(
|
||||
@@ -746,8 +739,8 @@ def test_manual_resolution_requires_reason_at_database_level(db: Session) -> Non
|
||||
reason=None,
|
||||
)
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
db.flush()
|
||||
assert db.query(HoldResolution).filter_by(hold_source_record_id=hold.id).one().reason is None
|
||||
db.rollback()
|
||||
_user, space, _account, _document, hold = hold_document(db)
|
||||
db.add(
|
||||
@@ -761,6 +754,49 @@ def test_manual_resolution_requires_reason_at_database_level(db: Session) -> Non
|
||||
db.flush()
|
||||
|
||||
|
||||
def test_upgrade_drops_legacy_manual_reason_constraint(tmp_path: Path, monkeypatch) -> None:
|
||||
from app.config import get_settings
|
||||
|
||||
database_url = f"sqlite:///{tmp_path / 'hold-reason.db'}"
|
||||
monkeypatch.setenv("DATABASE_URL", database_url)
|
||||
get_settings.cache_clear()
|
||||
config = Config(str(Path(__file__).parents[1] / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(Path(__file__).parents[1] / "alembic"))
|
||||
try:
|
||||
command.upgrade(config, "0003")
|
||||
engine = create_engine(database_url)
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
operations = Operations(MigrationContext.configure(connection))
|
||||
with operations.batch_alter_table("hold_resolutions") as batch_op:
|
||||
batch_op.create_check_constraint(
|
||||
"ck_hold_resolutions_manual_reason_required",
|
||||
"NOT (resolution IN ('confirmed_as_posted', 'discarded_as_cancelled') "
|
||||
"AND (reason IS NULL OR reason = ''))",
|
||||
)
|
||||
|
||||
command.upgrade(config, "0005")
|
||||
constraints = sa.inspect(engine).get_check_constraints("hold_resolutions")
|
||||
assert not any(
|
||||
(constraint.get("name") or "").endswith("manual_reason_required")
|
||||
for constraint in constraints
|
||||
)
|
||||
with Session(engine) as db:
|
||||
db.add(
|
||||
HoldResolution(
|
||||
space_id=uuid.uuid4(),
|
||||
hold_source_record_id=uuid.uuid4(),
|
||||
resolution="discarded_as_cancelled",
|
||||
reason=None,
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
finally:
|
||||
engine.dispose()
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _draft(rows, *, opening=1_000, closing=1_200, **declared):
|
||||
return SimpleNamespace(
|
||||
currency="RUB",
|
||||
@@ -920,6 +956,7 @@ def test_resolve_endpoint_requires_csrf_and_resolves_hold(client: TestClient, db
|
||||
space = SimpleNamespace(id=space_id)
|
||||
document, run = make_document(db, space, account, user=user)
|
||||
hold = add_record(db, document, run, bank_status="hold")
|
||||
hold.mcc = 5411
|
||||
service.confirm_document(db, space_id=space_id, document_id=document.id, user=user)
|
||||
db.commit()
|
||||
|
||||
@@ -932,14 +969,17 @@ def test_resolve_endpoint_requires_csrf_and_resolves_hold(client: TestClient, db
|
||||
resolved = client.post(
|
||||
f"/api/v1/imports/{document.id}/rows/{hold.id}/resolve",
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
json={"action": "confirm_as_posted", "reason": "Checked in the bank app"},
|
||||
json={"action": "confirm_as_posted"},
|
||||
)
|
||||
assert resolved.status_code == 200, resolved.text
|
||||
rows = resolved.json()["rows"]
|
||||
row = next(item for item in rows if item["id"] == str(hold.id))
|
||||
assert row["bankStatus"] == "hold"
|
||||
assert row["mcc"] == 5411
|
||||
assert row["mccName"] == "Продуктовые магазины и супермаркеты"
|
||||
assert row["holdResolution"]["resolution"] == "confirmed_as_posted"
|
||||
assert row["holdResolution"]["reason"] == "Checked in the bank app"
|
||||
assert row["holdResolution"]["reason"] is None
|
||||
assert row["holdResolution"]["operationId"] is not None
|
||||
operations = db.query(Operation).filter_by(space_id=space_id).all()
|
||||
assert len(operations) == 1
|
||||
assert operations[0].category_id is not None
|
||||
|
||||
@@ -18,7 +18,7 @@ from app.imports.models import (
|
||||
SourceRecordLink,
|
||||
StatementDocument,
|
||||
)
|
||||
from app.ledger.models import Operation
|
||||
from app.ledger.models import Category, Operation
|
||||
from app.ledger.service import create_account
|
||||
from app.spaces.service import create_space
|
||||
|
||||
@@ -93,6 +93,45 @@ def test_confirm_and_revert_are_idempotent(db: Session) -> None:
|
||||
assert db.query(SourceRecordLink).filter_by(space_id=space.id).count() == 0
|
||||
|
||||
|
||||
def test_import_review_and_confirmation_do_not_require_reason(db: Session) -> None:
|
||||
user, space, document = ready_document(db)
|
||||
document.status = "review_required"
|
||||
document.reconciliation = {"full": False, "matched": None}
|
||||
service.set_review_confirmation(
|
||||
db,
|
||||
space_id=space.id,
|
||||
document_id=document.id,
|
||||
account_id=document.account_id,
|
||||
confirmed_without_full_reconciliation=True,
|
||||
)
|
||||
assert document.reconciliation["reason"] is None
|
||||
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
|
||||
assert document.status == "confirmed"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("code", "system_key", "label"),
|
||||
[
|
||||
(5411, "groceries", "Продуктовые магазины и супермаркеты"),
|
||||
(5999, "others", "Прочие специализированные магазины"),
|
||||
(9999, "others", None),
|
||||
],
|
||||
)
|
||||
def test_import_mcc_assigns_default_category_and_label(
|
||||
db: Session, code: int, system_key: str, label: str | None
|
||||
) -> None:
|
||||
user, space, document = ready_document(db)
|
||||
source = db.query(SourceRecord).filter_by(document_id=document.id).one()
|
||||
source.mcc = code
|
||||
row = service.document_detail(db, space_id=space.id, document_id=document.id)["records"][0]
|
||||
assert row["mcc"] == code
|
||||
assert row["mcc_name"] == label
|
||||
|
||||
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
|
||||
operation = db.query(Operation).filter_by(space_id=space.id).one()
|
||||
assert db.get(Category, operation.category_id).system_key == system_key
|
||||
|
||||
|
||||
def test_confirm_rejects_arithmetic_mismatch(db: Session) -> None:
|
||||
user, space, document = ready_document(db)
|
||||
document.reconciliation = {"full": True, "matched": False}
|
||||
@@ -222,9 +261,7 @@ def test_duplicate_candidate_can_reuse_unique_operation_without_client_id(db: Se
|
||||
def test_duplicate_candidates_ignore_rows_from_same_parse_run(db: Session) -> None:
|
||||
user, space, document = ready_document(db)
|
||||
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
|
||||
first_source = (
|
||||
db.query(SourceRecord).filter_by(parse_run_id=document.active_parse_run_id).one()
|
||||
)
|
||||
first_source = db.query(SourceRecord).filter_by(parse_run_id=document.active_parse_run_id).one()
|
||||
existing_link = db.query(SourceRecordLink).filter_by(source_record_id=first_source.id).one()
|
||||
second_source = SourceRecord(
|
||||
space_id=first_source.space_id,
|
||||
|
||||
@@ -2,10 +2,11 @@ from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from alembic import command
|
||||
from app.categorization.models import CategoryMcc
|
||||
from app.config import get_settings
|
||||
from app.identity.models import User
|
||||
from app.ledger.models import Account, Category
|
||||
@@ -13,7 +14,8 @@ from app.spaces.models import FinancialSpace, Membership
|
||||
|
||||
|
||||
def test_upgrade_backfills_one_pair_without_claiming_same_named_rows(
|
||||
tmp_path: Path, monkeypatch,
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
database_url = f"sqlite:///{tmp_path / 'migration.db'}"
|
||||
monkeypatch.setenv("DATABASE_URL", database_url)
|
||||
@@ -61,8 +63,37 @@ def test_upgrade_backfills_one_pair_without_claiming_same_named_rows(
|
||||
assert system_category[0].linked_account_id == system_account[0].id
|
||||
assert any(item.name == "Savings" and item.system_key is None for item in accounts)
|
||||
assert any(
|
||||
item.name_override == "Savings" and item.system_key is None
|
||||
item.name_override == "Savings" and item.system_key is None for item in categories
|
||||
)
|
||||
default_categories = {
|
||||
item.system_key: item
|
||||
for item in categories
|
||||
if item.system_key not in {None, "savings"}
|
||||
}
|
||||
assert len(default_categories) == 21
|
||||
assert "groceries" in default_categories and "others" in default_categories
|
||||
assert (
|
||||
db.query(CategoryMcc)
|
||||
.filter_by(
|
||||
space_id=space_id,
|
||||
category_id=default_categories["groceries"].id,
|
||||
mcc_code=5411,
|
||||
)
|
||||
.count()
|
||||
== 1
|
||||
)
|
||||
|
||||
command.downgrade(config, "0003")
|
||||
schema = inspect(engine)
|
||||
assert schema.has_table("mcc_codes")
|
||||
assert schema.has_table("bank_category_mappings")
|
||||
assert not schema.has_table("category_mcc_codes")
|
||||
with Session(engine) as db:
|
||||
assert (
|
||||
db.query(Category)
|
||||
.filter(Category.space_id == space_id, Category.system_key == "groceries")
|
||||
.count()
|
||||
== 0
|
||||
)
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
Generated
+83
-174
@@ -103,69 +103,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/d1/095d95eaf2ed1d9f77268cf3291bde148c6cd56121f8db2c74c1ba618a0e/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6ab674f668d5962a3a4136ae0812519b0f1586874263723a32181d60d64137e1", size = 25378, upload-time = "2026-08-20T07:33:04.332Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ast-serialize"
|
||||
version = "0.11.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/a2/04a9383e7512c91c54b2f34b3ff86dc7d2610506f588c2bda36e952a68f7/ast_serialize-0.11.1.tar.gz", hash = "sha256:cc5db2983805f6be786488aac8c5998d5b71965488d1b18c44d435a2205a5cb4", size = 953785, upload-time = "2026-09-09T16:05:27.851Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/cb/3577c66278e4ea4ff6968aee04327447335ecfc58c76123f68cf7f76d75e/ast_serialize-0.11.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7ca1557553fdab313999ad69156de154ff87152cae963698be2e765164bb6c0", size = 897021, upload-time = "2026-09-09T16:03:59.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/db/9b4eed53ab0bb63653f56ef71062fa91693c8c26b0b648c58d98365c990c/ast_serialize-0.11.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8b54e44763a851c336ca137c60a2434511f0d155e0b924ef374bfbc8d8db8927", size = 1235148, upload-time = "2026-09-09T16:04:01.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/97/1ebe323015c3cf530f08e88df9929bfd9f5cf8165842bd4b37a7c958565c/ast_serialize-0.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:753afabcc4abf295f515ea33185cf997a1990b88a29d3a3336a9acf62ea85950", size = 1216082, upload-time = "2026-09-09T16:04:02.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/72/0d3a368edc2d1e0e188c0d3f23821f4e20a6a2291b23d9df482a6b11277f/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98b8aba6bb682b9e8859c628382b4881c0600b28056d63c3168c227c449f3284", size = 1282848, upload-time = "2026-09-09T16:04:04.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/fb/2a546a57d7caa708d739c1747bbdf45cc3aa9dac9407a11d9d67e8a0e255/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:651a7890558896c3e08089e51ab3b3a43710eab15b72ad861c400bc9a51923ba", size = 1285337, upload-time = "2026-09-09T16:04:05.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/16/4ff4179584f8b8bec348787bf721cb7ce3d4c707c7878778065249b59b9a/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff8ddc1453bfe7934292409c7b8e9f68b0a6cff4e4c12535e4b4a66fd83aca0c", size = 1554906, upload-time = "2026-09-09T16:04:07.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/e8/e54a54676ca8965e1ae3cfe065dc26c75158e0b3a3ce35af283da106fbf9/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:daec16b026c5741950a00c708acdf698c4f17babbe4829dd8232324822e9514f", size = 1301472, upload-time = "2026-09-09T16:04:08.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/ab/618835a2479868a0d4ac807dd04cf2f89d24afe22c4171538d9779c2772e/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9961abcb93bf03652ab09c00862a2396abacd57551c769ad34490a2ae508a61d", size = 1301293, upload-time = "2026-09-09T16:04:10.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/cb/8acc29a1b279ccdfb30dcbdf099e1d8d34c4ee77be9be751c48061433129/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:8ca9e48edf246f09fd9bdb64fbd9cd8d18cd2a69ffc24d44891466e17595094f", size = 1307807, upload-time = "2026-09-09T16:04:11.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/74/bada875132f452cb5c1179e3156df43cee6df6cb51f773220688be3d4bb4/ast_serialize-0.11.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6a57e0e025e9dcee48e466b3cf909178e298d238148664e796fc0f60ded5e52e", size = 1356265, upload-time = "2026-09-09T16:04:13.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/ae/720a8042fc8c1d170f4cc7ed46295ef078f8198ed3476f58210a1e675e0c/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:110386eebccf200446d5f4dcc215f80866a5d3d3d054d3ced6ec1bf386cd3f02", size = 1459957, upload-time = "2026-09-09T16:04:14.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/2c/ce2ff1ffd4d70376073393c154033728c8535c17211b7710fee7b0f04474/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ef3950747f3a588f692cf4d6f2937eedd1a2d5e199f863af29e8ec7da78a4f36", size = 1562369, upload-time = "2026-09-09T16:04:16.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/3e/d7a5dd5f609bd0bac45617ce9425e4bd1e369b998c8678edfd70c985408c/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0bc94b81878a3f999fe4cdb606bf00ef7dff6b4f66d0e089b2cf7bfc00d59beb", size = 1556466, upload-time = "2026-09-09T16:04:18.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/cd/a45179802a8637f2be252163ec4e4948534fa8d88798dd391c9cc54e096e/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:341dfc9e6b4e47e5c37e1b7257a77d39f407cf49a371ee90225ab624c2fe8b36", size = 1687190, upload-time = "2026-09-09T16:04:19.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/eb/ef206d764ec0554368501b8b5268f5468819dc0588815acb0575465643df/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7b524cb1b08e15db0f116075c39ab51674b6d3c8103ec1a59c8ca464be83e28a", size = 1481188, upload-time = "2026-09-09T16:04:21.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/37/9a724c523af7c8b97a659bf4dc28a8e60f97d0cf4d19be277db4067ca734/ast_serialize-0.11.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c2358b341aca08bd2e1005713bc480bc06eee9f345b67fcb826829aa7b8d0d3", size = 1500731, upload-time = "2026-09-09T16:04:22.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/10/890d5fcaf568088de7989e8cdb4ca525f485bd6cb25eea49d45f017500af/ast_serialize-0.11.1-cp314-cp314t-win32.whl", hash = "sha256:b9e61143a5904f46daa0cde74ba60d3b7c0e3e000752ee138e2bc68e126a5803", size = 1119009, upload-time = "2026-09-09T16:04:24.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/61/2bd32bd16b5e2ee07badc8c32a73c545dc5a9f087ed19c07eee1243d5a1b/ast_serialize-0.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:31df8649761d5cb6eb0de2209bd4a167c7477c0516147c70c0b2f5bddc7839bb", size = 1155665, upload-time = "2026-09-09T16:04:26.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/b6/84bb5e1dc418e660524b873c5bac9be2bf13d38676c96feef4cd4e7d03f3/ast_serialize-0.11.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2f8f33e416a4c7aad12ad757e895c676e953b4027b80b90718dccea83d45111a", size = 1131854, upload-time = "2026-09-09T16:04:28.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/dd/16de2c0d23a6b298c735d4e4b56d86fa70476eef98e2c66ceaa65503b62f/ast_serialize-0.11.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:a015ede631eeb098a23de8ec0cfe11a1dd02159ab7a26bdc7c6bde2befbbc7b4", size = 1235495, upload-time = "2026-09-09T16:04:29.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/f7/302d2251e6298bbeabc8a1815c9147127031517b305001a02f34fd46b6b5/ast_serialize-0.11.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:57b7d52d1f5c92905cd648bda9efc83cac33097de1e5bb68de8feca9b5f7e87a", size = 1215642, upload-time = "2026-09-09T16:04:31.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/06/93b6527646613502f364cebfb23783fa6701cdbe90761ee26144f1fa20b9/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e47b9b028efbc0486263a49f782ad0ae3e879855fe5a2897b59feefb78e1c40c", size = 1283526, upload-time = "2026-09-09T16:04:32.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/ac/ffb216262c9a582d039d846081b8c3017dc368cbed5a2520deca5cb7f8cb/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00adb748034c7b1938ec56925f9f6230ffcd9c56d47355fe32485df29b90b977", size = 1287526, upload-time = "2026-09-09T16:04:34.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/06/19a4c4837c4d5e73b982938d6da3c9ae10513b61527575b3e1b8396f9298/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:82359d00ea808c260c327e43955c0190bb7baef152a01ae0b5c74d05387b9552", size = 1558354, upload-time = "2026-09-09T16:04:35.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/29/2373199907b2d97feed176115308dfb3dd0446566b047009828f266d2d66/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f35bb9ddeed4008c7d3e272dd1999885d712e1ce623b0a65ba8a8ed1d51c35cf", size = 1302731, upload-time = "2026-09-09T16:04:37.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/55/ab6805a1457dbe565c84f8d36e2aa4207ef22408adfd01d225823cd07484/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74093ed682ba58456f0d4450da7fb62b826fddd97824d44fc4a81ca6e0bf9e23", size = 1301594, upload-time = "2026-09-09T16:04:38.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/e2/4f54eab2201bb420d39bf61423abcb5d4daffd28e2270e1c5eee2bf3c0f1/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:4dd7218870c203eff4533cfc04a39f29077d6789b20f5b746e7648fe5762a548", size = 1309355, upload-time = "2026-09-09T16:04:40.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/07/755dd98664e2374080b72ccb5fea63d9f11b4bec2409a05b2a4ebc97618d/ast_serialize-0.11.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e8d20700766171a8a17f89cf53d7bd9712c509460d28a51f7f98340577b78aa", size = 1356645, upload-time = "2026-09-09T16:04:42.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/a0/fadbde3e108064236e2679728ce6d8247aefc81bac5fd82505b59cf69172/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:20b3371c0403099cd55d59f4bcedb6d3994d9db3fa41711c648760a89ef2a575", size = 1459696, upload-time = "2026-09-09T16:04:44.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/fd/b4f95249bd895368ea8290668e27d1f0a5c4ad888bd72a34c7beb2a500d6/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:cc1a78b913f8665dda145999b1b4805ad2ef442ecbe3b819512cf7f95e70498a", size = 1562517, upload-time = "2026-09-09T16:04:45.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/98/6afac594380410710d9d863b9ef41e9c6ca89bd901bda1464ee9e99180a8/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:38f7141881e783bccc362d201d7fd7437934216b669b9fef39febc2f63fd2d9b", size = 1556951, upload-time = "2026-09-09T16:04:47.12Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/6f/5a81b5cbf1731f4722bc90adf4b4ace7dda69ca29837844147063aeafb22/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:13903a3f212ab9e5d05a6c3f2337288f4f8f4c18e6ab3817417ae7a3d46dee14", size = 1691039, upload-time = "2026-09-09T16:04:48.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/eb/d9b903206cbd6d5143838367d385fa88ec49eaae2cc12f284327c6e0af05/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:e5a9c53e64732118ae8235d0e57eb90e2333e90ff749a1a7dca6f8bfdfa5200e", size = 1483297, upload-time = "2026-09-09T16:04:50.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/48/ee13b4079ec67e9b333a87e5bb8eee643ab1b49f7173b3fca71158c6cdf6/ast_serialize-0.11.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:aecb606f67f21fd1c0ffba1826470d2ee400e41b23881ad9a1b2157306865b1a", size = 1501686, upload-time = "2026-09-09T16:04:51.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/67/2175b79e1ee6b042e4bf8ed6871e60cafea7e9cf3fc69e87d7a9d520ae77/ast_serialize-0.11.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:d1ef9c478d8c8ca83499704d13e5ac32c840ed7ca7884aaaf716166aca5a2806", size = 1119522, upload-time = "2026-09-09T16:04:53.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/c3/8c96aa5f121e9bdda0346b2df3919185eb1ec9a2cce2d20e311b4575ca0b/ast_serialize-0.11.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:27eef0739e0110f5db1ff5dfa38b3dadfcb6f0c31975a2b412f9f2bd72139cea", size = 1157260, upload-time = "2026-09-09T16:04:54.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bc/8aa663209e335eca73c9e1006197222b6b8dafa6665e513cdd975aa65496/ast_serialize-0.11.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:f9454960bbf185d33c669ccd5c9b76c96709a4542a95a65e27342c4d10dd8e20", size = 1132060, upload-time = "2026-09-09T16:04:56.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/a7/e738887429de70350d9e25f84a95efbdd086b79579a811509dee8b02d7d5/ast_serialize-0.11.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7b9a6089f1337838492b217707e9a55d0b9b407fe9169a960fa12282abf2234c", size = 1240585, upload-time = "2026-09-09T16:04:59.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/f9/46b64fa883f3c8b8cf51629978f16d8a2da3d5c5c02f64add40864907703/ast_serialize-0.11.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:68929da7fb1c7375f69641baac9519d5c41ee441cd09a591ea5dfc83107ffe6f", size = 1228038, upload-time = "2026-09-09T16:05:00.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/89/fe75c8d0f104a4be11cd0e23acecf129484c9ba06c7130033752d63a78c5/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8945557ed3015173dbf8d44184da9621add9162720acbc0ac4832b5a091b4e8a", size = 1292388, upload-time = "2026-09-09T16:05:02.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/56/3f21c881900d8254a35195e1e962355b4d570ccd9cc193b0ba08abb81952/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9cbed53bb8992b72e587dd47d0ca71703f5f715420f48ed57587c06c2fcd213", size = 1294413, upload-time = "2026-09-09T16:05:04.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/b2/8d77be3dad1139158c59e370391f4990ea73f7f102983392272856bd3a63/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65c836d1ab7af65e64a1dd73de82572566e838dd27529b1124eac0ac86f35e76", size = 1565921, upload-time = "2026-09-09T16:05:05.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/2e/0675b5796c897f957fbdac6af0ebeeaa9a63cee866c1c54f1e5136ef5bf6/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ab6df9898600ff45149b86c12bb5aa3359cc71843ad99ad0c924d11392469ab", size = 1312160, upload-time = "2026-09-09T16:05:07.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/c2/1749fa4efae0dc98aad3c3d29f0178e2609208c3ba34700b4551082e40d5/ast_serialize-0.11.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3eebd3dd25a81839ac7d25b5f3fb3527f6141b35eca29f63d2613fe6254307fd", size = 1312405, upload-time = "2026-09-09T16:05:08.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/14/2afc9f9d7db551f805b4d5e496946dc3dc2a703d27294ccc200b6ff07a7a/ast_serialize-0.11.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:311333c5f58f55fc04121be2c5adffa42a19e1038a43a2cef862fdf58c45bf4f", size = 1319458, upload-time = "2026-09-09T16:05:10.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/4a/8b2866d9d6d5d0b037d7bf2b74db6e75a66695c503248632116f24009a8d/ast_serialize-0.11.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:aa8d0a9f6d02e7626cdf540555bc2f5313eabe803dcca04854fc628ddf4b1058", size = 1365055, upload-time = "2026-09-09T16:05:12.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/17/73119504574f9b46610ffb718501b254c8adb727d4e43c5babc3c62d4529/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e209a797fdf8680d58d2e969a9ddb25058ea682f6cff59dbabc98d8aef4e0db1", size = 1467771, upload-time = "2026-09-09T16:05:13.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/f5/776bfb7a856ba0bb9bd373b76a90230fb77b1a10c987810b621ed244d5ba/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b391becd31e9889da438d388aac0362e3dfd222affbe45d920580c351486c787", size = 1571459, upload-time = "2026-09-09T16:05:15.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/43/86a655170ee36a8fdce65a922b7e34040bb319eb4ec251161032343c1fc3/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:021afb3482d27d1dace9c8999724a13083e96c7ece785e53e418bf5df3b50e0a", size = 1568959, upload-time = "2026-09-09T16:05:16.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/b1/015a3a156dc9bd46e9c4e4f24a939eb0b87179b1c53c9495ac6f088f13b4/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:c1dc60251d93beff32d4147ecd4ccc518b0f022a900093f6f3021c2012416f0f", size = 1698173, upload-time = "2026-09-09T16:05:18.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/ea/a988b70980aca3a8a3fb731901dc911fe73a456cebbc225b767bbe5490ff/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:b77d6a2267227d68d8221cfca177f8c6d9f68019f3250401d0355d2638db9fb9", size = 1493298, upload-time = "2026-09-09T16:05:19.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/15/e049b2701ddab9087d7f62448f4c2c1b1463e676baa471345cf3e4a31e1b/ast_serialize-0.11.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e68ac7d7fd1a5d42a3a6d726d7cc8a7e0c9987934f11c6a5162aa2cf68e81e24", size = 1510213, upload-time = "2026-09-09T16:05:21.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/a2/23d555eb842d1397dce54fac31851e6f151e03a0ae74db389d9b39718c33/ast_serialize-0.11.1-cp39-abi3-win32.whl", hash = "sha256:ed449d786b9032a7b85fcd6c2f7f54c4cd0e1e1ad254d396805ff922096bf08d", size = 1125239, upload-time = "2026-09-09T16:05:22.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/4a/251f3fd1b8a5549edaedf8f3ba0b9fb5060e194d3c0ed208b593fccaeff1/ast_serialize-0.11.1-cp39-abi3-win_amd64.whl", hash = "sha256:6b43f5a9b9a8dd20ba3124914e63aa7d7427de761ac849081cda403c2112fda0", size = 1164260, upload-time = "2026-09-09T16:05:24.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/b5/08e2f643feb9e4d72d42a484949ad51238b18262a01c7036196c201cc330/ast_serialize-0.11.1-cp39-abi3-win_arm64.whl", hash = "sha256:a579ea6f473aab3958a64734194b22fbaec108ec45d994c28d3bd721e1e1da32", size = 1137533, upload-time = "2026-09-09T16:05:26.095Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "billiard"
|
||||
version = "4.2.4"
|
||||
@@ -228,15 +165,6 @@ redis = [
|
||||
{ name = "kombu", extra = ["redis"] },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.7.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.1.1"
|
||||
@@ -508,6 +436,7 @@ dependencies = [
|
||||
{ name = "defusedxml" },
|
||||
{ name = "email-validator" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "iso18245" },
|
||||
{ name = "openpyxl" },
|
||||
{ name = "pdfplumber" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
@@ -521,11 +450,11 @@ dependencies = [
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx2" },
|
||||
{ name = "hypothesis" },
|
||||
{ name = "mypy" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
{ name = "ty" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -538,6 +467,7 @@ requires-dist = [
|
||||
{ name = "defusedxml", specifier = ">=0.7" },
|
||||
{ name = "email-validator", specifier = ">=2.1" },
|
||||
{ name = "fastapi", specifier = ">=0.115" },
|
||||
{ name = "iso18245", specifier = ">=1.4.1" },
|
||||
{ name = "openpyxl", specifier = ">=3.1" },
|
||||
{ name = "pdfplumber", specifier = ">=0.11" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
|
||||
@@ -551,11 +481,11 @@ requires-dist = [
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ name = "httpx2", specifier = ">=2.0" },
|
||||
{ name = "hypothesis", specifier = ">=6.112" },
|
||||
{ name = "mypy", specifier = ">=1.10" },
|
||||
{ name = "pytest", specifier = ">=8.2" },
|
||||
{ name = "ruff", specifier = ">=0.5" },
|
||||
{ name = "ty", specifier = ">=0.0.18" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -591,16 +521,16 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
name = "httpcore2"
|
||||
version = "2.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
{ name = "truststore" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/8c/e925b1c92018abb3a1863ce1549d76d2381e334d21d65d4ac8f65dabd78a/httpcore2-2.13.0.tar.gz", hash = "sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db", size = 67740, upload-time = "2026-09-14T14:18:04.717Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/0d/117a771a2bb91df334b66bf4da14cd02f21aefbcfe53180f336ce55e8f90/httpcore2-2.13.0-py3-none-any.whl", hash = "sha256:35ae5be347aa40467b4a5dc032ac67ebb6d27189fc97e8cebcf99616f6a1bb9e", size = 83162, upload-time = "2026-09-14T14:18:02.529Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -626,18 +556,28 @@ wheels = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
name = "httpx2"
|
||||
version = "2.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
|
||||
{ name = "httpcore2", marker = "sys_platform != 'emscripten'" },
|
||||
{ name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
|
||||
{ name = "idna" },
|
||||
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/a0/e9deef4654132857b5a5dbe4eddd0ac59c2814500e11f2f5044cd81103ee/httpx2-2.13.0.tar.gz", hash = "sha256:81bd07dc67a3701729ef1f777a3c00c915d4539604fdb5afd327f8682f6b7b44", size = 100290, upload-time = "2026-09-14T14:18:05.486Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/d1/a0c72b0e006df654709fbc366cc5bcb53e5aee13e1e3395152c6dd293376/httpx2-2.13.0-py3-none-any.whl", hash = "sha256:fc12720cedf72faa26cca6b4ca394e05c894e7d7933fc45cafe767960804e49a", size = 95565, upload-time = "2026-09-14T14:18:03.553Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx2-jsfetch"
|
||||
version = "1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -707,6 +647,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "importlib-resources"
|
||||
version = "5.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a5/f1/8711c49ffd121083007a24c1bff0d324c9ff621d4fdf8b4ffcb8d9e60330/importlib_resources-5.13.0.tar.gz", hash = "sha256:82d5c6cca930697dbbd86c93333bb2c2e72861d4789a11c2662b933e5ad2b528", size = 36550, upload-time = "2023-07-07T16:21:00.171Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/68/bd9dd6bbf06772c7accce77d0354d783333fbe712a60b08fc13540c05422/importlib_resources-5.13.0-py3-none-any.whl", hash = "sha256:9f7bd0c97b79972a6cce36a366356d16d5e13b09679c11a58f1014bfdf8e64b2", size = 32912, upload-time = "2023-07-07T16:20:58.128Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
@@ -716,6 +665,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iso18245"
|
||||
version = "1.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "importlib-resources" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/de/e8095ac5ac026e4ad23c07d1cb35bc1495051d2ae32c863b246d43e1d9a1/iso18245-1.4.1.tar.gz", hash = "sha256:1ee69d2b6b6334e29d9131371970a171649a969fe319ae7b09afcfc2ecb02720", size = 50749, upload-time = "2025-09-03T09:23:07.299Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/5e/ef3e709668014b9ebabb86d05f4848f96ffac406e202c7207b773b3ccc2f/iso18245-1.4.1-py3-none-any.whl", hash = "sha256:07fcac02b22044e5f188a7683684f2166900a36d39119eb6ca0f4ef5f0df5772", size = 66746, upload-time = "2025-09-03T09:23:05.833Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jmespath"
|
||||
version = "1.1.0"
|
||||
@@ -745,45 +706,6 @@ redis = [
|
||||
{ name = "redis" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "librt"
|
||||
version = "0.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.4.1"
|
||||
@@ -826,44 +748,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "2.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ast-serialize" },
|
||||
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openpyxl"
|
||||
version = "3.1.5"
|
||||
@@ -885,15 +769,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfminer-six"
|
||||
version = "20260107"
|
||||
@@ -1292,6 +1167,40 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "truststore"
|
||||
version = "0.10.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.83"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/7d/2fd9575bce2d14e2281bec82d0713dee68329b0dc944e9c06c2e36b761fe/ty-0.0.83.tar.gz", hash = "sha256:db118de73c05ac476faceb4d42d59782feaeddfc4d721fd3ae8b807a0a2ae4e4", size = 7373076, upload-time = "2026-09-21T22:47:29.207Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/85/7259431bade2317d8b82fa27220ae4618c21396acf0ca28a7bd75e92afd7/ty-0.0.83-py3-none-linux_armv6l.whl", hash = "sha256:836bc08bad96ecc7e38baf1a6ddaec15aa8c8b46ae99a96365397b5f6481b7a5", size = 13905669, upload-time = "2026-09-21T22:46:36.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/30/9527386ade8dd71fc52f65cde3c094411af7ad0d505f9063ef0841998702/ty-0.0.83-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f1fb029fe076c7dca404d98e38fdb5fdbc2038a09407f89d43be1fdd9b2c459", size = 13499505, upload-time = "2026-09-21T22:46:40.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/54/63080d8cfd59a53810499a69a2ebc995724be27618b70d7f099e73ace317/ty-0.0.83-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3515776d01ee3b1be974dafa73d449d0bc711afbc12ba341f82f70bc641ff4ee", size = 13498980, upload-time = "2026-09-21T22:46:43.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/78/e647f190bd9513d09db30d45309e9b5df070b5caa44af2de3290fab59fbe/ty-0.0.83-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5b80d66c7f9c8d60f25ccfd40c6eaebe8fd7c9005074904e0e164dd89c13d03", size = 13494705, upload-time = "2026-09-21T22:46:46.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/12/aef645b13c5bd15e3e5993537dbc23f7d65475f3cf1755d07b4d3e56850d/ty-0.0.83-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:57eae4aa55dab1af5d1423227c6517ccab1e658fe265c8e5a58caa584ca2876b", size = 13661466, upload-time = "2026-09-21T22:46:49.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/c4/11872e823653c568468ef7f0c3b23aed7ad2d33652e20144d28d5bfad58a/ty-0.0.83-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dea3680911340ecf93dff5786dbeefd69c9399df685a22751e1fbd27e6007b50", size = 14479677, upload-time = "2026-09-21T22:46:52.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/db/5b0d0d24d16d24d781f29ee842ee718903e453dc4068acb02bef860d5540/ty-0.0.83-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8453786c274f48e2a89b78e8a505a4f449323f357f2ce26a9b90a4bd68176f67", size = 14972208, upload-time = "2026-09-21T22:46:55.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/04/0077a53bcf6ceb41365a45d8091b52094c91a5f4b071c2a6d705ed8bb65b/ty-0.0.83-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7701bdae3b52271cf0835abe4202aa3b23b735425d8c5a3e6a8ca1c68d39ead8", size = 14660550, upload-time = "2026-09-21T22:46:58.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/ff/b6c879f1c3121387edd19ebdb1fb02af30691f88f632278a5d467fb5f348/ty-0.0.83-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa95b4376447e5309fe3ccc98ee75916a9082714e6a029b99427ca62021dc94a", size = 14147376, upload-time = "2026-09-21T22:47:01.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/bc/82e730247d53cb02acd5a91237c0c62f215b2e0379689a550176d307ed89/ty-0.0.83-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2a4550a08fc1899a33e4a08787a73ec9daa99d292268df4f0a5f1c24c63854c3", size = 14567502, upload-time = "2026-09-21T22:47:04.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/13/bb6f57923231f51b626931625ada71e219d64541f8fadf96065bc5034358/ty-0.0.83-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:feb72080f0835fa2b27485e3b11fa50dc57c30e0ba73808a3f7d64b56db83f6f", size = 13443051, upload-time = "2026-09-21T22:47:07.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/20/9862431662d4453527cd52cf0f30d5975cd1801aa39e570bf45fb299be77/ty-0.0.83-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3e5206def2d6e1df93c75ed8dc763255233ccc3153647509d125bada10ad770a", size = 13678908, upload-time = "2026-09-21T22:47:10.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/e3/94ebbe1573b76660fa09f2dbce96da6d0bb69a2d29c7b6ad538b452979d4/ty-0.0.83-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bdf5dfa157f5c127ba096fe0561b47877e6e79562c7fc2b35906ba5e9f6bb723", size = 13937248, upload-time = "2026-09-21T22:47:14.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/c0/3ea45c2ec3a82fddb7137e457ccb2a452446905430d225adaa4990cdc338/ty-0.0.83-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:626d6ad00f05a5d9b7ea80de44b12931d0c0fb50e64eab20b7fcc13e9aff2a68", size = 14290826, upload-time = "2026-09-21T22:47:16.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/b2/c941ce83a59e46ef31dd04bdf6067530e7ec77d5f0cdd1e223fbc6fc351b/ty-0.0.83-py3-none-win32.whl", hash = "sha256:0391017dac6ba3e452e0322301f50bba12fbccadac07d7fd888c46951ad37638", size = 13105291, upload-time = "2026-09-21T22:47:19.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/8a/5eef9458505698b144643cbd2062f465c5d860d8cf86a8710d74505ad9a0/ty-0.0.83-py3-none-win_amd64.whl", hash = "sha256:1b67d86f5bbad4076ae1a2ff23a6a4493219885d72d4b148cb0193e090390060", size = 13901557, upload-time = "2026-09-21T22:47:23.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/27/a06061513f1de25569c2e8898018bd552ab7b31afa7cfe818703269b3350/ty-0.0.83-py3-none-win_arm64.whl", hash = "sha256:a916a3d7d68fe30b7aacdb9fe16d461fa944d722e59e96ea0fa9d206a1b6625f", size = 13627960, upload-time = "2026-09-21T22:47:26.305Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
|
||||
@@ -218,16 +218,19 @@ export interface CategoryDefinition {
|
||||
systemKey: string | null
|
||||
linkedAccountId: string | null
|
||||
archived: boolean
|
||||
mccCodes: number[]
|
||||
}
|
||||
|
||||
export interface CreateCategoryPayload {
|
||||
name: string
|
||||
kind: CreatableCategoryKind
|
||||
mccCodes?: number[]
|
||||
}
|
||||
|
||||
export interface PatchCategoryDefinitionPayload {
|
||||
name?: string
|
||||
archived?: boolean
|
||||
mccCodes?: number[]
|
||||
}
|
||||
|
||||
export type ImportStatus =
|
||||
@@ -291,6 +294,8 @@ export interface ImportRow {
|
||||
direction: 'in' | 'out'
|
||||
bankStatus: RowBankStatus
|
||||
description: string | null
|
||||
mcc: number | null
|
||||
mccName: string | null
|
||||
payeeHint: string | null
|
||||
warnings: string[]
|
||||
decision: RowDecision
|
||||
@@ -322,13 +327,12 @@ export interface SaveRowDecisionPayload {
|
||||
|
||||
export interface ResolveHoldPayload {
|
||||
action: HoldResolutionAction
|
||||
reason: string
|
||||
reason?: string
|
||||
accountId?: string
|
||||
}
|
||||
|
||||
export interface ConfirmImportPayload {
|
||||
confirmedWithoutFullReconciliation?: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface CategorySpendItem {
|
||||
|
||||
@@ -19,26 +19,42 @@ import { categoryLabel } from '@/lib/categoryLabel'
|
||||
|
||||
const CREATE_KINDS: readonly CreatableCategoryKind[] = ['monthly', 'savings_envelope']
|
||||
|
||||
function parseMccCodes(value: string): number[] {
|
||||
return value.split(',').map((code) => Number(code.trim()))
|
||||
}
|
||||
|
||||
function CreateCategoryForm() {
|
||||
const { t } = useTranslation()
|
||||
const create = useCreateCategory()
|
||||
const schema = z.object({
|
||||
name: z.string().trim().min(1, t('common.requiredField')),
|
||||
kind: z.enum(['monthly', 'savings_envelope']),
|
||||
mccCodes: z.string().trim().regex(/^(?:\d{3,4}(?:\s*,\s*\d{3,4})*)?$/, t('budget.manager.mccCodesInvalid')).optional(),
|
||||
})
|
||||
type Values = z.infer<typeof schema>
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { name: '', kind: 'monthly' },
|
||||
defaultValues: { name: '', kind: 'monthly', mccCodes: '' },
|
||||
shouldUnregister: true,
|
||||
})
|
||||
|
||||
const submit = handleSubmit((values) => {
|
||||
create.mutate(values, { onSuccess: () => reset({ name: '', kind: values.kind }) })
|
||||
create.mutate(
|
||||
{
|
||||
name: values.name,
|
||||
kind: values.kind,
|
||||
...(values.kind === 'monthly' && values.mccCodes
|
||||
? { mccCodes: parseMccCodes(values.mccCodes) }
|
||||
: {}),
|
||||
},
|
||||
{ onSuccess: () => reset({ name: '', kind: values.kind, mccCodes: '' }) },
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -55,6 +71,14 @@ function CreateCategoryForm() {
|
||||
</option>
|
||||
))}
|
||||
</SelectField>
|
||||
{watch('kind') === 'monthly' ? (
|
||||
<TextField
|
||||
label={t('budget.manager.mccCodes')}
|
||||
hint={t('budget.manager.mccCodesHint')}
|
||||
error={errors.mccCodes?.message}
|
||||
{...register('mccCodes')}
|
||||
/>
|
||||
) : null}
|
||||
<Button type="submit" disabled={create.isPending}>
|
||||
{create.isPending ? t('common.saving') : t('common.create')}
|
||||
</Button>
|
||||
@@ -103,12 +127,59 @@ function RenameModal({
|
||||
)
|
||||
}
|
||||
|
||||
function MccCodesModal({
|
||||
definition,
|
||||
onClose,
|
||||
}: {
|
||||
definition: CategoryDefinition
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const update = useUpdateCategoryDefinition()
|
||||
const schema = z.object({
|
||||
mccCodes: z.string().trim().regex(/^(?:\d{3,4}(?:\s*,\s*\d{3,4})*)?$/, t('budget.manager.mccCodesInvalid')),
|
||||
})
|
||||
type Values = z.infer<typeof schema>
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { mccCodes: definition.mccCodes.map((code) => String(code).padStart(4, '0')).join(', ') },
|
||||
})
|
||||
|
||||
const submit = handleSubmit((values) => {
|
||||
update.mutate(
|
||||
{ id: definition.id, payload: { mccCodes: values.mccCodes ? parseMccCodes(values.mccCodes) : [] } },
|
||||
{ onSuccess: onClose },
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={t('budget.manager.editMccCodesTitle')}>
|
||||
<form onSubmit={submit} noValidate className="form-grid">
|
||||
<TextField
|
||||
label={t('budget.manager.mccCodes')}
|
||||
hint={t('budget.manager.mccCodesHint')}
|
||||
error={errors.mccCodes?.message}
|
||||
{...register('mccCodes')}
|
||||
/>
|
||||
<Button type="submit" disabled={update.isPending}>
|
||||
{update.isPending ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export function CategoryManagerModal({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const definitions = useCategoryDefinitions()
|
||||
const update = useUpdateCategoryDefinition()
|
||||
const [tab, setTab] = useState<'active' | 'archived'>('active')
|
||||
const [renameTarget, setRenameTarget] = useState<CategoryDefinition | null>(null)
|
||||
const [mccTarget, setMccTarget] = useState<CategoryDefinition | null>(null)
|
||||
const [archiveTarget, setArchiveTarget] = useState<CategoryDefinition | null>(null)
|
||||
return (
|
||||
<Modal open onClose={onClose} title={t('budget.manager.title')}>
|
||||
@@ -161,11 +232,18 @@ export function CategoryManagerModal({ onClose }: { onClose: () => void }) {
|
||||
{t(`budget.category.kind.${definition.kind}`)}
|
||||
{isSystem ? ` · ${t('budget.manager.systemProtected')}` : ''}
|
||||
</span>
|
||||
{definition.mccCodes.length > 0 ? (
|
||||
<details className="manager-row-meta">
|
||||
<summary>{t('budget.manager.mccCodesCount', { count: definition.mccCodes.length })}</summary>
|
||||
{definition.mccCodes.map((code) => String(code).padStart(4, '0')).join(', ')}
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
{!isSystem ? (
|
||||
{!isSystem || (definition.kind === 'monthly' && !definition.archived) ? (
|
||||
<div className="inline-actions manager-row-actions">
|
||||
{!definition.archived ? (
|
||||
<>
|
||||
{!isSystem ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
disabled={update.isPending}
|
||||
@@ -173,6 +251,17 @@ export function CategoryManagerModal({ onClose }: { onClose: () => void }) {
|
||||
>
|
||||
{t('budget.manager.rename')}
|
||||
</Button>
|
||||
) : null}
|
||||
{definition.kind === 'monthly' ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
disabled={update.isPending}
|
||||
onClick={() => setMccTarget(definition)}
|
||||
>
|
||||
{t('budget.manager.editMccCodes')}
|
||||
</Button>
|
||||
) : null}
|
||||
{!isSystem ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
disabled={update.isPending}
|
||||
@@ -180,6 +269,7 @@ export function CategoryManagerModal({ onClose }: { onClose: () => void }) {
|
||||
>
|
||||
{t('budget.manager.archive')}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
@@ -204,6 +294,9 @@ export function CategoryManagerModal({ onClose }: { onClose: () => void }) {
|
||||
{renameTarget ? (
|
||||
<RenameModal definition={renameTarget} onClose={() => setRenameTarget(null)} />
|
||||
) : null}
|
||||
{mccTarget ? (
|
||||
<MccCodesModal definition={mccTarget} onClose={() => setMccTarget(null)} />
|
||||
) : null}
|
||||
<ConfirmDialog
|
||||
open={archiveTarget !== null}
|
||||
danger
|
||||
|
||||
@@ -67,6 +67,8 @@ export function useUpdateCategoryDefinition() {
|
||||
toast.push('success', t('toast.categoryArchived'))
|
||||
} else if (variables.payload.archived === false) {
|
||||
toast.push('success', t('toast.categoryRestored'))
|
||||
} else if (variables.payload.mccCodes !== undefined) {
|
||||
toast.push('success', t('toast.categoryMccUpdated'))
|
||||
} else {
|
||||
toast.push('success', t('toast.categoryRenamed', { name: definition.name ?? '' }))
|
||||
}
|
||||
|
||||
@@ -254,6 +254,12 @@
|
||||
"systemBadge": "System",
|
||||
"systemProtected": "renaming and archiving are unavailable",
|
||||
"rename": "Rename",
|
||||
"mccCodes": "MCC codes",
|
||||
"mccCodesHint": "Optional: comma-separated 3- or 4-digit codes",
|
||||
"mccCodesInvalid": "Enter 3- or 4-digit MCC codes separated by commas",
|
||||
"mccCodesCount": "MCC codes ({{count}})",
|
||||
"editMccCodes": "Edit MCC",
|
||||
"editMccCodesTitle": "Category MCC codes",
|
||||
"archive": "Archive",
|
||||
"restore": "Restore",
|
||||
"renameTitle": "Rename category",
|
||||
@@ -368,7 +374,7 @@
|
||||
"holdCancelledTitle": "Operation cancelled",
|
||||
"holdCancelledBody": "The held operation of {{date}} for {{amount}} will be marked cancelled and never enters the ledger.",
|
||||
"holdReason": "Resolution reason",
|
||||
"holdReasonHint": "Required; kept in the audit journal",
|
||||
"holdReasonHint": "Optional; you can leave it blank",
|
||||
"holdResolution.confirmed_as_posted": "Posted",
|
||||
"holdResolution.discarded_as_cancelled": "Cancelled",
|
||||
"warnings": "Warnings ({{count}})",
|
||||
@@ -389,6 +395,7 @@
|
||||
"rowsTitle": "Statement rows",
|
||||
"noRows": "No rows.",
|
||||
"rowWarnings": "Notes",
|
||||
"mcc": "MCC",
|
||||
"rowWarnings.amount": "amount",
|
||||
"rowWarnings.date": "date",
|
||||
"rowWarnings.direction": "direction",
|
||||
@@ -405,7 +412,6 @@
|
||||
"confirmTitle": "Apply import",
|
||||
"confirmBody": "Operations will be created from the selected rows. Applying is atomic and can be reverted.",
|
||||
"confirmWithout": "I confirm applying without full balance reconciliation",
|
||||
"confirmReason": "Reason",
|
||||
"reparse": "Reparse",
|
||||
"revert": "Revert import",
|
||||
"revertTitle": "Revert import",
|
||||
@@ -463,6 +469,7 @@
|
||||
"savingsConversionReverted": "Transfer reverted to expense",
|
||||
"categoryCreated": "Category “{{name}}” created",
|
||||
"categoryRenamed": "Category renamed to “{{name}}”",
|
||||
"categoryMccUpdated": "Category MCC codes updated",
|
||||
"categoryArchived": "Category archived",
|
||||
"categoryRestored": "Category restored",
|
||||
"planUpdated": "Plan updated",
|
||||
@@ -491,12 +498,27 @@
|
||||
"categories": {
|
||||
"uncategorized": "Uncategorized",
|
||||
"groceries": "Groceries",
|
||||
"cafes": "Cafes and restaurants",
|
||||
"transport": "Transport",
|
||||
"fuel": "Fuel and gas stations",
|
||||
"housing": "Housing",
|
||||
"utilities": "Utilities",
|
||||
"health": "Health",
|
||||
"electronics": "Electronics",
|
||||
"clothing": "Clothing and footwear",
|
||||
"beauty": "Beauty and personal care",
|
||||
"education": "Education",
|
||||
"entertainment": "Entertainment",
|
||||
"sport": "Sports and fitness",
|
||||
"travel": "Travel",
|
||||
"home": "Home improvement",
|
||||
"telecom": "Phone and internet",
|
||||
"kids": "Children",
|
||||
"pets": "Pets",
|
||||
"gifts": "Gifts and hobbies",
|
||||
"auto": "Automotive",
|
||||
"finance": "Finance and insurance",
|
||||
"others": "Other",
|
||||
"eating_out": "Eating out",
|
||||
"clothes": "Clothes",
|
||||
"personal": "Personal",
|
||||
|
||||
@@ -254,6 +254,12 @@
|
||||
"systemBadge": "Системная",
|
||||
"systemProtected": "переименование и архивирование недоступны",
|
||||
"rename": "Переименовать",
|
||||
"mccCodes": "Коды МСС",
|
||||
"mccCodesHint": "Необязательно: 3–4-значные коды через запятую",
|
||||
"mccCodesInvalid": "Укажите 3–4-значные коды МСС через запятую",
|
||||
"mccCodesCount": "Коды МСС ({{count}})",
|
||||
"editMccCodes": "Изменить МСС",
|
||||
"editMccCodesTitle": "Коды МСС категории",
|
||||
"archive": "В архив",
|
||||
"restore": "Восстановить",
|
||||
"renameTitle": "Переименовать категорию",
|
||||
@@ -368,7 +374,7 @@
|
||||
"holdCancelledTitle": "Операция отменена",
|
||||
"holdCancelledBody": "Замороженная операция от {{date}} на {{amount}} будет помечена отменённой и не попадёт в операции.",
|
||||
"holdReason": "Причина решения",
|
||||
"holdReasonHint": "Обязательно; попадёт в журнал аудита",
|
||||
"holdReasonHint": "Необязательно, можно оставить пустым",
|
||||
"holdResolution.confirmed_as_posted": "Проведена",
|
||||
"holdResolution.discarded_as_cancelled": "Отменена",
|
||||
"warnings": "Предупреждения ({{count}})",
|
||||
@@ -389,6 +395,7 @@
|
||||
"rowsTitle": "Строки выписки",
|
||||
"noRows": "Строк нет.",
|
||||
"rowWarnings": "Замечания",
|
||||
"mcc": "МСС",
|
||||
"rowWarnings.amount": "сумма",
|
||||
"rowWarnings.date": "дата",
|
||||
"rowWarnings.direction": "направление",
|
||||
@@ -405,7 +412,6 @@
|
||||
"confirmTitle": "Применить импорт",
|
||||
"confirmBody": "Будут созданы операции по выбранным строкам. Применение атомарно, его можно отменить.",
|
||||
"confirmWithout": "Подтверждаю применение без полной сверки остатков",
|
||||
"confirmReason": "Причина",
|
||||
"reparse": "Переразобрать",
|
||||
"revert": "Отменить применение",
|
||||
"revertTitle": "Отменить применение импорта",
|
||||
@@ -465,6 +471,7 @@
|
||||
"savingsConversionReverted": "Перевод возвращён в расходы",
|
||||
"categoryCreated": "Категория «{{name}}» создана",
|
||||
"categoryRenamed": "Категория переименована в «{{name}}»",
|
||||
"categoryMccUpdated": "Коды МСС категории обновлены",
|
||||
"categoryArchived": "Категория отправлена в архив",
|
||||
"categoryRestored": "Категория восстановлена",
|
||||
"planUpdated": "План обновлён",
|
||||
@@ -493,12 +500,27 @@
|
||||
"categories": {
|
||||
"uncategorized": "Без категории",
|
||||
"groceries": "Продукты",
|
||||
"cafes": "Кафе и рестораны",
|
||||
"transport": "Транспорт",
|
||||
"fuel": "Топливо и АЗС",
|
||||
"housing": "Жильё",
|
||||
"utilities": "Коммунальные",
|
||||
"health": "Здоровье",
|
||||
"electronics": "Электроника",
|
||||
"clothing": "Одежда и обувь",
|
||||
"beauty": "Красота и уход",
|
||||
"education": "Образование",
|
||||
"entertainment": "Развлечения",
|
||||
"sport": "Спорт и фитнес",
|
||||
"travel": "Путешествия",
|
||||
"home": "Дом и ремонт",
|
||||
"telecom": "Связь и интернет",
|
||||
"kids": "Дети",
|
||||
"pets": "Питомцы",
|
||||
"gifts": "Подарки и хобби",
|
||||
"auto": "Авто",
|
||||
"finance": "Финансы и страхование",
|
||||
"others": "Остальное",
|
||||
"eating_out": "Кафе и рестораны",
|
||||
"clothes": "Одежда",
|
||||
"personal": "Личное",
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { z } from 'zod'
|
||||
import type { HoldResolutionAction, ImportRow, RowDecision } from '@/api/types'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
|
||||
import { TextareaField, TextField } from '@/components/ui/fields'
|
||||
import { TextareaField } from '@/components/ui/fields'
|
||||
import { Modal } from '@/components/ui/Modal'
|
||||
import { PageHeader } from '@/components/ui/PageHeader'
|
||||
import { QueryGate } from '@/components/ui/QueryGate'
|
||||
@@ -56,25 +53,15 @@ function HoldResolveModal({
|
||||
const { t, i18n } = useTranslation()
|
||||
const locale = currentLocale(i18n.language)
|
||||
const resolve = useResolveHold(importId)
|
||||
const schema = z.object({
|
||||
reason: z.string().trim().min(1, t('common.requiredField')),
|
||||
})
|
||||
type Values = z.infer<typeof schema>
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<Values>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { reason: '' },
|
||||
})
|
||||
const [reason, setReason] = useState('')
|
||||
|
||||
const submit = handleSubmit((values) => {
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
resolve.mutate(
|
||||
{ rowId: row.id, payload: { action, reason: values.reason } },
|
||||
{ rowId: row.id, payload: { action, reason: reason.trim() || undefined } },
|
||||
{ onSuccess: onClose },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -101,8 +88,8 @@ function HoldResolveModal({
|
||||
<TextareaField
|
||||
label={t('imports.holdReason')}
|
||||
hint={t('imports.holdReasonHint')}
|
||||
error={errors.reason?.message}
|
||||
{...register('reason')}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
<Button type="submit" variant="primary" disabled={resolve.isPending}>
|
||||
{resolve.isPending ? t('common.saving') : t('common.confirm')}
|
||||
@@ -124,7 +111,6 @@ function ConfirmModal({
|
||||
const { t } = useTranslation()
|
||||
const confirm = useConfirmImport(importId)
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
const [reason, setReason] = useState('')
|
||||
|
||||
const disabled = needsConfirmation && !acknowledged
|
||||
|
||||
@@ -142,20 +128,14 @@ function ConfirmModal({
|
||||
/>
|
||||
{t('imports.confirmWithout')}
|
||||
</label>
|
||||
<TextField
|
||||
label={t('imports.confirmReason')}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<Button
|
||||
disabled={disabled || confirm.isPending}
|
||||
onClick={() =>
|
||||
confirm.mutate(
|
||||
needsConfirmation ? { confirmedWithoutFullReconciliation: true, reason: reason || undefined } : {},
|
||||
{ onSuccess: onClose },
|
||||
)
|
||||
confirm.mutate(needsConfirmation ? { confirmedWithoutFullReconciliation: true } : {}, {
|
||||
onSuccess: onClose,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('imports.confirm')}
|
||||
@@ -334,6 +314,9 @@ export function ImportDetailPage() {
|
||||
<td data-label={t('operations.description')} className="statement-description">
|
||||
{row.description ?? '—'}
|
||||
{row.payeeHint ? <span className="muted"> · {row.payeeHint}</span> : null}
|
||||
{row.mcc !== null ? (
|
||||
<span className="muted"> · {t('imports.mcc')} {row.mcc}{row.mccName ? ` · ${row.mccName}` : ''}</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td
|
||||
data-label={t('common.amount')}
|
||||
|
||||
@@ -70,10 +70,21 @@ const diningDefinition: CategoryDefinition = {
|
||||
systemKey: null,
|
||||
linkedAccountId: null,
|
||||
archived: false,
|
||||
mccCodes: [5999, 5994],
|
||||
}
|
||||
|
||||
const definitions: CategoryDefinition[] = [
|
||||
diningDefinition,
|
||||
{
|
||||
id: 'def-groceries',
|
||||
name: 'Продукты и супермаркеты',
|
||||
nameKey: 'groceries',
|
||||
kind: 'monthly',
|
||||
systemKey: 'groceries',
|
||||
linkedAccountId: null,
|
||||
archived: false,
|
||||
mccCodes: [5411, 5422],
|
||||
},
|
||||
{
|
||||
id: 'def-savings',
|
||||
name: 'Накопления',
|
||||
@@ -82,6 +93,7 @@ const definitions: CategoryDefinition[] = [
|
||||
systemKey: 'savings',
|
||||
linkedAccountId: 'a-sys',
|
||||
archived: false,
|
||||
mccCodes: [],
|
||||
},
|
||||
{
|
||||
id: 'def-old-gym',
|
||||
@@ -91,6 +103,7 @@ const definitions: CategoryDefinition[] = [
|
||||
systemKey: null,
|
||||
linkedAccountId: null,
|
||||
archived: true,
|
||||
mccCodes: [],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -129,6 +142,75 @@ describe('BudgetPage category manager lifecycle', () => {
|
||||
expect(await screen.findByText('Category “Dining” created')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates a category with multiple MCC codes', async () => {
|
||||
createCategoryMock.mockResolvedValue(definition({ id: 'def-new', mccCodes: [5999, 5994] }))
|
||||
const { dialog } = await openManager()
|
||||
await userEvent.type(within(dialog).getByLabelText('Name'), 'Shops')
|
||||
await userEvent.type(within(dialog).getByLabelText('MCC codes'), '5999, 5994')
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
|
||||
expect(createCategoryMock).toHaveBeenCalledWith({
|
||||
name: 'Shops',
|
||||
kind: 'monthly',
|
||||
mccCodes: [5999, 5994],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not offer MCC mapping for a savings envelope', async () => {
|
||||
createCategoryMock.mockResolvedValue(definition({ kind: 'savings_envelope' }))
|
||||
const { dialog } = await openManager()
|
||||
await userEvent.type(within(dialog).getByLabelText('Name'), 'Trip')
|
||||
await userEvent.type(within(dialog).getByLabelText('MCC codes'), '5999')
|
||||
await userEvent.selectOptions(within(dialog).getByRole('combobox', { name: 'Type' }), 'savings_envelope')
|
||||
expect(within(dialog).queryByLabelText('MCC codes')).not.toBeInTheDocument()
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
|
||||
expect(createCategoryMock).toHaveBeenCalledWith({ name: 'Trip', kind: 'savings_envelope' })
|
||||
})
|
||||
|
||||
it('lets users release an MCC code from a pre-created category', async () => {
|
||||
updateDefinitionMock.mockResolvedValue(definitions[1]!)
|
||||
const { screen, dialog } = await openManager()
|
||||
const groceriesRow = (await within(dialog).findByText('Groceries')).closest('li') as HTMLElement
|
||||
expect(within(groceriesRow).queryByRole('button', { name: 'Rename' })).not.toBeInTheDocument()
|
||||
await userEvent.click(within(groceriesRow).getByRole('button', { name: 'Edit MCC' }))
|
||||
const editor = await screen.findByRole('dialog', { name: 'Category MCC codes' })
|
||||
const input = within(editor).getByLabelText('MCC codes')
|
||||
await userEvent.clear(input)
|
||||
await userEvent.type(input, '5422')
|
||||
await userEvent.click(within(editor).getByRole('button', { name: 'Save' }))
|
||||
expect(updateDefinitionMock).toHaveBeenCalledWith('def-groceries', { mccCodes: [5422] })
|
||||
})
|
||||
|
||||
it('shows and updates multiple MCC codes on a category', async () => {
|
||||
updateDefinitionMock.mockResolvedValue(definition({ mccCodes: [5999, 5994, 5992] }))
|
||||
const { screen, dialog } = await openManager()
|
||||
const diningRow = (await within(dialog).findByText('Dining')).closest('li') as HTMLElement
|
||||
await userEvent.click(within(diningRow).getByText('MCC codes (2)'))
|
||||
expect(within(diningRow).getByText('5999, 5994')).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(within(diningRow).getByRole('button', { name: 'Edit MCC' }))
|
||||
const editor = await screen.findByRole('dialog', { name: 'Category MCC codes' })
|
||||
const input = within(editor).getByLabelText('MCC codes')
|
||||
await userEvent.clear(input)
|
||||
await userEvent.type(input, '5999, 5994, 5992')
|
||||
await userEvent.click(within(editor).getByRole('button', { name: 'Save' }))
|
||||
expect(updateDefinitionMock).toHaveBeenCalledWith('def-dining', {
|
||||
mccCodes: [5999, 5994, 5992],
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a three-digit MCC code', async () => {
|
||||
updateDefinitionMock.mockResolvedValue(definition({ mccCodes: [742] }))
|
||||
const { screen, dialog } = await openManager()
|
||||
const diningRow = (await within(dialog).findByText('Dining')).closest('li') as HTMLElement
|
||||
await userEvent.click(within(diningRow).getByRole('button', { name: 'Edit MCC' }))
|
||||
const editor = await screen.findByRole('dialog', { name: 'Category MCC codes' })
|
||||
const input = within(editor).getByLabelText('MCC codes')
|
||||
await userEvent.clear(input)
|
||||
await userEvent.type(input, '742')
|
||||
await userEvent.click(within(editor).getByRole('button', { name: 'Save' }))
|
||||
expect(updateDefinitionMock).toHaveBeenCalledWith('def-dining', { mccCodes: [742] })
|
||||
})
|
||||
|
||||
it('renames and archives a user category through inline actions', async () => {
|
||||
updateDefinitionMock.mockResolvedValue(definition({ name: 'Eating out' }))
|
||||
const { screen, dialog } = await openManager()
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { within } from '@testing-library/react'
|
||||
import { api } from '@/api/endpoints'
|
||||
import type { ImportDetail, ImportRow } from '@/api/types'
|
||||
import { ImportDetailPage } from '@/pages/ImportDetailPage'
|
||||
import { renderWithProviders } from './helpers'
|
||||
|
||||
vi.mock('@/api/endpoints', () => ({
|
||||
api: {
|
||||
imports: {
|
||||
list: vi.fn(),
|
||||
get: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
saveRowDecision: vi.fn(),
|
||||
resolveHold: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
reparse: vi.fn(),
|
||||
revert: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const getImportMock = vi.mocked(api.imports.get)
|
||||
const confirmMock = vi.mocked(api.imports.confirm)
|
||||
|
||||
function postedRow(): ImportRow {
|
||||
return {
|
||||
id: 'row-1',
|
||||
externalId: 'ext-1',
|
||||
occurredOn: '2026-09-10',
|
||||
amountKopecks: 150000,
|
||||
direction: 'out',
|
||||
bankStatus: 'posted',
|
||||
description: 'Coffee beans',
|
||||
mcc: null,
|
||||
mccName: null,
|
||||
payeeHint: null,
|
||||
warnings: [],
|
||||
decision: 'create',
|
||||
duplicateOfOperationId: null,
|
||||
holdResolution: null,
|
||||
}
|
||||
}
|
||||
|
||||
function detailFixture(reconciliationStatus: 'matched' | 'mismatched'): ImportDetail {
|
||||
const rows = [postedRow()]
|
||||
return {
|
||||
id: 'imp-1',
|
||||
fileName: 'alfa.xlsx',
|
||||
bankAdapter: 'alfa_debit_xlsx',
|
||||
status: 'ready',
|
||||
accountId: 'a1',
|
||||
accountName: 'Alfa Card',
|
||||
periodFrom: null,
|
||||
periodTo: null,
|
||||
unresolvedHoldCount: 0,
|
||||
createdAt: '2026-09-01T10:00:00Z',
|
||||
failureReason: null,
|
||||
blockers: [],
|
||||
warnings: [],
|
||||
reconciliation: {
|
||||
status: reconciliationStatus,
|
||||
openingBalanceKopecks: null,
|
||||
closingBalanceKopecks: null,
|
||||
computedClosingKopecks: null,
|
||||
reason: null,
|
||||
},
|
||||
rows,
|
||||
totals: {
|
||||
total: rows.length,
|
||||
create: rows.length,
|
||||
discard: 0,
|
||||
duplicate: 0,
|
||||
transfer: 0,
|
||||
savingsTransfer: 0,
|
||||
pending: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('ImportDetailPage apply import', () => {
|
||||
beforeEach(() => {
|
||||
getImportMock.mockResolvedValue(detailFixture('matched'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('applies the import without a reason field', async () => {
|
||||
confirmMock.mockResolvedValue(detailFixture('matched'))
|
||||
const screen = renderWithProviders(<ImportDetailPage />)
|
||||
await screen.findByRole('button', { name: 'Apply import' })
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Apply import' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Apply import' })
|
||||
|
||||
expect(within(dialog).queryAllByRole('textbox')).toHaveLength(0)
|
||||
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Apply import' }))
|
||||
|
||||
expect(confirmMock).toHaveBeenCalledTimes(1)
|
||||
expect(confirmMock).toHaveBeenCalledWith('imp-1', {})
|
||||
})
|
||||
|
||||
it('applies without reconciliation and never sends a reason', async () => {
|
||||
getImportMock.mockResolvedValue(detailFixture('mismatched'))
|
||||
confirmMock.mockResolvedValue(detailFixture('mismatched'))
|
||||
const screen = renderWithProviders(<ImportDetailPage />)
|
||||
await screen.findByRole('button', { name: 'Apply import' })
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Apply import' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Apply import' })
|
||||
|
||||
expect(within(dialog).queryAllByRole('textbox')).toHaveLength(0)
|
||||
|
||||
const submit = within(dialog).getByRole('button', { name: 'Apply import' })
|
||||
expect(submit).toBeDisabled()
|
||||
|
||||
await userEvent.click(
|
||||
within(dialog).getByRole('checkbox', {
|
||||
name: 'I confirm applying without full balance reconciliation',
|
||||
}),
|
||||
)
|
||||
expect(submit).toBeEnabled()
|
||||
await userEvent.click(submit)
|
||||
|
||||
expect(confirmMock).toHaveBeenCalledTimes(1)
|
||||
expect(confirmMock).toHaveBeenCalledWith('imp-1', {
|
||||
confirmedWithoutFullReconciliation: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -33,6 +33,8 @@ function row(overrides: Partial<ImportRow>): ImportRow {
|
||||
direction: 'out',
|
||||
bankStatus: 'posted',
|
||||
description: 'Coffee beans',
|
||||
mcc: null,
|
||||
mccName: null,
|
||||
payeeHint: null,
|
||||
warnings: [],
|
||||
decision: 'create',
|
||||
@@ -124,7 +126,15 @@ describe('ImportDetailPage HOLD resolution', () => {
|
||||
expect(screen.getByRole('button', { name: 'Operation cancelled' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('requires a reason and resolves a hold as posted', async () => {
|
||||
it('labels imported MCC codes even without a dedicated category', async () => {
|
||||
getImportMock.mockResolvedValue(
|
||||
detailFixture([row({ mcc: 7995, mccName: 'Лотереи и азартные игры' })]),
|
||||
)
|
||||
const screen = renderWithProviders(<ImportDetailPage />)
|
||||
expect(await screen.findByText(/MCC 7995 · Лотереи и азартные игры/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('resolves a hold as posted without requiring a reason', async () => {
|
||||
resolveHoldMock.mockResolvedValue(detailFixture([row({}), resolvedHold]))
|
||||
const screen = renderWithProviders(<ImportDetailPage />)
|
||||
await screen.findByRole('button', { name: 'Operation posted' })
|
||||
@@ -132,17 +142,30 @@ describe('ImportDetailPage HOLD resolution', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Operation posted' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Operation posted' })
|
||||
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Confirm' }))
|
||||
expect(await screen.findByText('Required field')).toBeInTheDocument()
|
||||
expect(resolveHoldMock).not.toHaveBeenCalled()
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Resolution reason'), 'Charge cleared at the bank')
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Confirm' }))
|
||||
|
||||
expect(resolveHoldMock).toHaveBeenCalledTimes(1)
|
||||
expect(resolveHoldMock).toHaveBeenCalledWith('imp-1', 'row-hold', {
|
||||
action: 'confirm_as_posted',
|
||||
reason: 'Charge cleared at the bank',
|
||||
reason: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the optional reason when provided', async () => {
|
||||
resolveHoldMock.mockResolvedValue(detailFixture([row({}), resolvedHold]))
|
||||
const screen = renderWithProviders(<ImportDetailPage />)
|
||||
await screen.findByRole('button', { name: 'Operation cancelled' })
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Operation cancelled' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Operation cancelled' })
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Resolution reason'), 'Duplicate charge')
|
||||
await userEvent.click(within(dialog).getByRole('button', { name: 'Confirm' }))
|
||||
|
||||
expect(resolveHoldMock).toHaveBeenCalledTimes(1)
|
||||
expect(resolveHoldMock).toHaveBeenCalledWith('imp-1', 'row-hold', {
|
||||
action: 'discard_as_cancelled',
|
||||
reason: 'Duplicate charge',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"0": "identity/service.py",
|
||||
"0": "categorization/service.py",
|
||||
"1": "budgeting/router.py",
|
||||
"2": "Base",
|
||||
"3": "exports/service.py",
|
||||
"4": "imports/service.py",
|
||||
"4": "imports/router.py",
|
||||
"5": "alfa_xlsx.py",
|
||||
"6": "system-account-picker.test.tsx",
|
||||
"7": "types.ts",
|
||||
@@ -14,14 +14,14 @@
|
||||
"12": "BudgetPage.tsx",
|
||||
"13": "@testing-library/user-event",
|
||||
"14": "devDependencies",
|
||||
"15": "Session",
|
||||
"15": "AppError",
|
||||
"16": "dependencies",
|
||||
"17": "ImportDetailPage.tsx",
|
||||
"18": "OperationsPage.tsx",
|
||||
"19": "useAccounts.ts",
|
||||
"20": "index.ts",
|
||||
"19": "useOperations.ts",
|
||||
"20": "apiErrors.ts",
|
||||
"21": "test_adapter_contracts.py",
|
||||
"22": "AccountsPage.tsx",
|
||||
"22": "useBudget.ts",
|
||||
"23": "client.ts",
|
||||
"24": "fields.tsx",
|
||||
"25": "Docker Compose Development Stack",
|
||||
@@ -43,8 +43,8 @@
|
||||
"41": "test_api.py",
|
||||
"42": "i18n-parity.test.ts",
|
||||
"43": "Expected Income",
|
||||
"44": "helpers.tsx",
|
||||
"45": "20260922_0003_savings_system.py",
|
||||
"44": "api",
|
||||
"45": "set_space_context",
|
||||
"46": "category-manager.test.tsx",
|
||||
"47": "graphify",
|
||||
"48": "@types/react-dom",
|
||||
@@ -57,31 +57,35 @@
|
||||
"55": "spaces/__init__.py",
|
||||
"56": "Payee",
|
||||
"57": "eslint.config.js",
|
||||
"58": "spaces/router.py",
|
||||
"58": "test_private_fixtures.py",
|
||||
"59": "vite-env.d.ts",
|
||||
"60": "vite.config.ts",
|
||||
"61": "finance-backend",
|
||||
"62": "LocalDirectoryBlobStore",
|
||||
"63": "TestOzonDebitContract",
|
||||
"63": "OzonDebitPdfAdapter",
|
||||
"64": "Gitea CI and real statement imports",
|
||||
"65": "20260918_0002_hold_lifecycle.py",
|
||||
"66": "eslint-plugin-react-hooks",
|
||||
"67": "main.py",
|
||||
"68": "current_session",
|
||||
"68": "analytics/router.py",
|
||||
"69": "TestAlfaDebitContract",
|
||||
"70": "vite",
|
||||
"71": "rocketbank_pdf.py",
|
||||
"71": "mcc.py",
|
||||
"72": "TestAlfaSavingsRule",
|
||||
"73": "savings-convert-revert.test.tsx",
|
||||
"74": "api",
|
||||
"74": "exports/router.py",
|
||||
"75": "ColumnChart.tsx",
|
||||
"76": "get_settings",
|
||||
"76": "identity/service.py",
|
||||
"77": "AGENTS.md",
|
||||
"78": "graphify.js",
|
||||
"79": "dependencies.py",
|
||||
"79": "Category",
|
||||
"80": "test_category_definitions.py",
|
||||
"81": "budget-plan.test.tsx",
|
||||
"82": "import-hold-resolution.test.tsx",
|
||||
"83": "test_postgres_and_s3.py",
|
||||
"86": "test_import_holds.py"
|
||||
"83": "get_settings",
|
||||
"84": "session_scope",
|
||||
"85": "20260923_0004_mcc_categories.py",
|
||||
"86": "Session",
|
||||
"87": "app_error_handler",
|
||||
"88": "20260923_0005_optional_hold_reason.py"
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"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
@@ -1,25 +1,25 @@
|
||||
# Graph Report - finance (2026-09-23)
|
||||
|
||||
## Corpus Check
|
||||
- 178 files · ~83,334 words
|
||||
- 182 files · ~87,581 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 1567 nodes · 4903 edges · 85 communities (62 shown, 17 thin omitted)
|
||||
- 1620 nodes · 5066 edges · 89 communities (67 shown, 16 thin omitted)
|
||||
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 91 edges (avg confidence: 0.93)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Graph Freshness
|
||||
- Built from commit: `8c2b89c8`
|
||||
- Built from commit: `db4dd757`
|
||||
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||
- Run `graphify update .` after code changes (no API cost).
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- identity/service.py
|
||||
- categorization/service.py
|
||||
- budgeting/router.py
|
||||
- Base
|
||||
- exports/service.py
|
||||
- imports/service.py
|
||||
- imports/router.py
|
||||
- alfa_xlsx.py
|
||||
- system-account-picker.test.tsx
|
||||
- types.ts
|
||||
@@ -30,14 +30,14 @@
|
||||
- BudgetPage.tsx
|
||||
- @testing-library/user-event
|
||||
- devDependencies
|
||||
- Session
|
||||
- AppError
|
||||
- dependencies
|
||||
- ImportDetailPage.tsx
|
||||
- OperationsPage.tsx
|
||||
- useAccounts.ts
|
||||
- index.ts
|
||||
- useOperations.ts
|
||||
- apiErrors.ts
|
||||
- test_adapter_contracts.py
|
||||
- AccountsPage.tsx
|
||||
- useBudget.ts
|
||||
- client.ts
|
||||
- fields.tsx
|
||||
- Docker Compose Development Stack
|
||||
@@ -59,8 +59,8 @@
|
||||
- test_api.py
|
||||
- i18n-parity.test.ts
|
||||
- Expected Income
|
||||
- helpers.tsx
|
||||
- 20260922_0003_savings_system.py
|
||||
- api
|
||||
- set_space_context
|
||||
- category-manager.test.tsx
|
||||
- graphify
|
||||
- @types/react-dom
|
||||
@@ -70,42 +70,46 @@
|
||||
- Favicon SVG (ledger lines icon)
|
||||
- init-app-role.sh
|
||||
- Payee
|
||||
- spaces/router.py
|
||||
- test_private_fixtures.py
|
||||
- finance-backend
|
||||
- LocalDirectoryBlobStore
|
||||
- TestOzonDebitContract
|
||||
- OzonDebitPdfAdapter
|
||||
- Gitea CI and real statement imports
|
||||
- 20260918_0002_hold_lifecycle.py
|
||||
- eslint-plugin-react-hooks
|
||||
- main.py
|
||||
- current_session
|
||||
- analytics/router.py
|
||||
- TestAlfaDebitContract
|
||||
- vite
|
||||
- rocketbank_pdf.py
|
||||
- mcc.py
|
||||
- TestAlfaSavingsRule
|
||||
- savings-convert-revert.test.tsx
|
||||
- api
|
||||
- exports/router.py
|
||||
- ColumnChart.tsx
|
||||
- get_settings
|
||||
- identity/service.py
|
||||
- AGENTS.md
|
||||
- dependencies.py
|
||||
- Category
|
||||
- test_category_definitions.py
|
||||
- budget-plan.test.tsx
|
||||
- import-hold-resolution.test.tsx
|
||||
- test_postgres_and_s3.py
|
||||
- test_import_holds.py
|
||||
- get_settings
|
||||
- session_scope
|
||||
- 20260923_0004_mcc_categories.py
|
||||
- Session
|
||||
- app_error_handler
|
||||
- 20260923_0005_optional_hold_reason.py
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `Session` - 198 edges
|
||||
2. `AppError` - 73 edges
|
||||
3. `Base` - 47 edges
|
||||
4. `conflict()` - 42 edges
|
||||
5. `get_settings()` - 39 edges
|
||||
6. `PkMixin` - 39 edges
|
||||
1. `Session` - 209 edges
|
||||
2. `AppError` - 72 edges
|
||||
3. `Base` - 48 edges
|
||||
4. `conflict()` - 43 edges
|
||||
5. `get_settings()` - 40 edges
|
||||
6. `PkMixin` - 40 edges
|
||||
7. `record()` - 38 edges
|
||||
8. `TimestampMixin` - 38 edges
|
||||
9. `StatementDocument` - 36 edges
|
||||
10. `create_expense()` - 35 edges
|
||||
10. `create_expense()` - 36 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `Typed Same-Origin API Client` --conceptually_related_to--> `Same-Origin Reverse Proxy` [INFERRED]
|
||||
@@ -127,31 +131,31 @@
|
||||
- **Financial Space Tenancy Boundary** — context_financial_space, context_membership, docs_adr_0001_financial_space_ownership_and_rls_defense_in_depth_authorization, docs_adr_0001_financial_space_ownership_and_rls_transaction_local_rls, docs_adr_0001_financial_space_ownership_and_rls_cross_space_integrity [EXTRACTED 1.00]
|
||||
- **Safe Statement Import Flow** — context_statement_import, context_parse_run, context_source_record, docs_adr_0003_versioned_statement_imports_immutable_statement_document, docs_adr_0003_versioned_statement_imports_conservative_deduplication, docs_adr_0003_versioned_statement_imports_atomic_confirm_revert [EXTRACTED 1.00]
|
||||
|
||||
## Communities (85 total, 17 thin omitted)
|
||||
## Communities (89 total, 16 thin omitted)
|
||||
|
||||
### Community 0 - "identity/service.py"
|
||||
Cohesion: 0.28
|
||||
Nodes (18): unauthorized(), accept_invitation(), authenticate(), create_invitation(), create_password_reset(), create_session(), get_active_session(), normalize_email() (+10 more)
|
||||
### Community 0 - "categorization/service.py"
|
||||
Cohesion: 0.14
|
||||
Nodes (24): CategorizationRule, CategoryMcc, IncomeSource, MccCode, Payee, PayeeAlias, Space-scoped MCC codes of a category: many codes per category, but every code…, Confirmed payee rule: auto-applies category to matching future operations. (+16 more)
|
||||
|
||||
### Community 1 - "budgeting/router.py"
|
||||
Cohesion: 0.13
|
||||
Nodes (48): balances(), cash_flow(), category_spend(), plan_fact(), Context, date, Db, get (+40 more)
|
||||
Cohesion: 0.17
|
||||
Nodes (41): AssignmentItem, assignments(), AssignmentsBody, categories(), category_definition_json(), category_definitions(), CategoryBody, CategoryDefinitionPatchBody (+33 more)
|
||||
|
||||
### Community 2 - "Base"
|
||||
Cohesion: 0.07
|
||||
Nodes (73): downgrade(), upgrade(), AuditEvent, Append-only domain/security event. Never stores raw financial payloads., ExpectedIncome, ExpectedIncomeMatch, FundingMovement, PeriodClosure (+65 more)
|
||||
Cohesion: 0.13
|
||||
Nodes (33): downgrade(), upgrade(), AuditEvent, Append-only domain/security event. Never stores raw financial payloads., ExpectedIncome, ExpectedIncomeMatch, FundingMovement, PeriodClosure (+25 more)
|
||||
|
||||
### Community 3 - "exports/service.py"
|
||||
Cohesion: 0.10
|
||||
Nodes (35): Any, Transactional scope for workers/endpoints that own the transaction lifecycle., session_scope(), download(), export_status(), Context, Csrf, Db (+27 more)
|
||||
Cohesion: 0.19
|
||||
Nodes (17): build_export(), cancel_deletion(), consume_export(), _csv_bytes(), get_export(), _json_rows(), datetime, UUID (+9 more)
|
||||
|
||||
### Community 4 - "imports/service.py"
|
||||
Cohesion: 0.06
|
||||
Nodes (85): datetime, utcnow(), magic_kind(), StatementDocument, confirm(), ConfirmBody, decide_row(), DecisionBody (+77 more)
|
||||
### Community 4 - "imports/router.py"
|
||||
Cohesion: 0.15
|
||||
Nodes (35): confirm(), ConfirmBody, decide_row(), DecisionBody, delete_source(), detail(), HoldResolveBody, imports() (+27 more)
|
||||
|
||||
### Community 5 - "alfa_xlsx.py"
|
||||
Cohesion: 0.15
|
||||
Nodes (27): AlfaCreditXlsxAdapter, AlfaDebitXlsxAdapter, _AlfaHeaderMeta, AlfaXlsxAdapterBase, _cell_str(), _debit_find_header(), _debit_header_metadata(), _debit_header_value() (+19 more)
|
||||
Cohesion: 0.17
|
||||
Nodes (24): AlfaDebitXlsxAdapter, _AlfaHeaderMeta, AlfaXlsxAdapterBase, _cell_str(), _debit_find_header(), _debit_header_metadata(), _debit_header_value(), _debit_norm() (+16 more)
|
||||
|
||||
### Community 6 - "system-account-picker.test.tsx"
|
||||
Cohesion: 0.15
|
||||
@@ -159,11 +163,11 @@ Nodes (12): AdjustBalanceResult, BudgetCategory, Operation, Page, categoriesMock
|
||||
|
||||
### Community 7 - "types.ts"
|
||||
Cohesion: 0.08
|
||||
Nodes (35): AccountStatus, AccountType, BalanceItem, BalancesReport, BudgetStatus, BudgetWarning, CashFlowMonth, CategoriesBlocker (+27 more)
|
||||
Nodes (34): AccountStatus, AccountType, AdjustBalancePayload, BalanceItem, BalancesReport, BudgetStatus, BudgetWarning, CashFlowMonth (+26 more)
|
||||
|
||||
### Community 8 - "router.tsx"
|
||||
Cohesion: 0.07
|
||||
Nodes (49): isApiError(), AcceptInvitePayload, LoginPayload, PasswordResetPayload, App(), queryClient, AcceptInvitePage, AccountsPage (+41 more)
|
||||
Nodes (53): AcceptInvitePayload, LoginPayload, PasswordResetPayload, AcceptInvitePage, AccountsPage, AnalyticsPage, BudgetPage, ImportDetailPage (+45 more)
|
||||
|
||||
### Community 9 - "ledger/router.py"
|
||||
Cohesion: 0.16
|
||||
@@ -174,16 +178,16 @@ Cohesion: 0.07
|
||||
Nodes (28): compilerOptions, baseUrl, esModuleInterop, forceConsistentCasingInFileNames, isolatedModules, jsx, lib, module (+20 more)
|
||||
|
||||
### Community 12 - "BudgetPage.tsx"
|
||||
Cohesion: 0.08
|
||||
Nodes (53): NotFoundPage, CategoryManagerModal(), CreateCategoryForm(), RenameModal(), PlanFactBars(), PlanFactBarsProps, PlanFactRow, MonthSwitcher() (+45 more)
|
||||
Cohesion: 0.10
|
||||
Nodes (42): NotFoundPage, PlanFactBars(), PlanFactBarsProps, PlanFactRow, MonthSwitcher(), MonthSwitcherProps, PageHeader(), PageHeaderProps (+34 more)
|
||||
|
||||
### Community 14 - "devDependencies"
|
||||
Cohesion: 0.10
|
||||
Nodes (21): eslint, @eslint/js, eslint-plugin-jsx-a11y, devDependencies, eslint, @eslint/js, eslint-plugin-jsx-a11y, globals (+13 more)
|
||||
|
||||
### Community 15 - "Session"
|
||||
Cohesion: 0.05
|
||||
Nodes (165): balances(), cash_flow(), category_spend(), plan_fact(), date, UUID, Any, record() (+157 more)
|
||||
### Community 15 - "AppError"
|
||||
Cohesion: 0.06
|
||||
Nodes (141): Any, record(), BudgetPeriod, Calendar month budget period in the space timezone (month granularity)., add_expected_income(), archive_category(), _balance_until(), _close_blockers() (+133 more)
|
||||
|
||||
### Community 16 - "dependencies"
|
||||
Cohesion: 0.09
|
||||
@@ -194,32 +198,32 @@ Cohesion: 0.16
|
||||
Nodes (23): HoldResolutionAction, ImportStatus, RowDecision, useToast(), useConfirmImport(), useImport(), useImportErrorToaster(), useReparseImport() (+15 more)
|
||||
|
||||
### Community 18 - "OperationsPage.tsx"
|
||||
Cohesion: 0.12
|
||||
Nodes (43): isSystemAccount(), Locale, useAccounts(), useActiveAccounts(), useUserAccounts(), useCategorizeOperation(), useConvertToSavings(), useCreateOperation() (+35 more)
|
||||
Cohesion: 0.09
|
||||
Nodes (54): Account, isSystemAccount(), Locale, Badge(), BadgeTone, EmptyState(), EmptyStateProps, MoneyField() (+46 more)
|
||||
|
||||
### Community 19 - "useAccounts.ts"
|
||||
Cohesion: 0.16
|
||||
Nodes (13): invalidationRoots, queryKeys, SESSION_ROOT, sessionInvalidationRoot, SPACE_ROOT, spaceInvalidationRoot, AdjustBalancePayload, CreateAccountPayload (+5 more)
|
||||
### Community 19 - "useOperations.ts"
|
||||
Cohesion: 0.15
|
||||
Nodes (16): invalidationRoots, queryKeys, SESSION_ROOT, sessionInvalidationRoot, SPACE_ROOT, spaceInvalidationRoot, CreateOperationPayload, OperationFilters (+8 more)
|
||||
|
||||
### Community 20 - "index.ts"
|
||||
Cohesion: 0.16
|
||||
Nodes (13): BudgetBlocker, blockerCta(), blockerText(), BudgetBlockers(), BudgetBlockersProps, Cta, LangSwitcher(), createI18n() (+5 more)
|
||||
### Community 20 - "apiErrors.ts"
|
||||
Cohesion: 0.14
|
||||
Nodes (16): isApiError(), BudgetBlocker, App(), queryClient, AppRoutes(), blockerCta(), blockerText(), BudgetBlockers() (+8 more)
|
||||
|
||||
### Community 21 - "test_adapter_contracts.py"
|
||||
Cohesion: 0.12
|
||||
Nodes (19): account_fingerprint(), amount_to_kopecks(), _mask_digits(), money_to_kopecks(), Parse statement money strings like '-1 234,56 RUR', '+ 500.00 ₽', '12 345'.…, SourceRow, StatementDraft, _first_page_text() (+11 more)
|
||||
Cohesion: 0.11
|
||||
Nodes (25): AlfaCreditXlsxAdapter, account_fingerprint(), amount_to_kopecks(), _mask_digits(), money_to_kopecks(), Protocol, Parse statement money strings like '-1 234,56 RUR', '+ 500.00 ₽', '12 345'.…, SourceRow (+17 more)
|
||||
|
||||
### Community 22 - "AccountsPage.tsx"
|
||||
Cohesion: 0.12
|
||||
Nodes (23): Account, CREATE_KINDS, Badge(), BadgeTone, Button(), ButtonProps, Variant, ConfirmDialog() (+15 more)
|
||||
### Community 22 - "useBudget.ts"
|
||||
Cohesion: 0.22
|
||||
Nodes (15): CategoryManagerModal(), CREATE_KINDS, CreateCategoryForm(), MccCodesModal(), parseMccCodes(), RenameModal(), ConfirmDialog(), ConfirmDialogProps (+7 more)
|
||||
|
||||
### Community 23 - "client.ts"
|
||||
Cohesion: 0.26
|
||||
Nodes (10): apiClient, ApiError, buildUrl(), ErrorEnvelope, parseErrorEnvelope(), QueryValue, readCsrfToken(), request() (+2 more)
|
||||
|
||||
### Community 24 - "fields.tsx"
|
||||
Cohesion: 0.23
|
||||
Nodes (10): FieldChrome, InputProps, MoneyField(), MoneyFieldProps, SelectFieldProps, SelectProps, TextareaField(), TextareaFieldProps (+2 more)
|
||||
Cohesion: 0.25
|
||||
Nodes (9): FieldChrome, InputProps, MoneyFieldProps, SelectFieldProps, SelectProps, TextareaField(), TextareaFieldProps, TextareaProps (+1 more)
|
||||
|
||||
### Community 25 - "Docker Compose Development Stack"
|
||||
Cohesion: 0.29
|
||||
@@ -238,8 +242,8 @@ Cohesion: 0.29
|
||||
Nodes (8): Budget Period, Funding, General Pool, Monthly Category, Plan Assignment, Savings Envelope, Immutable Closure Snapshots, Plan and Funding Separation
|
||||
|
||||
### Community 30 - "TestBaseContract"
|
||||
Cohesion: 0.12
|
||||
Nodes (7): Protocol, StatementAdapter, detect_adapter(), make_draft(), make_source_row(), TestBaseContract, Workbook
|
||||
Cohesion: 0.21
|
||||
Nodes (3): make_draft(), make_source_row(), TestBaseContract
|
||||
|
||||
### Community 31 - "Per-File Envelope Encryption"
|
||||
Cohesion: 0.29
|
||||
@@ -285,13 +289,13 @@ Nodes (5): components, $defs, operations, paths, webhooks
|
||||
Cohesion: 0.67
|
||||
Nodes (3): TestClient, test_invite_login_and_authenticated_space(), test_openapi_contains_critical_paths()
|
||||
|
||||
### Community 44 - "helpers.tsx"
|
||||
Cohesion: 0.16
|
||||
Nodes (13): User, ToastApi, ToastContext, ToastItem, ToastKind, ToastProvider(), openManager(), createTestQueryClient() (+5 more)
|
||||
### Community 44 - "api"
|
||||
Cohesion: 0.11
|
||||
Nodes (18): api, User, ToastApi, ToastContext, ToastItem, ToastKind, ToastProvider(), adjustBalanceMock (+10 more)
|
||||
|
||||
### Community 45 - "20260922_0003_savings_system.py"
|
||||
Cohesion: 0.32
|
||||
Nodes (11): _check_constraints(), downgrade(), _has_column(), _has_row(), _has_unique_constraint(), _normalized_sqltext(), Savings system resources, adjustments and reversible conversions., Swap a check constraint for a new value list, dialect-safely. Fresh databases… (+3 more)
|
||||
### Community 45 - "set_space_context"
|
||||
Cohesion: 0.26
|
||||
Nodes (13): _check_constraints(), downgrade(), _has_column(), _has_row(), _has_unique_constraint(), _normalized_sqltext(), Savings system resources, adjustments and reversible conversions., Swap a check constraint for a new value list, dialect-safely. Fresh databases… (+5 more)
|
||||
|
||||
### Community 46 - "category-manager.test.tsx"
|
||||
Cohesion: 0.15
|
||||
@@ -305,14 +309,18 @@ Nodes (11): command, enabled, timeout, type, mcp, graphify, plugin, $schema (+3
|
||||
Cohesion: 0.10
|
||||
Nodes (20): 1. Доменная основа накоплений, 2. Альфа debit: «Копилка для сдачи», 3. Управление категориями, 4. Понятные и устранимые блокировки, Backend-контракт, Frontend, Ближайший край: накопления, категории и блокировки бюджета, Бюджетная семантика (+12 more)
|
||||
|
||||
### Community 58 - "spaces/router.py"
|
||||
Cohesion: 0.17
|
||||
Nodes (20): forbidden(), cancel_deletion(), cancel_deletion(), current(), deletion_status(), DeletionBody, invite_member(), MemberInviteBody (+12 more)
|
||||
### Community 58 - "test_private_fixtures.py"
|
||||
Cohesion: 0.12
|
||||
Nodes (22): detect_adapter(), Any, Reconcile parsed rows against declared bank controls with stable diagnostics.…, _reconciliation_summary(), _check(), _fail(), _load_entries(), parsed() (+14 more)
|
||||
|
||||
### Community 62 - "LocalDirectoryBlobStore"
|
||||
Cohesion: 0.39
|
||||
Nodes (3): LocalDirectoryBlobStore, Path, S3-neutral local store; production swaps in an S3-compatible client.
|
||||
|
||||
### Community 63 - "OzonDebitPdfAdapter"
|
||||
Cohesion: 0.16
|
||||
Nodes (7): _ozon_cell(), _ozon_normalize(), OzonDebitPdfAdapter, Ozon debit statement contract, evidenced against the real document. A 'Справка…, ozon_extracted(), TestOzonDebitContract, Pattern
|
||||
|
||||
### Community 64 - "Gitea CI and real statement imports"
|
||||
Cohesion: 0.29
|
||||
Nodes (6): Confirmed decisions, Gitea CI and real statement imports, Goal and constraints, Implementation plan, Principal risks, Validation and rollout
|
||||
@@ -322,80 +330,96 @@ Cohesion: 0.80
|
||||
Nodes (4): downgrade(), _has_check_constraint(), _has_column(), upgrade()
|
||||
|
||||
### Community 67 - "main.py"
|
||||
Cohesion: 0.13
|
||||
Nodes (23): get_db(), get_engine(), get_session_factory(), sessionmaker, app_error_handler(), health(), healthz(), get (+15 more)
|
||||
Cohesion: 0.31
|
||||
Nodes (9): get_db(), get_engine(), get_session_factory(), sessionmaker, health(), healthz(), get, readiness() (+1 more)
|
||||
|
||||
### Community 68 - "current_session"
|
||||
Cohesion: 0.20
|
||||
Nodes (10): current_session(), alias, Context, Db, Header, User, require_csrf(), Cookie (+2 more)
|
||||
### Community 68 - "analytics/router.py"
|
||||
Cohesion: 0.24
|
||||
Nodes (14): balances(), cash_flow(), category_spend(), plan_fact(), Context, date, Db, get (+6 more)
|
||||
|
||||
### Community 69 - "TestAlfaDebitContract"
|
||||
Cohesion: 0.18
|
||||
Nodes (3): alfa_workbook_bytes(), synthetic_alfa_rows(), TestAlfaDebitContract
|
||||
|
||||
### Community 71 - "rocketbank_pdf.py"
|
||||
### Community 71 - "mcc.py"
|
||||
Cohesion: 0.24
|
||||
Nodes (7): _date_from(), _PendingRecord, date, Rocketbank debit statement contract, evidenced against the real document.…, RocketbankDebitPdfAdapter, open_pdf(), _quota_error()
|
||||
Nodes (12): _default_categories(), DefaultCategory, english_title(), _english_titles(), iter_mcc_dictionary(), MCC catalog backed by the iso18245 library with Russian labels. The global…, Curated Russian translation, else a Cyrillic transcription of the label., Yield ``(code, russian_title)`` for every MCC known to iso18245. (+4 more)
|
||||
|
||||
### Community 73 - "savings-convert-revert.test.tsx"
|
||||
Cohesion: 0.20
|
||||
Nodes (6): budgetCategoriesMock, listAccountsMock, listOperationsMock, operations, revertMock, savingsTransferMock
|
||||
|
||||
### Community 74 - "api"
|
||||
Cohesion: 0.22
|
||||
Nodes (5): api, adjustBalanceMock, listMock, createMock, listMock
|
||||
### Community 74 - "exports/router.py"
|
||||
Cohesion: 0.27
|
||||
Nodes (10): download(), export_status(), Context, Csrf, Db, get, post, Response (+2 more)
|
||||
|
||||
### Community 75 - "ColumnChart.tsx"
|
||||
Cohesion: 0.24
|
||||
Nodes (7): CashFlowColumn, ColumnChart(), ColumnChartProps, BarRow, HorizontalBars(), HorizontalBarsProps, rows
|
||||
|
||||
### Community 76 - "get_settings"
|
||||
Cohesion: 0.12
|
||||
Nodes (31): get_settings(), Settings, send_token_email(), accept_invite(), AcceptInviteBody, confirm_password_reset(), create_invite(), InviteBody (+23 more)
|
||||
### Community 76 - "identity/service.py"
|
||||
Cohesion: 0.06
|
||||
Nodes (80): current_context(), current_session(), alias, Context, CurrentSession, Db, Header, User (+72 more)
|
||||
|
||||
### Community 79 - "dependencies.py"
|
||||
Cohesion: 0.26
|
||||
Nodes (14): Transaction-local RLS context. No-op on SQLite (tests); fail-closed on…, set_space_context(), current_context(), CurrentSession, RequestContext, FinancialSpace, Membership, get_membership() (+6 more)
|
||||
### Community 79 - "Category"
|
||||
Cohesion: 0.23
|
||||
Nodes (12): Pure localization key for system categories; None for user categories., resolve_category_name_key(), Account, Category, Operation, Budget category. Kept in ledger module to avoid circular imports., Financial account. Balance is always derived: opening + confirmed postings., User financial fact. amount_kopecks is always positive; direction lives in… (+4 more)
|
||||
|
||||
### Community 80 - "test_category_definitions.py"
|
||||
Cohesion: 0.54
|
||||
Nodes (7): bootstrap(), TestClient, HTTP contract for category definitions, system protections and CSRF., test_adjust_and_savings_conversion_http_contracts(), test_category_lifecycle_create_rename_archive_restore(), test_openapi_contains_new_contracts(), test_space_has_protected_savings_pair()
|
||||
Cohesion: 0.50
|
||||
Nodes (8): bootstrap(), TestClient, HTTP contract for category definitions, system protections and CSRF., test_adjust_and_savings_conversion_http_contracts(), test_categories_expose_and_update_multiple_mcc_codes(), test_category_lifecycle_create_rename_archive_restore(), test_openapi_contains_new_contracts(), test_space_has_protected_savings_pair()
|
||||
|
||||
### Community 81 - "budget-plan.test.tsx"
|
||||
Cohesion: 0.25
|
||||
Nodes (7): BudgetSummary, categories, categoriesMock, month, summary, summaryMock, updatePlanMock
|
||||
|
||||
### Community 82 - "import-hold-resolution.test.tsx"
|
||||
Cohesion: 0.25
|
||||
Nodes (5): ImportRow, getImportMock, resolvedHold, resolveHoldMock, unresolvedHold
|
||||
Cohesion: 0.15
|
||||
Nodes (11): ImportDetail, ImportRow, ImportSummary, confirmMock, detailFixture(), getImportMock, postedRow(), getImportMock (+3 more)
|
||||
|
||||
### Community 83 - "test_postgres_and_s3.py"
|
||||
Cohesion: 0.17
|
||||
Nodes (7): BlobStore, get_blob_store(), Protocol, S3BlobStore, test_postgres_rls_requires_matching_transaction_context(), test_s3_blob_round_trip(), skipif
|
||||
### Community 83 - "get_settings"
|
||||
Cohesion: 0.10
|
||||
Nodes (12): get_settings(), Settings, send_token_email(), BlobStore, get_blob_store(), Protocol, S3BlobStore, create_celery_app() (+4 more)
|
||||
|
||||
### Community 86 - "test_import_holds.py"
|
||||
Cohesion: 0.13
|
||||
Nodes (41): normalize_description(), date, record_fingerprint(), add_record(), _draft(), hold_document(), make_document(), date (+33 more)
|
||||
### Community 84 - "session_scope"
|
||||
Cohesion: 0.31
|
||||
Nodes (9): Any, Transactional scope for workers/endpoints that own the transaction lifecycle., session_scope(), purge_due_spaces(), build_export_task(), enqueue_export(), purge_due_spaces_task(), task (+1 more)
|
||||
|
||||
### Community 85 - "20260923_0004_mcc_categories.py"
|
||||
Cohesion: 0.46
|
||||
Nodes (7): _create_tenant_policy(), _default_keys(), downgrade(), _has_table(), _space_ids(), upgrade(), default_category_system_keys()
|
||||
|
||||
### Community 86 - "Session"
|
||||
Cohesion: 0.06
|
||||
Nodes (124): datetime, utcnow(), Session, detect_savings_transfer(), _savings_rule_text(), normalize_description(), date, record_fingerprint() (+116 more)
|
||||
|
||||
### Community 87 - "app_error_handler"
|
||||
Cohesion: 0.32
|
||||
Nodes (8): app_error_handler(), Request, security_headers(), stale_data_handler(), exception_handler, JSONResponse, middleware, StaleDataError
|
||||
|
||||
### Community 88 - "20260923_0005_optional_hold_reason.py"
|
||||
Cohesion: 0.83
|
||||
Nodes (3): downgrade(), _reason_constraint_name(), upgrade()
|
||||
|
||||
## Knowledge Gaps
|
||||
- **248 isolated node(s):** `$schema`, `file:///home/k1nq/repos/finance/.kilo/plugins/graphify.js`, `type`, `/home/k1nq/.local/bin/graphify-mcp`, `/home/k1nq/repos/finance/graphify-out/graph.json` (+243 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 429 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
|
||||
- **17 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
- **250 isolated node(s):** `$schema`, `file:///home/k1nq/repos/finance/.kilo/plugins/graphify.js`, `type`, `/home/k1nq/.local/bin/graphify-mcp`, `/home/k1nq/repos/finance/graphify-out/graph.json` (+245 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 443 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
|
||||
- **16 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `Session` connect `Session` to `identity/service.py`, `Base`, `exports/service.py`, `main.py`, `imports/service.py`, `20260922_0003_savings_system.py`, `dependencies.py`, `test_postgres_and_s3.py`, `test_import_holds.py`, `spaces/router.py`?**
|
||||
_High betweenness centrality (0.063) - this node is a cross-community bridge._
|
||||
- **Why does `AppError` connect `Session` to `identity/service.py`, `Base`, `exports/service.py`, `imports/service.py`, `alfa_xlsx.py`, `main.py`, `rocketbank_pdf.py`, `TestRocketbankDebitContract`, `get_settings`, `test_adapter_contracts.py`, `test_import_holds.py`, `spaces/router.py`, `TestOzonDebitContract`?**
|
||||
_High betweenness centrality (0.050) - this node is a cross-community bridge._
|
||||
- **Why does `get_settings()` connect `get_settings` to `identity/service.py`, `Base`, `exports/service.py`, `main.py`, `alfa_xlsx.py`, `imports/service.py`, `rocketbank_pdf.py`, `dependencies.py`, `test_postgres_and_s3.py`?**
|
||||
_High betweenness centrality (0.018) - this node is a cross-community bridge._
|
||||
- **Are the 3 inferred relationships involving `Session` (e.g. with `upgrade()` and `UTCDateTime`) actually correct?**
|
||||
_`Session` has 3 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 40 inferred relationships involving `AppError` (e.g. with `request_export()` and `login()`) actually correct?**
|
||||
_`AppError` has 40 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Why does `Session` connect `Session` to `categorization/service.py`, `budgeting/router.py`, `Base`, `main.py`, `analytics/router.py`, `exports/service.py`, `identity/service.py`, `set_space_context`, `AppError`, `Category`, `session_scope`, `20260923_0004_mcc_categories.py`?**
|
||||
_High betweenness centrality (0.070) - this node is a cross-community bridge._
|
||||
- **Why does `AppError` connect `AppError` to `exports/service.py`, `imports/router.py`, `alfa_xlsx.py`, `main.py`, `TestRocketbankDebitContract`, `exports/router.py`, `identity/service.py`, `test_adapter_contracts.py`, `Session`, `app_error_handler`, `OzonDebitPdfAdapter`?**
|
||||
_High betweenness centrality (0.043) - this node is a cross-community bridge._
|
||||
- **Why does `get_settings()` connect `get_settings` to `exports/service.py`, `main.py`, `alfa_xlsx.py`, `imports/router.py`, `identity/service.py`, `Category`, `test_adapter_contracts.py`, `Session`?**
|
||||
_High betweenness centrality (0.021) - this node is a cross-community bridge._
|
||||
- **Are the 4 inferred relationships involving `Session` (e.g. with `upgrade()` and `upgrade()`) actually correct?**
|
||||
_`Session` has 4 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 39 inferred relationships involving `AppError` (e.g. with `request_export()` and `login()`) actually correct?**
|
||||
_`AppError` has 39 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 3 inferred relationships involving `Base` (e.g. with `downgrade()` and `upgrade()`) actually correct?**
|
||||
_`Base` has 3 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **What connects `$schema`, `file:///home/k1nq/repos/finance/.kilo/plugins/graphify.js`, `type` to the rest of the system?**
|
||||
_248 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
_250 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
File diff suppressed because one or more lines are too long
+9438
-6909
File diff suppressed because it is too large
Load Diff
+116
-92
@@ -66,15 +66,15 @@
|
||||
"semantic_hash": "039081cab3d91704e8f8a7152a981304"
|
||||
},
|
||||
"backend/app/budgeting/router.py": {
|
||||
"mtime": 1790097111.8510067,
|
||||
"seen": 1790129548.0529666,
|
||||
"ast_hash": "64e5050629404acc7877b8489944f28f",
|
||||
"mtime": 1790140570.7235522,
|
||||
"seen": 1790140596.4645138,
|
||||
"ast_hash": "5c8ea9e79dbe5450c2891c40931f0f4d",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/app/budgeting/service.py": {
|
||||
"mtime": 1790129321.9786477,
|
||||
"seen": 1790129548.052968,
|
||||
"ast_hash": "9e7273a5b90092b3df2fc7eb89c7bde5",
|
||||
"mtime": 1790139967.779716,
|
||||
"seen": 1790140596.4645145,
|
||||
"ast_hash": "8b936c8c80b35123143fcc6bed627172",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/app/categorization/__init__.py": {
|
||||
@@ -84,15 +84,15 @@
|
||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"backend/app/categorization/models.py": {
|
||||
"mtime": 1789130236.8679283,
|
||||
"seen": 1789377303.0691943,
|
||||
"ast_hash": "922083067aa2a24d67954296844db8f0",
|
||||
"semantic_hash": "922083067aa2a24d67954296844db8f0"
|
||||
"mtime": 1790139315.3002875,
|
||||
"seen": 1790140596.464517,
|
||||
"ast_hash": "6f7ad97ad5760e889ff3bf6c2e23a9e3",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/app/categorization/service.py": {
|
||||
"mtime": 1790060937.0384295,
|
||||
"seen": 1790096479.536434,
|
||||
"ast_hash": "175c114e437a93f2c4deb76c7bd7df2c",
|
||||
"mtime": 1790141279.1839979,
|
||||
"seen": 1790141340.3614173,
|
||||
"ast_hash": "91b8f18d6fd833a1b234b7b2f1685a88",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/app/config.py": {
|
||||
@@ -114,10 +114,10 @@
|
||||
"semantic_hash": "ec981c0f87b0470519cca7727172a950"
|
||||
},
|
||||
"backend/app/db/models.py": {
|
||||
"mtime": 1789128813.706167,
|
||||
"seen": 1789377303.0691986,
|
||||
"ast_hash": "338516ce68785afd5a7fe37f06e1c56f",
|
||||
"semantic_hash": "338516ce68785afd5a7fe37f06e1c56f"
|
||||
"mtime": 1790139321.140364,
|
||||
"seen": 1790140596.4645212,
|
||||
"ast_hash": "9dd2183a84d4b0346b8fec960af20eba",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/app/db/session.py": {
|
||||
"mtime": 1789128374.1340652,
|
||||
@@ -210,9 +210,9 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/app/imports/adapters/alfa_xlsx.py": {
|
||||
"mtime": 1790061232.3863332,
|
||||
"seen": 1790096479.5364504,
|
||||
"ast_hash": "6a3d3456eceef5ad3e2a79d2c43bcdc2",
|
||||
"mtime": 1790139655.0636957,
|
||||
"seen": 1790140596.4645343,
|
||||
"ast_hash": "e1a47b79913cbb08cdda13e8feff04a1",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/app/imports/adapters/base.py": {
|
||||
@@ -252,9 +252,9 @@
|
||||
"semantic_hash": "c72d7849c6f46e6c9a9136f3ae9dc92f"
|
||||
},
|
||||
"backend/app/imports/models.py": {
|
||||
"mtime": 1789730695.7355492,
|
||||
"seen": 1789735433.1581326,
|
||||
"ast_hash": "b99c93656a17603691838c9e7963d9ed",
|
||||
"mtime": 1790138566.972772,
|
||||
"seen": 1790140596.46454,
|
||||
"ast_hash": "a82e93256d8c14f5e7be840d572262aa",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/app/imports/readers.py": {
|
||||
@@ -264,15 +264,15 @@
|
||||
"semantic_hash": "0904afccafd9eb544b4d6846fc6384c8"
|
||||
},
|
||||
"backend/app/imports/router.py": {
|
||||
"mtime": 1790097243.6436644,
|
||||
"seen": 1790129548.052993,
|
||||
"ast_hash": "5c3be5ef9ee835a376b463b59132ecef",
|
||||
"mtime": 1790141910.6661994,
|
||||
"seen": 1790142012.6081262,
|
||||
"ast_hash": "cd3c86a3d0cbd157da465edab752836b",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/app/imports/service.py": {
|
||||
"mtime": 1790097243.6436644,
|
||||
"seen": 1790129548.0529938,
|
||||
"ast_hash": "2d46532c0c4294ac19adfbc4696506dd",
|
||||
"mtime": 1790140440.0177455,
|
||||
"seen": 1790140596.4645422,
|
||||
"ast_hash": "d4afb66723127361ae78ad1e793cca7a",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/app/imports/tasks.py": {
|
||||
@@ -300,9 +300,9 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/app/ledger/service.py": {
|
||||
"mtime": 1790077834.4411142,
|
||||
"seen": 1790096479.536463,
|
||||
"ast_hash": "9d5895e92011706e835b582aca3878c8",
|
||||
"mtime": 1790139917.4417093,
|
||||
"seen": 1790140596.4645474,
|
||||
"ast_hash": "a9d176c786237da53b3a2e6fb6d6b4e6",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/app/main.py": {
|
||||
@@ -342,9 +342,9 @@
|
||||
"semantic_hash": "073fa39a355b257a832ead3830942d8f"
|
||||
},
|
||||
"backend/app/spaces/service.py": {
|
||||
"mtime": 1790060979.7579985,
|
||||
"seen": 1790096479.5364685,
|
||||
"ast_hash": "214a4094630b761ea2a64de83d59317a",
|
||||
"mtime": 1790139400.2393951,
|
||||
"seen": 1790140596.4645526,
|
||||
"ast_hash": "ae39850843111710abad98657c253f62",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/app/worker.py": {
|
||||
@@ -354,9 +354,9 @@
|
||||
"semantic_hash": "4788addc575411056226ee892d1e2806"
|
||||
},
|
||||
"backend/pyproject.toml": {
|
||||
"mtime": 1789731892.783645,
|
||||
"seen": 1789735433.1581438,
|
||||
"ast_hash": "6ae54b23d066a4a48b7edbbe964af75f",
|
||||
"mtime": 1790139585.4503105,
|
||||
"seen": 1790140596.464554,
|
||||
"ast_hash": "5ed77663858f2555b4989d2e295b331e",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/tests/conftest.py": {
|
||||
@@ -372,15 +372,15 @@
|
||||
"semantic_hash": "bfec0778131788a91a0bc5c9ec2ad8ba"
|
||||
},
|
||||
"backend/tests/test_budget.py": {
|
||||
"mtime": 1789358107.2584474,
|
||||
"seen": 1789377303.0692346,
|
||||
"ast_hash": "f6e5c4d1e1d0f2b1941acf8ec9f6678b",
|
||||
"semantic_hash": "f6e5c4d1e1d0f2b1941acf8ec9f6678b"
|
||||
"mtime": 1790141279.1849978,
|
||||
"seen": 1790141340.361456,
|
||||
"ast_hash": "81578f618d0758c7f9e2f00c921f2594",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/tests/test_imports.py": {
|
||||
"mtime": 1789987559.2557845,
|
||||
"seen": 1789987623.1673782,
|
||||
"ast_hash": "775b6bb13c49cfd933fdfe981142dc41",
|
||||
"mtime": 1790140440.029933,
|
||||
"seen": 1790140596.4645612,
|
||||
"ast_hash": "9e568b07fa4d77eb55c36ef748fb0f9b",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/tests/test_ledger_properties.py": {
|
||||
@@ -444,9 +444,9 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"frontend/src/api/types.ts": {
|
||||
"mtime": 1790097111.8510067,
|
||||
"seen": 1790129548.0530243,
|
||||
"ast_hash": "744887bcd7ef47a37a6899108a5fcbc0",
|
||||
"mtime": 1790140175.05337,
|
||||
"seen": 1790140596.4645727,
|
||||
"ast_hash": "7a2c079c721ef1966d181492a4624686",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"frontend/src/app/router.tsx": {
|
||||
@@ -576,9 +576,9 @@
|
||||
"semantic_hash": "40f9f47b108ec0b484f9564bd1d319cb"
|
||||
},
|
||||
"frontend/src/hooks/useBudget.ts": {
|
||||
"mtime": 1790077001.481125,
|
||||
"seen": 1790096479.5365117,
|
||||
"ast_hash": "2ab1d3e626a5771c11c6f35b39d9055f",
|
||||
"mtime": 1790140175.05337,
|
||||
"seen": 1790140596.464592,
|
||||
"ast_hash": "ffe50957304981998e7b92838ae15bdb",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"frontend/src/hooks/useImports.ts": {
|
||||
@@ -606,15 +606,15 @@
|
||||
"semantic_hash": "243512f6e095deeaa29393b7c831777f"
|
||||
},
|
||||
"frontend/src/i18n/locales/en.json": {
|
||||
"mtime": 1790096831.314309,
|
||||
"seen": 1790129548.0530467,
|
||||
"ast_hash": "2d62cdde2e726baf5e6b9627d58372ec",
|
||||
"mtime": 1790141684.2632692,
|
||||
"seen": 1790142012.6081812,
|
||||
"ast_hash": "7fb00b4761bc40813a3c81cc9346d92e",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"frontend/src/i18n/locales/ru.json": {
|
||||
"mtime": 1790096831.3153088,
|
||||
"seen": 1790129548.0530472,
|
||||
"ast_hash": "aebf731fc4a592f2f20abd2d309e9a97",
|
||||
"mtime": 1790141684.2632692,
|
||||
"seen": 1790142012.6081817,
|
||||
"ast_hash": "d82c9fcf5dec8f9d417ee4ead2e2825f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"frontend/src/main.tsx": {
|
||||
@@ -648,9 +648,9 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"frontend/src/pages/ImportDetailPage.tsx": {
|
||||
"mtime": 1790077605.334677,
|
||||
"seen": 1790096479.5365288,
|
||||
"ast_hash": "75923869914b7ef53b939676f9d5f095",
|
||||
"mtime": 1790138988.0589676,
|
||||
"seen": 1790140596.4646058,
|
||||
"ast_hash": "38dc8d5253cf9622ec266804a9e907ef",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"frontend/src/pages/ImportsPage.tsx": {
|
||||
@@ -792,10 +792,10 @@
|
||||
"semantic_hash": "c8211b75ee8dfba83e37f06ff219d2b1"
|
||||
},
|
||||
"README.md": {
|
||||
"mtime": 1789358273.3585796,
|
||||
"seen": 1789377303.0692983,
|
||||
"ast_hash": "79eb6002a05286e6655e6fe3904f5b29",
|
||||
"semantic_hash": "79eb6002a05286e6655e6fe3904f5b29"
|
||||
"mtime": 1790139585.4512253,
|
||||
"seen": 1790140596.4646337,
|
||||
"ast_hash": "a4502b2cc4571860844ff035d31e5798",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/adr/0001-financial-space-ownership-and-rls.md": {
|
||||
"mtime": 1789126779.7591345,
|
||||
@@ -858,10 +858,10 @@
|
||||
"semantic_hash": "42788af442bf05e487937f435141f84b"
|
||||
},
|
||||
"infra/README.md": {
|
||||
"mtime": 1789357215.8087454,
|
||||
"seen": 1789377303.0693076,
|
||||
"ast_hash": "0a7bb82cce04631f36f2d561a1665478",
|
||||
"semantic_hash": "0a7bb82cce04631f36f2d561a1665478"
|
||||
"mtime": 1790139585.4515448,
|
||||
"seen": 1790140596.4646425,
|
||||
"ast_hash": "b4aa0d817fb75341937b44526cb943f6",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"infra/docker-compose.yml": {
|
||||
"mtime": 1790067636.943902,
|
||||
@@ -894,9 +894,9 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/tests/test_import_holds.py": {
|
||||
"mtime": 1789736201.2172022,
|
||||
"seen": 1789736272.9455423,
|
||||
"ast_hash": "6f14226a83e65efbaaee6446d4ec4cb6",
|
||||
"mtime": 1790139806.356644,
|
||||
"seen": 1790140596.4645593,
|
||||
"ast_hash": "f483eaf3c70604ee3e931958f0b9736b",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/tests/test_private_fixtures.py": {
|
||||
@@ -906,9 +906,9 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
".gitea/workflows/ci.yml": {
|
||||
"mtime": 1789736949.8123386,
|
||||
"seen": 1789737916.791613,
|
||||
"ast_hash": "f0891b841c5e24c761f5067a4ea23637",
|
||||
"mtime": 1790139585.4503105,
|
||||
"seen": 1790140596.464629,
|
||||
"ast_hash": "f06ce2f92ce51151ee2edf6879038f2e",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
".kilo/plans/1789727434954-gitea-ci-real-statement-imports.md": {
|
||||
@@ -960,15 +960,15 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/tests/test_budget_savings.py": {
|
||||
"mtime": 1790077946.050938,
|
||||
"seen": 1790096479.5364738,
|
||||
"ast_hash": "47280e68387aee190b930ac53ac79e93",
|
||||
"mtime": 1790140489.3004284,
|
||||
"seen": 1790140596.464558,
|
||||
"ast_hash": "dba98c2706a6cdbbf988336ac51d248a",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/tests/test_category_definitions.py": {
|
||||
"mtime": 1790078212.0062637,
|
||||
"seen": 1790096479.5364745,
|
||||
"ast_hash": "4d16f9374862cea5f66bec35ea4ffec9",
|
||||
"mtime": 1790141779.8555074,
|
||||
"seen": 1790142012.6081438,
|
||||
"ast_hash": "f2b6219d9e5687ddee8ecc85093447cb",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/tests/test_import_savings.py": {
|
||||
@@ -990,9 +990,9 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"frontend/src/components/budget/CategoryManagerModal.tsx": {
|
||||
"mtime": 1790078043.7267377,
|
||||
"seen": 1790096479.5364919,
|
||||
"ast_hash": "72a373612048b42b68405b965daa3153",
|
||||
"mtime": 1790141684.261269,
|
||||
"seen": 1790142012.6081603,
|
||||
"ast_hash": "3320a39025aec0d67df0a5f8a4bf6d4b",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"frontend/src/lib/accountLabel.ts": {
|
||||
@@ -1056,15 +1056,15 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"frontend/src/test/category-manager.test.tsx": {
|
||||
"mtime": 1790078889.2937336,
|
||||
"seen": 1790096479.5365384,
|
||||
"ast_hash": "e32016dfb312c6fd11e9b485b9499798",
|
||||
"mtime": 1790141753.6681683,
|
||||
"seen": 1790142012.6082017,
|
||||
"ast_hash": "9d2f35f1471a69a6a73d1a55cf81cb2a",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"frontend/src/test/import-hold-resolution.test.tsx": {
|
||||
"mtime": 1790078821.0050712,
|
||||
"seen": 1790096479.5365412,
|
||||
"ast_hash": "8d46975e4f292e69d2531486578b0e1e",
|
||||
"mtime": 1790138988.0599678,
|
||||
"seen": 1790140596.4646187,
|
||||
"ast_hash": "817410814a07e97b717b74b39cbe438d",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"frontend/src/test/review-queue-month.test.tsx": {
|
||||
@@ -1092,9 +1092,33 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/tests/test_savings_migration.py": {
|
||||
"mtime": 1790129378.2103765,
|
||||
"seen": 1790129548.053016,
|
||||
"ast_hash": "9ada17014ccdef9572af77bb00f2590b",
|
||||
"mtime": 1790141939.6325738,
|
||||
"seen": 1790142012.6081502,
|
||||
"ast_hash": "b72fd7f10323c67152182b3316bcbe76",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/alembic/versions/20260923_0004_mcc_categories.py": {
|
||||
"mtime": 1790141910.6651993,
|
||||
"seen": 1790142012.6080875,
|
||||
"ast_hash": "7124e3107ea58f73ad95fe1a18499c0d",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/alembic/versions/20260923_0005_optional_hold_reason.py": {
|
||||
"mtime": 1790139704.955339,
|
||||
"seen": 1790140596.464503,
|
||||
"ast_hash": "29af3b5bcb808fc59a05688ea64ca134",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"backend/app/categorization/mcc.py": {
|
||||
"mtime": 1790140416.340453,
|
||||
"seen": 1790140596.4645164,
|
||||
"ast_hash": "3f34254e6900441ba4f2db78df7b98f1",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"frontend/src/test/import-confirm.test.tsx": {
|
||||
"mtime": 1790138988.0599678,
|
||||
"seen": 1790140596.4646177,
|
||||
"ast_hash": "c69052c781b488bfc9fd94c18145b3dd",
|
||||
"semantic_hash": ""
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -43,7 +43,7 @@ These entry points are referenced by the compose file and CI; coordinate changes
|
||||
through both sides:
|
||||
|
||||
**Backend** (`backend/`, build context of `Dockerfile.backend`):
|
||||
- uv-managed project with `pyproject.toml` + `uv.lock`; lint `ruff`, types `mypy`, tests `pytest`.
|
||||
- uv-managed project with `pyproject.toml` + `uv.lock`; lint `ruff`, types `ty`, tests `pytest`.
|
||||
- API: `uvicorn app.main:app`, health endpoint `GET /healthz`.
|
||||
- Worker: Celery app importable as `app.worker.celery`; tasks idempotent, `acks_late`, `prefetch=1`.
|
||||
- Statement parsing happens **only** in the worker; the local compose worker is attached only
|
||||
|
||||
Reference in New Issue
Block a user