Update manifest.json with new file metadata and add pytest configuration

- Updated timestamps and AST hashes for multiple files in the manifest.json.
- Added new test file backend/tests/test_expected_income.py.
- Added documentation files: перево́д_между_своими.md and 0002-fixes.md.
- Introduced pytest.ini to configure pytest settings, including test paths and warning filters.
This commit is contained in:
k1nq
2026-09-24 08:34:13 +05:00
parent 970fe9dacf
commit 023612ce0a
40 changed files with 23855 additions and 16804 deletions
+138 -23
View File
@@ -3,8 +3,8 @@ from collections import defaultdict
from datetime import date
from typing import Annotated, Literal
from fastapi import APIRouter, Query
from pydantic import BaseModel, Field
from fastapi import APIRouter, Query, Response
from pydantic import BaseModel, ConfigDict, Field
from app.budgeting import service
from app.categorization import service as categorization
@@ -37,10 +37,28 @@ class FundingBody(BaseModel):
class ExpectedIncomeBody(BaseModel):
expected_on: date
amount_kopecks: int = Field(gt=0)
source_name: str | None = Field(default=None, max_length=200)
note: str | None = Field(default=None, max_length=500)
model_config = ConfigDict(populate_by_name=True)
expected_on: date = Field(alias="expectedOn")
amount_kopecks: int = Field(gt=0, alias="amountKopecks")
source_name: str | None = Field(default=None, max_length=200, alias="sourceName")
note: str | None = Field(default=None, max_length=500, alias="note")
class ExpectedIncomePatchBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
expected_on: date | None = Field(default=None, alias="expectedOn")
amount_kopecks: int | None = Field(default=None, gt=0, alias="amountKopecks")
source_name: str | None = Field(default=None, max_length=200, alias="sourceName")
note: str | None = Field(default=None, max_length=500, alias="note")
class ExpectedIncomeMatchBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
operation_id: uuid.UUID = Field(alias="operationId")
amount_kopecks: int | None = Field(default=None, gt=0, alias="amountKopecks")
def period_for(db: Db, context: Context, year: int, month: int):
@@ -71,6 +89,22 @@ def parse_month(value: str) -> tuple[int, int]:
return year, month
def expected_income_json(item: dict) -> dict:
return {
"id": item["id"],
"expectedOn": item["expected_on"],
"amountKopecks": item["amount_kopecks"],
"sourceName": item["source_name"],
"note": item["note"],
"matchedKopecks": item["matched_kopecks"],
"status": "matched"
if item["matched_kopecks"] >= item["amount_kopecks"]
else "partially_matched"
if item["matched_kopecks"]
else "expected",
}
def frontend_summary(state: dict) -> dict:
monthly = state["monthly_categories"]
envelopes = state["envelopes"]
@@ -90,21 +124,7 @@ def frontend_summary(state: dict) -> dict:
"availableKopecks": available,
"warnings": [{"code": code} for code in state["warnings"]],
"blockers": state["close_blockers"],
"expectedIncomes": [
{
"id": item["id"],
"expectedOn": item["expected_on"],
"amountKopecks": item["amount_kopecks"],
"sourceName": item["source_name"],
"matchedKopecks": item["matched_kopecks"],
"status": "matched"
if item["matched_kopecks"] >= item["amount_kopecks"]
else "partially_matched"
if item["matched_kopecks"]
else "expected",
}
for item in state["expected_incomes"]
],
"expectedIncomes": [expected_income_json(item) for item in state["expected_incomes"]],
}
@@ -308,8 +328,20 @@ def fund(year: int, month: int, body: FundingBody, db: Db, context: Context, _cs
return {"id": movement.id}
def expected_income_item(db: Db, context: Context, period, income_id: uuid.UUID) -> dict:
items = service.expected_income_items(db, space_id=context.space.id, period=period)
return next(item for item in items if item["id"] == str(income_id))
@router.get("/{year}/{month}/expected-incomes")
def expected_incomes(year: int, month: int, db: Db, context: Context) -> list[dict]:
period = period_for(db, context, year, month)
items = service.expected_income_items(db, space_id=context.space.id, period=period)
return [expected_income_json(item) for item in items]
@router.post("/{year}/{month}/expected-incomes", status_code=201)
def expected_income(
def create_expected_income(
year: int,
month: int,
body: ExpectedIncomeBody,
@@ -325,7 +357,90 @@ def expected_income(
period=period,
**body.model_dump(),
)
return {"id": income.id}
return expected_income_json(expected_income_item(db, context, period, income.id))
@router.patch("/{year}/{month}/expected-incomes/{income_id}")
def patch_expected_income(
year: int,
month: int,
income_id: uuid.UUID,
body: ExpectedIncomePatchBody,
db: Db,
context: Context,
_csrf: Csrf,
) -> dict:
period = period_for(db, context, year, month)
service.update_expected_income(
db,
space_id=context.space.id,
user=context.user,
period=period,
income_id=income_id,
changes=body.model_dump(exclude_unset=True),
)
return expected_income_json(expected_income_item(db, context, period, income_id))
@router.delete("/{year}/{month}/expected-incomes/{income_id}", status_code=204)
def delete_expected_income(
year: int,
month: int,
income_id: uuid.UUID,
db: Db,
context: Context,
_csrf: Csrf,
) -> Response:
period = period_for(db, context, year, month)
service.delete_expected_income(
db, space_id=context.space.id, user=context.user, period=period, income_id=income_id
)
return Response(status_code=204)
@router.post("/{year}/{month}/expected-incomes/{income_id}/match", status_code=201)
def match_expected_income(
year: int,
month: int,
income_id: uuid.UUID,
body: ExpectedIncomeMatchBody,
db: Db,
context: Context,
_csrf: Csrf,
) -> dict:
period = period_for(db, context, year, month)
service.match_expected_income(
db,
space_id=context.space.id,
user=context.user,
period=period,
expected_income_id=income_id,
operation_id=body.operation_id,
amount_kopecks=body.amount_kopecks,
)
return expected_income_json(expected_income_item(db, context, period, income_id))
@router.delete("/{year}/{month}/expected-incomes/{income_id}/match/{operation_id}", status_code=204)
def unmatch_expected_income(
year: int,
month: int,
income_id: uuid.UUID,
operation_id: uuid.UUID,
db: Db,
context: Context,
_csrf: Csrf,
) -> Response:
period = period_for(db, context, year, month)
service.unmatch_expected_income(
db,
space_id=context.space.id,
user=context.user,
period=period,
expected_income_id=income_id,
operation_id=operation_id,
)
return Response(status_code=204)
@router.post("/{year}/{month}/close")
+187 -26
View File
@@ -422,17 +422,8 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri
row["planned"] = planned_by_cat.get(cid, 0)
planned_total = sum(planned_by_cat.values())
expected = db.query(ExpectedIncome).filter_by(period_id=period.id).all()
expected_total = sum(e.amount_kopecks for e in expected)
matched_by_expected: dict[Any, int] = defaultdict(int)
for m in (
db.query(ExpectedIncomeMatch)
.filter(ExpectedIncomeMatch.expected_income_id.in_([e.id for e in expected] or [None]))
.all()
if expected
else []
):
matched_by_expected[m.expected_income_id] += m.amount_kopecks
expected_items = expected_income_items(db, space_id=space_id, period=period)
expected_total = sum(item["amount_kopecks"] for item in expected_items)
uncategorized_count = sum(1 for op in ops if op.type == "expense" and op.category_id is None)
negative_envelopes = [
@@ -465,16 +456,7 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri
"income_total": income_total,
"planned_total": planned_total,
"expected_income_total": expected_total,
"expected_incomes": [
{
"id": str(e.id),
"expected_on": e.expected_on.isoformat(),
"amount_kopecks": e.amount_kopecks,
"source_name": e.source_name,
"matched_kopecks": matched_by_expected.get(e.id, 0),
}
for e in expected
],
"expected_incomes": expected_items,
"monthly_categories": monthly_rows,
"envelopes": envelope_rows,
"credit_reserves": credit_reserves,
@@ -717,6 +699,65 @@ def set_assignments(
return result
def get_expected_income(
db: Session, *, space_id: uuid.UUID, income_id: uuid.UUID
) -> ExpectedIncome:
income = db.query(ExpectedIncome).filter_by(id=income_id, space_id=space_id).one_or_none()
if income is None:
raise not_found("BUDGET_EXPECTED_INCOME_NOT_FOUND", "Expected income not found")
return income
def get_period_expected_income(
db: Session, *, space_id: uuid.UUID, period: BudgetPeriod, income_id: uuid.UUID
) -> ExpectedIncome:
"""Load an expected income scoped to the route's year/month period."""
income = get_expected_income(db, space_id=space_id, income_id=income_id)
if income.period_id != period.id:
raise not_found("BUDGET_EXPECTED_INCOME_NOT_FOUND", "Expected income not found")
return income
def _ensure_expected_date_in_period(period: BudgetPeriod, expected_on: date) -> None:
start, end = period_bounds(period.year, period.month)
if not start <= expected_on < end:
raise bad_request(
"BUDGET_EXPECTED_INCOME_DATE_OUTSIDE",
"Expected income date must fall within the budget period month",
)
def expected_income_items(
db: Session, *, space_id: uuid.UUID, period: BudgetPeriod
) -> list[dict[str, Any]]:
"""Aggregate expected incomes of the period with matched totals."""
expected = (
db.query(ExpectedIncome)
.filter_by(period_id=period.id)
.order_by(ExpectedIncome.expected_on, ExpectedIncome.created_at)
.all()
)
matched_by_expected: dict[Any, int] = defaultdict(int)
if expected:
for match in (
db.query(ExpectedIncomeMatch)
.filter(ExpectedIncomeMatch.expected_income_id.in_([e.id for e in expected]))
.all()
):
matched_by_expected[match.expected_income_id] += match.amount_kopecks
return [
{
"id": str(e.id),
"expected_on": e.expected_on.isoformat(),
"amount_kopecks": e.amount_kopecks,
"source_name": e.source_name,
"note": e.note,
"matched_kopecks": matched_by_expected.get(e.id, 0),
}
for e in expected
]
def add_expected_income(
db: Session,
*,
@@ -731,6 +772,7 @@ def add_expected_income(
require_open(period)
if amount_kopecks <= 0:
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
_ensure_expected_date_in_period(period, expected_on)
income = ExpectedIncome(
space_id=space_id,
period_id=period.id,
@@ -741,6 +783,13 @@ def add_expected_income(
)
db.add(income)
db.flush()
audit.record(
db,
event_type="budget.expected_income_created",
space_id=space_id,
user_id=user.id,
detail={"expected_income_id": str(income.id), "period_id": str(period.id)},
)
return income
@@ -749,15 +798,14 @@ def match_expected_income(
*,
space_id: uuid.UUID,
user: User,
period: BudgetPeriod,
expected_income_id: uuid.UUID,
operation_id: uuid.UUID,
amount_kopecks: int | None = None,
) -> ExpectedIncomeMatch:
expected = (
db.query(ExpectedIncome).filter_by(id=expected_income_id, space_id=space_id).one_or_none()
expected = get_period_expected_income(
db, space_id=space_id, period=period, income_id=expected_income_id
)
if expected is None:
raise not_found("BUDGET_EXPECTED_INCOME_NOT_FOUND", "Expected income not found")
operation = db.query(Operation).filter_by(id=operation_id, space_id=space_id).one_or_none()
if operation is None or operation.type != "income" or operation.status != "confirmed":
raise not_found("BUDGET_INCOME_OPERATION_NOT_FOUND", "Confirmed income operation not found")
@@ -776,15 +824,128 @@ def match_expected_income(
)
match = ExpectedIncomeMatch(
space_id=space_id,
expected_income_id=expected.id,
expected_income_id=expected_income_id,
operation_id=operation.id,
amount_kopecks=amount,
)
db.add(match)
db.flush()
audit.record(
db,
event_type="budget.expected_income_matched",
space_id=space_id,
user_id=user.id,
detail={
"expected_income_id": str(expected_income_id),
"operation_id": str(operation.id),
"amount_kopecks": amount,
},
)
return match
def unmatch_expected_income(
db: Session,
*,
space_id: uuid.UUID,
user: User,
period: BudgetPeriod,
expected_income_id: uuid.UUID,
operation_id: uuid.UUID,
) -> None:
get_period_expected_income(db, space_id=space_id, period=period, income_id=expected_income_id)
match = (
db.query(ExpectedIncomeMatch)
.filter_by(
space_id=space_id, expected_income_id=expected_income_id, operation_id=operation_id
)
.one_or_none()
)
if match is None:
raise not_found("BUDGET_MATCH_NOT_FOUND", "Expected income match not found")
db.delete(match)
db.flush()
audit.record(
db,
event_type="budget.expected_income_unmatched",
space_id=space_id,
user_id=user.id,
detail={
"expected_income_id": str(expected_income_id),
"operation_id": str(operation_id),
},
)
def update_expected_income(
db: Session,
*,
space_id: uuid.UUID,
user: User,
period: BudgetPeriod,
income_id: uuid.UUID,
changes: dict[str, Any],
) -> ExpectedIncome:
"""Partially update expected_on/amount_kopecks/source_name/note of one entry."""
require_open(period)
income = get_period_expected_income(db, space_id=space_id, period=period, income_id=income_id)
if "expected_on" in changes:
expected_on = changes["expected_on"]
if expected_on is None:
raise bad_request(
"BUDGET_EXPECTED_INCOME_DATE_REQUIRED", "Expected date cannot be removed"
)
_ensure_expected_date_in_period(period, expected_on)
income.expected_on = expected_on
if "amount_kopecks" in changes:
amount = changes["amount_kopecks"]
if amount is None or amount <= 0:
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
income.amount_kopecks = amount
if "source_name" in changes:
income.source_name = changes["source_name"]
if "note" in changes:
income.note = changes["note"]
db.flush()
audit.record(
db,
event_type="budget.expected_income_updated",
space_id=space_id,
user_id=user.id,
detail={
"expected_income_id": str(income.id),
"fields": sorted(
key
for key in changes
if key in ("expected_on", "amount_kopecks", "source_name", "note")
),
},
)
return income
def delete_expected_income(
db: Session,
*,
space_id: uuid.UUID,
user: User,
period: BudgetPeriod,
income_id: uuid.UUID,
) -> None:
require_open(period)
income = get_period_expected_income(db, space_id=space_id, period=period, income_id=income_id)
detail = {"expected_income_id": str(income.id), "period_id": str(period.id)}
db.delete(income)
db.flush()
audit.record(
db,
event_type="budget.expected_income_deleted",
space_id=space_id,
user_id=user.id,
detail=detail,
)
def fund(
db: Session,
*,
+3 -3
View File
@@ -170,7 +170,7 @@ class OzonDebitPdfAdapter:
def parse(self, raw: bytes) -> StatementDraft:
pages_text: list[str] = []
pages_tables: list[list[list[list[object]]]] = []
pages_tables: list[list[list[list[str | None]]]] = []
with open_pdf(raw) as pdf:
if not pdf.pages:
raise UnsupportedStatementLayout("PDF has no pages")
@@ -186,7 +186,7 @@ class OzonDebitPdfAdapter:
return self._draft_from_extracted(pages_text, pages_tables)
def _draft_from_extracted(
self, pages_text: list[str], pages_tables: list[list[list[list[object]]]]
self, pages_text: list[str], pages_tables: list[list[list[list[str | None]]]]
) -> StatementDraft:
page_zero = _ozon_normalize(pages_text[0])
if self.bank_marker not in page_zero or self.title_marker not in page_zero:
@@ -240,7 +240,7 @@ class OzonDebitPdfAdapter:
raise UnsupportedStatementLayout(f"Ozon {what} total line not found")
return match.group(1)
def _table_row(self, cells: list[object], row_ref: str) -> SourceRow | None:
def _table_row(self, cells: list[str | None], row_ref: str) -> SourceRow | None:
if cells is None:
raise UnsupportedStatementLayout("unexpected Ozon table row shape")
texts = [_ozon_cell(cell) for cell in cells]
+8 -7
View File
@@ -7,6 +7,7 @@ suite (tests/test_private_fixtures.py, marker `private_fixtures`).
import io
from datetime import date
from typing import Any
import pytest
from openpyxl import Workbook
@@ -39,7 +40,7 @@ NBSP = "\u00a0"
def make_source_row(**overrides: object) -> SourceRow:
values: dict[str, object] = {
values: dict[str, Any] = {
"external_id": None,
"occurred_on": date(2026, 1, 1),
"posted_on": date(2026, 1, 2),
@@ -52,11 +53,11 @@ def make_source_row(**overrides: object) -> SourceRow:
"row_ref": "row1",
}
values.update(overrides)
return SourceRow(**values) # type: ignore[arg-type]
return SourceRow(**values)
def make_draft(**overrides: object) -> StatementDraft:
values: dict[str, object] = {
values: dict[str, Any] = {
"adapter_id": "alfa_debit_xlsx",
"parser_version": ALFA_DEBIT_XLSX_VERSION,
"bank_code": "alfa",
@@ -70,7 +71,7 @@ def make_draft(**overrides: object) -> StatementDraft:
"rows": (),
}
values.update(overrides)
return StatementDraft(**values) # type: ignore[arg-type]
return StatementDraft(**values)
class TestBaseContract:
@@ -560,7 +561,7 @@ class TestAlfaSavingsRule:
None,
"416,90 RUR",
]
savings_row = [
savings_row: list[object] = [
"03.09.2026",
"03.09.2026",
None,
@@ -603,7 +604,7 @@ class TestAlfaSavingsRule:
assert all(not row.savings_transfer for row in draft.rows)
def ozon_extracted() -> tuple[list[str], list[list[list[object]]]]:
def ozon_extracted() -> tuple[list[str], list[list[list[list[str | None]]]]]:
pages_text = [
"\n".join(
[
@@ -623,7 +624,7 @@ def ozon_extracted() -> tuple[list[str], list[list[list[object]]]]:
]
),
]
pages_tables = [
pages_tables: list[list[list[list[str | None]]]] = [
[
[
[
+9 -3
View File
@@ -88,16 +88,22 @@ def test_default_mcc_categories_are_budgetable_and_editable_mapping(db: Session)
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
groceries_match = categorization.category_for_mcc(db, space_id=space.id, code=5999)
assert groceries_match is not None
assert groceries_match.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_match = categorization.category_for_mcc(db, space_id=space.id, code=5999)
assert custom_match is not None
assert custom_match.id == custom.id
custom.archived = True
assert categorization.category_for_mcc(db, space_id=space.id, code=5999).id == others.id
fallback_match = categorization.category_for_mcc(db, space_id=space.id, code=5999)
assert fallback_match is not None
assert fallback_match.id == others.id
custom.archived = False
with pytest.raises(AppError) as exc:
categorization.set_category_mcc_codes(
+346
View File
@@ -0,0 +1,346 @@
from datetime import date
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.budgeting.service import (
add_expected_income,
close_period,
delete_expected_income,
expected_income_items,
get_or_create_period,
match_expected_income,
unmatch_expected_income,
update_expected_income,
)
from app.errors import AppError
from app.identity.models import User
from app.ledger.service import create_account, create_income
from app.spaces.service import create_space
def seed(db: Session):
user = User(email="expected@example.test", password_hash="unused-in-domain-tests")
db.add(user)
db.flush()
return user, create_space(db, user=user, name="Expected")
def test_expected_income_crud_and_validation(db: Session) -> None:
user, space = seed(db)
period = get_or_create_period(db, space_id=space.id, year=2026, month=9)
with pytest.raises(AppError) as exc:
add_expected_income(
db,
space_id=space.id,
user=user,
period=period,
expected_on=date(2026, 10, 1),
amount_kopecks=1_000,
source_name=None,
)
assert exc.value.code == "BUDGET_EXPECTED_INCOME_DATE_OUTSIDE"
income = add_expected_income(
db,
space_id=space.id,
user=user,
period=period,
expected_on=date(2026, 9, 25),
amount_kopecks=120_000,
source_name="Salary",
note="net",
)
assert expected_income_items(db, space_id=space.id, period=period) == [
{
"id": str(income.id),
"expected_on": "2026-09-25",
"amount_kopecks": 120_000,
"source_name": "Salary",
"note": "net",
"matched_kopecks": 0,
}
]
update_expected_income(
db,
space_id=space.id,
user=user,
period=period,
income_id=income.id,
changes={"amount_kopecks": 130_000, "note": None, "expected_on": date(2026, 9, 30)},
)
refreshed = expected_income_items(db, space_id=space.id, period=period)[0]
assert refreshed["amount_kopecks"] == 130_000
assert refreshed["expected_on"] == "2026-09-30"
assert refreshed["note"] is None
with pytest.raises(AppError) as exc:
update_expected_income(
db,
space_id=space.id,
user=user,
period=period,
income_id=income.id,
changes={"expected_on": date(2026, 8, 31)},
)
assert exc.value.code == "BUDGET_EXPECTED_INCOME_DATE_OUTSIDE"
with pytest.raises(AppError) as exc:
update_expected_income(
db,
space_id=space.id,
user=user,
period=period,
income_id=income.id,
changes={"amount_kopecks": 0},
)
assert exc.value.code == "MONEY_AMOUNT_MUST_BE_POSITIVE"
delete_expected_income(db, space_id=space.id, user=user, period=period, income_id=income.id)
assert expected_income_items(db, space_id=space.id, period=period) == []
def test_expected_income_match_unmatch_and_delete_cascade(db: Session) -> None:
user, space = seed(db)
period = get_or_create_period(db, space_id=space.id, year=2026, month=9)
account = create_account(
db,
space_id=space.id,
user=user,
name="Wallet",
kind="cash",
opened_on=date(2026, 9, 1),
)
income = add_expected_income(
db,
space_id=space.id,
user=user,
period=period,
expected_on=date(2026, 9, 25),
amount_kopecks=100_000,
source_name="Salary",
)
operation = create_income(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=40_000,
occurred_on=date(2026, 9, 20),
)
with pytest.raises(AppError) as exc:
match_expected_income(
db,
space_id=space.id,
user=user,
period=period,
expected_income_id=income.id,
operation_id=operation.id,
amount_kopecks=50_000,
)
assert exc.value.code == "BUDGET_MATCH_AMOUNT_INVALID"
match_expected_income(
db,
space_id=space.id,
user=user,
period=period,
expected_income_id=income.id,
operation_id=operation.id,
)
assert (
expected_income_items(db, space_id=space.id, period=period)[0]["matched_kopecks"] == 40_000
)
other_period = get_or_create_period(db, space_id=space.id, year=2026, month=10)
with pytest.raises(AppError) as exc:
match_expected_income(
db,
space_id=space.id,
user=user,
period=other_period,
expected_income_id=income.id,
operation_id=operation.id,
)
assert exc.value.code == "BUDGET_EXPECTED_INCOME_NOT_FOUND"
unmatch_expected_income(
db,
space_id=space.id,
user=user,
period=period,
expected_income_id=income.id,
operation_id=operation.id,
)
assert expected_income_items(db, space_id=space.id, period=period)[0]["matched_kopecks"] == 0
with pytest.raises(AppError) as exc:
unmatch_expected_income(
db,
space_id=space.id,
user=user,
period=period,
expected_income_id=income.id,
operation_id=operation.id,
)
assert exc.value.code == "BUDGET_MATCH_NOT_FOUND"
match_expected_income(
db,
space_id=space.id,
user=user,
period=period,
expected_income_id=income.id,
operation_id=operation.id,
)
delete_expected_income(db, space_id=space.id, user=user, period=period, income_id=income.id)
from app.budgeting.models import ExpectedIncomeMatch
assert db.query(ExpectedIncomeMatch).filter_by(space_id=space.id).count() == 0
def test_expected_income_mutations_guard_closed_period(db: Session) -> None:
user, space = seed(db)
period = get_or_create_period(db, space_id=space.id, year=2026, month=9)
income = add_expected_income(
db,
space_id=space.id,
user=user,
period=period,
expected_on=date(2026, 9, 5),
amount_kopecks=5_000,
source_name=None,
)
close_period(db, space_id=space.id, user=user, period=period)
with pytest.raises(AppError) as exc:
update_expected_income(
db,
space_id=space.id,
user=user,
period=period,
income_id=income.id,
changes={"amount_kopecks": 1},
)
assert exc.value.code == "BUDGET_PERIOD_CLOSED"
with pytest.raises(AppError) as exc:
delete_expected_income(db, space_id=space.id, user=user, period=period, income_id=income.id)
assert exc.value.code == "BUDGET_PERIOD_CLOSED"
def _login(client: TestClient) -> dict[str, str]:
invite = client.post(
"/api/v1/admin/invitations",
headers={"X-Admin-Token": "test-admin-token"},
json={"email": "Expected@Example.com"},
)
assert invite.status_code == 201, invite.text
accepted = client.post(
"/api/v1/auth/invitations/accept",
json={
"token": invite.json()["token"],
"password": "a-long-password-for-tests",
"fullName": "Test Person",
"locale": "en",
},
)
assert accepted.status_code == 201, accepted.text
return {"X-CSRF-Token": client.cookies["csrf_token"]}
def test_expected_income_month_api_crud_and_summary(client: TestClient) -> None:
headers = _login(client)
account = client.post(
"/api/v1/accounts",
headers=headers,
json={
"name": "Wallet",
"type": "cash",
"openedOn": "2026-09-01",
"openingBalanceKopecks": 0,
},
)
assert account.status_code == 201, account.text
account_id = account.json()["id"]
created = client.post(
"/api/v1/budget/2026/9/expected-incomes",
headers=headers,
json={"expectedOn": "2026-09-25", "amountKopecks": 120_000, "sourceName": "Salary"},
)
assert created.status_code == 201, created.text
income = created.json()
assert income["status"] == "expected"
assert income["amountKopecks"] == 120_000
legacy = client.post(
"/api/v1/budget/2026/9/expected-incomes",
headers=headers,
json={"expected_on": "2026-09-26", "amount_kopecks": 1_000, "source_name": "Gift"},
)
assert legacy.status_code == 201, legacy.text
assert legacy.json()["amountKopecks"] == 1_000
legacy_removed = client.delete(
f"/api/v1/budget/2026/9/expected-incomes/{legacy.json()['id']}", headers=headers
)
assert legacy_removed.status_code == 204, legacy_removed.text
listed = client.get("/api/v1/budget/2026/9/expected-incomes")
assert listed.status_code == 200, listed.text
assert [item["id"] for item in listed.json()] == [income["id"]]
patched = client.patch(
f"/api/v1/budget/2026/9/expected-incomes/{income['id']}",
headers=headers,
json={"amountKopecks": 130_000, "note": None},
)
assert patched.status_code == 200, patched.text
assert patched.json()["amountKopecks"] == 130_000
assert patched.json()["note"] is None
rejected = client.patch(
f"/api/v1/budget/2026/9/expected-incomes/{income['id']}",
headers=headers,
json={"expectedOn": "2026-10-01"},
)
assert rejected.status_code == 400, rejected.text
assert rejected.json()["error"]["code"] == "BUDGET_EXPECTED_INCOME_DATE_OUTSIDE"
operation = client.post(
"/api/v1/operations",
headers=headers,
json={
"kind": "income",
"amountKopecks": 80_000,
"accountId": account_id,
"occurredOn": "2026-09-20",
},
)
assert operation.status_code == 201, operation.text
operation_id = operation.json()["id"]
matched = client.post(
f"/api/v1/budget/2026/9/expected-incomes/{income['id']}/match",
headers=headers,
json={"operationId": operation_id},
)
assert matched.status_code == 201, matched.text
assert matched.json()["matchedKopecks"] == 80_000
assert matched.json()["status"] == "partially_matched"
summary = client.get("/api/v1/budget/summary", params={"month": "2026-09"})
assert summary.status_code == 200, summary.text
body = summary.json()
assert body["expectedIncomes"][0]["status"] == "partially_matched"
assert body["projectedPoolKopecks"] == 130_000
unmatched = client.delete(
f"/api/v1/budget/2026/9/expected-incomes/{income['id']}/match/{operation_id}",
headers=headers,
)
assert unmatched.status_code == 204, unmatched.text
deleted = client.delete(
f"/api/v1/budget/2026/9/expected-incomes/{income['id']}", headers=headers
)
assert deleted.status_code == 204, deleted.text
assert client.get("/api/v1/budget/2026/9/expected-incomes").json() == []
+6 -1
View File
@@ -3,10 +3,12 @@
import uuid
from datetime import date
from types import SimpleNamespace
from typing import cast
import pytest
from sqlalchemy.orm import Session
from app.dependencies import RequestContext
from app.errors import AppError
from app.identity.models import User
from app.imports import router as imports_router
@@ -132,6 +134,7 @@ def test_confirm_creates_savings_transfer_with_system_category(db: Session) -> N
db.flush()
savings_account = get_savings_account(db, space_id=space.id)
assert savings_account is not None
operations = db.query(Operation).filter_by(space_id=space.id).all()
assert len(operations) == 1
transfer = operations[0]
@@ -233,6 +236,7 @@ def test_statement_mapping_rejects_system_account(db: Session) -> None:
user, space, account = seed(db)
document, _run = make_document(db, space, account, user=user)
savings_account = get_savings_account(db, space_id=space.id)
assert savings_account is not None
with pytest.raises(AppError) as exc:
service.set_review_confirmation(
@@ -358,7 +362,7 @@ def test_unresolved_hold_counts_and_month_filter(db: Session) -> None:
assert service.unresolved_hold_counts(
db, space_id=space.id, document_ids=[document.id], month=may
) == {document.id: 1}
context = SimpleNamespace(space=space)
context = cast(RequestContext, SimpleNamespace(space=space))
july_items = imports_router.imports(db, context, month="2026-07")
assert july_items[0]["id"] == document.id
assert july_items[0]["unresolvedHoldCount"] == 0
@@ -376,6 +380,7 @@ def test_unresolved_hold_counts_and_month_filter(db: Session) -> None:
def test_confirm_savings_transfer_rejects_savings_statement_account(db: Session) -> None:
user, space, account = seed(db)
savings_account = get_savings_account(db, space_id=space.id)
assert savings_account is not None
document, run = make_document(db, space, savings_account, user=user)
document.account_id = savings_account.id
add_record(db, document, run, description=SAVINGS_PHRASE)
+8 -2
View File
@@ -2,10 +2,12 @@ import uuid
from contextlib import contextmanager
from datetime import date
from types import SimpleNamespace
from typing import cast
import pytest
from sqlalchemy.orm import Session
from app.dependencies import RequestContext
from app.errors import AppError
from app.identity.models import User
from app.imports import router as imports_router
@@ -129,7 +131,9 @@ def test_import_mcc_assigns_default_category_and_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
category = db.get(Category, operation.category_id)
assert category is not None
assert category.system_key == system_key
def test_confirm_rejects_arithmetic_mismatch(db: Session) -> None:
@@ -467,7 +471,9 @@ def test_import_detail_exposes_pending_candidate_and_blocker(db: Session) -> Non
db.add(candidate)
db.flush()
response = imports_router.detail(document.id, db, SimpleNamespace(space=space))
response = imports_router.detail(
document.id, db, cast(RequestContext, SimpleNamespace(space=space))
)
assert response["rows"][0]["decision"] == "pending"
assert response["rows"][0]["candidates"] == [
+2 -1
View File
@@ -20,6 +20,7 @@ Privacy contract (docs/parsers/private-fixture-gate.md):
import json
import os
from pathlib import Path
from typing import NoReturn
import pytest
@@ -75,7 +76,7 @@ def _check(ok: bool, name: str, what: str) -> None:
assert ok, f"{name}: {what} mismatch"
def _fail(name: str, message: str) -> None:
def _fail(name: str, message: str) -> NoReturn:
pytest.fail(f"{name}: {message}", pytrace=False)
+4 -1
View File
@@ -98,6 +98,7 @@ def test_adjust_balance_creates_single_signed_posting(db: Session) -> None:
assert postings[0].amount_kopecks == -25_000
assert account_balance(db, account) == 475_000
event = db.query(AuditEvent).filter_by(event_type="account.balance_adjusted").one()
assert event.detail is not None
assert event.detail["old_balance_kopecks"] == 500_000
assert event.detail["new_balance_kopecks"] == 475_000
assert event.detail["delta_kopecks"] == -25_000
@@ -295,12 +296,14 @@ def test_convert_rejects_income_refund_transfer_and_double_conversion(
convert_expense_to_savings(db, space_id=space.id, user=user, operation_id=expense.id)
assert exc.value.code == "LEDGER_SAVINGS_CONVERT_INVALID"
savings_account = get_savings_account(db, space_id=space.id)
assert savings_account is not None
transfer = create_transfer(
db,
space_id=space.id,
user=user,
source_account_id=account.id,
target_account_id=get_savings_account(db, space_id=space.id).id,
target_account_id=savings_account.id,
amount_kopecks=1_000,
occurred_on=date(2026, 3, 6),
)
+1276 -911
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
Как создать перевод между двумя счетами через выписки.
Вот я выгружаю выписку из одного банка (но для того чтобы отметить транзакцию как перевод нужна вторая такая же на другом счете)
Я не могу применить одновременно две выписки, все равно сначала принимается одна потом вторая. Как сметчить их, чтобы задетектились переводы?
+9
View File
@@ -0,0 +1,9 @@
Используй скиллы pen-dev и design-interface для работы с дизайном в @design.pen
Используй скиллы pen-dev, frontend-design и impeccable для верстки фронта
1. На русском на фронте в меню навигации шрифт сильно отличается от английского. Мне нравится подобранный шрифт на английском. Надо сделать подобный и для русского.
2. Страница This month сильно отличается от дизайна, в дизайне идёт табличка категорий слева как основное меню, в дизайне есть Expected Income, также надо реализовать его и на бэке. В дизайне управление категориями почему-то в This month, хотя его там быть не должно.
3. Страница Бюджет вообще отсутствует в дизайн-доке, доделай. Большая проблема с "Управлять категориями" на странице Бюджет. Текст в кнопках вылезает из рамок (текст кнопки Создать) Поле Name шириной меньше одной буквы, а заголовок полей Name и Type наложились друг на друга. Сначала нарисовать дизайн для этого меню "Manage categories" - потом сверстать по нему
4. При клике вне выезжающего меню (категории или новая операция) оно должно закрываться
5. Расположи на дизайн листе @design.pen экраны таким образом слева направа - разные страницы. Одинаковые страницы в разных состояниях друг над другом в столбик. Мобильные экраны вообще где-нибудь слева сбоку
6. Прогони 'ty check .' и pytest в корне и исправь все выявленные проблемы. Там много чего по тайпчеку и один тест падает
+1
View File
@@ -16,6 +16,7 @@
"dependencies": {
"@fontsource/funnel-sans": "^5.3.0",
"@fontsource/geist-mono": "^5.3.0",
"@fontsource/onest": "^5.3.1",
"@hookform/resolvers": "^5.1.0",
"@tanstack/react-query": "^5.80.0",
"i18next": "^25.2.0",
+8
View File
@@ -14,6 +14,9 @@ importers:
'@fontsource/geist-mono':
specifier: ^5.3.0
version: 5.3.0
'@fontsource/onest':
specifier: ^5.3.1
version: 5.3.1
'@hookform/resolvers':
specifier: ^5.1.0
version: 5.9.1(react-hook-form@7.87.0(react@19.3.0))(zod@3.25.76)
@@ -420,6 +423,9 @@ packages:
'@fontsource/geist-mono@5.3.0':
resolution: {integrity: sha512-UtJ1BBBCVpMYdIcW7nEB45UAoAw5M53ZXs2t0ciPW+IokuAAIc56M8+kW5tXbRJCTpDw4XTtU7proT6NdQAHTg==}
'@fontsource/onest@5.3.1':
resolution: {integrity: sha512-2uA9HUzQHBB8H7T2R6inmrnqPofB6+svATHM/huIG640duJr4rm+uanmAIheTTlbZvRC0ujMtf2T4U4RX51oEQ==}
'@hookform/resolvers@5.9.1':
resolution: {integrity: sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==}
peerDependencies:
@@ -2461,6 +2467,8 @@ snapshots:
'@fontsource/geist-mono@5.3.0': {}
'@fontsource/onest@5.3.1': {}
'@hookform/resolvers@5.9.1(react-hook-form@7.87.0(react@19.3.0))(zod@3.25.76)':
dependencies:
'@standard-schema/utils': 0.3.0
+8
View File
@@ -17,6 +17,8 @@ import type {
CreateCategoryPayload,
CreateOperationPayload,
ExportInfo,
ExpectedIncome,
ExpectedIncomePayload,
ImportDetail,
ImportSummary,
LoginPayload,
@@ -94,6 +96,12 @@ export const api = {
apiClient.post<CategoryDefinition>('/budget/categories', { json: payload }),
updateCategoryDefinition: (id: string, payload: PatchCategoryDefinitionPayload) =>
apiClient.patch<CategoryDefinition>(`/budget/category-definitions/${id}`, { json: payload }),
createExpectedIncome: (month: string, payload: ExpectedIncomePayload) =>
apiClient.post<ExpectedIncome>(`/budget/${month.replace('-', '/')}/expected-incomes`, { json: payload }),
updateExpectedIncome: (month: string, id: string, payload: ExpectedIncomePayload) =>
apiClient.patch<ExpectedIncome>(`/budget/${month.replace('-', '/')}/expected-incomes/${id}`, { json: payload }),
deleteExpectedIncome: (month: string, id: string) =>
apiClient.delete<void>(`/budget/${month.replace('-', '/')}/expected-incomes/${id}`),
},
imports: {
list: (month?: string) =>
+8
View File
@@ -190,10 +190,18 @@ export interface ExpectedIncome {
expectedOn: string
amountKopecks: number
sourceName: string | null
note?: string | null
matchedKopecks: number
status: ExpectedIncomeStatus
}
export interface ExpectedIncomePayload {
expectedOn: string
amountKopecks: number
sourceName: string | null
note: string | null
}
export interface BudgetSummary {
month: string
status: BudgetStatus
@@ -59,18 +59,20 @@ function CreateCategoryForm() {
return (
<form className="manager-form" onSubmit={submit} noValidate>
<TextField
label={t('common.name')}
error={errors.name?.message}
{...register('name')}
/>
<SelectField label={t('accounts.type')} error={errors.kind?.message} {...register('kind')}>
{CREATE_KINDS.map((kind) => (
<option key={kind} value={kind}>
{t(`budget.category.kind.${kind}`)}
</option>
))}
</SelectField>
<div className="manager-form-fields">
<TextField
label={t('common.name')}
error={errors.name?.message}
{...register('name')}
/>
<SelectField label={t('accounts.type')} error={errors.kind?.message} {...register('kind')}>
{CREATE_KINDS.map((kind) => (
<option key={kind} value={kind}>
{t(`budget.category.kind.${kind}`)}
</option>
))}
</SelectField>
</div>
{watch('kind') === 'monthly' ? (
<TextField
label={t('budget.manager.mccCodes')}
+2 -2
View File
@@ -111,7 +111,7 @@ const MOBILE_NAV_ROUTES: readonly string[] = ['/', '/operations', '/budget', '/i
const MOBILE_NAV_ITEMS = NAV_ITEMS.filter((item) => MOBILE_NAV_ROUTES.includes(item.to))
export function AppLayout() {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const me = useMe()
const logout = useLogout()
const navigate = useNavigate()
@@ -124,7 +124,7 @@ export function AppLayout() {
})
return (
<div className="app-shell">
<div className="app-shell" lang={i18n.language}>
<a className="skip-link" href="#main">
{t('common.skipToContent')}
</a>
+13
View File
@@ -21,6 +21,19 @@ export function Modal({ open, onClose, title, children }: ModalProps) {
if (!open && dialog.open) dialog.close()
}, [open])
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
const closeOnBackdrop = (event: MouseEvent) => {
if (event.target !== dialog) return
const bounds = dialog.getBoundingClientRect()
if (event.clientX < bounds.left || event.clientX > bounds.right ||
event.clientY < bounds.top || event.clientY > bounds.bottom) onClose()
}
dialog.addEventListener('click', closeOnBackdrop)
return () => dialog.removeEventListener('click', closeOnBackdrop)
}, [onClose])
return (
<dialog
ref={dialogRef}
+24 -1
View File
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '@/api/endpoints'
import { invalidationRoots, queryKeys } from '@/api/queryKeys'
import type { CreateCategoryPayload, PatchCategoryDefinitionPayload } from '@/api/types'
import type { CreateCategoryPayload, ExpectedIncomePayload, PatchCategoryDefinitionPayload } from '@/api/types'
import { useToast } from '@/components/ui/Toaster'
import { useTranslation } from 'react-i18next'
import { describeApiError } from '@/lib/apiErrors'
@@ -17,6 +17,29 @@ export function useBudgetCategories(month: string) {
})
}
export function useExpectedIncomeMutation(month: string) {
const queryClient = useQueryClient()
const onError = useBudgetErrorToaster()
const invalidate = () => void queryClient.invalidateQueries({ queryKey: queryKeys.budgetSummary(month) })
const create = useMutation({
mutationFn: (payload: ExpectedIncomePayload) => api.budget.createExpectedIncome(month, payload),
onSuccess: invalidate,
onError,
})
const update = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: ExpectedIncomePayload }) =>
api.budget.updateExpectedIncome(month, id, payload),
onSuccess: invalidate,
onError,
})
const remove = useMutation({
mutationFn: (id: string) => api.budget.deleteExpectedIncome(month, id),
onSuccess: invalidate,
onError,
})
return { create, update, remove }
}
export function useCategoryDefinitions() {
return useQuery({
queryKey: queryKeys.budgetCategoryDefinitions(),
+10 -1
View File
@@ -37,7 +37,14 @@
"readyToClose": "No blockers. This month is ready to close.",
"importsNeedReview": "{{count}} statement needs review",
"reviewBlockers": "Review blockers",
"categoryPulse": "Category pulse"
"categoryPulse": "Category pulse",
"noExpectedIncome": "No expected income yet. Add an estimate for this month.",
"addIncome": "Add income",
"editIncome": "Edit expected income",
"incomeNote": "Note (optional)",
"incomeInvalid": "Enter a positive amount and a date in this month.",
"deleteIncome": "Delete expected income",
"deleteIncomeConfirm": "Remove this expected income from the monthly forecast?"
},
"authShell": {
"statement": "Your household ledger, sealed by design.",
@@ -65,6 +72,8 @@
"create": "Create",
"dash": "—",
"date": "Date",
"delete": "Delete",
"edit": "Edit",
"empty": "Nothing here yet",
"loading": "Loading…",
"name": "Name",
+10 -1
View File
@@ -37,7 +37,14 @@
"readyToClose": "Блокировок нет. Месяц можно закрывать.",
"importsNeedReview": "Выписок на проверке: {{count}}",
"reviewBlockers": "Разобрать блокировки",
"categoryPulse": "Состояние категорий"
"categoryPulse": "Состояние категорий",
"noExpectedIncome": "Ожидаемых доходов пока нет. Добавьте прогноз на этот месяц.",
"addIncome": "Добавить доход",
"editIncome": "Изменить ожидаемый доход",
"incomeNote": "Примечание (необязательно)",
"incomeInvalid": "Укажите положительную сумму и дату в этом месяце.",
"deleteIncome": "Удалить ожидаемый доход",
"deleteIncomeConfirm": "Удалить этот доход из прогноза месяца?"
},
"authShell": {
"statement": "Семейный гроссбух, закрытый по замыслу.",
@@ -65,6 +72,8 @@
"create": "Создать",
"dash": "—",
"date": "Дата",
"delete": "Удалить",
"edit": "Изменить",
"empty": "Пока ничего нет",
"loading": "Загрузка…",
"name": "Название",
+8
View File
@@ -4,6 +4,14 @@ import '@fontsource/funnel-sans/400.css'
import '@fontsource/funnel-sans/500.css'
import '@fontsource/funnel-sans/600.css'
import '@fontsource/funnel-sans/700.css'
import '@fontsource/onest/cyrillic-400.css'
import '@fontsource/onest/cyrillic-500.css'
import '@fontsource/onest/cyrillic-600.css'
import '@fontsource/onest/cyrillic-700.css'
import '@fontsource/onest/latin-400.css'
import '@fontsource/onest/latin-500.css'
import '@fontsource/onest/latin-600.css'
import '@fontsource/onest/latin-700.css'
import '@fontsource/geist-mono/400.css'
import '@fontsource/geist-mono/500.css'
import '@fontsource/geist-mono/600.css'
+17 -42
View File
@@ -293,47 +293,8 @@ export function BudgetPage() {
</dl>
<QueryGate query={categories}>
{() => (
<>
{summary.data && summary.data.expectedIncomes.length > 0 ? (
<section aria-label={t('budget.expectedIncome')}>
<h2 className="section-title">{t('budget.expectedIncome')}</h2>
<table className="data-table">
<caption className="visually-hidden">{t('budget.expectedIncome')}</caption>
<thead>
<tr>
<th scope="col">{t('common.date')}</th>
<th scope="col">{t('budget.incomeSource')}</th>
<th scope="col" className="num">{t('common.amount')}</th>
<th scope="col" className="num">{t('budget.matched')}</th>
<th scope="col">{t('common.status')}</th>
</tr>
</thead>
<tbody>
{summary.data.expectedIncomes.map((income) => (
<tr key={income.id}>
<th scope="row" data-label={t('common.date')}>
{formatDate(income.expectedOn, locale)}
</th>
<td data-label={t('budget.incomeSource')}>
{income.sourceName ?? t('budget.noSource')}
</td>
<td data-label={t('common.amount')} className="num">
{formatMoney(income.amountKopecks, locale)}
</td>
<td data-label={t('budget.matched')} className="num">
{formatMoney(income.matchedKopecks, locale)}
</td>
<td data-label={t('common.status')}>
<Badge tone={income.status === 'matched' ? 'accent' : 'warn'}>
{t(`budget.income.status.${income.status}`)}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</section>
) : null}
<div className="budget-workspace">
<div className="budget-category-column">
<section aria-label={t('budget.categories')} id="categories">
<div className="section-heading categories-heading">
<h2 className="section-title">{t('budget.categories')}</h2>
@@ -377,7 +338,21 @@ export function BudgetPage() {
</table>
</section>
) : null}
</>
</div>
<aside className="budget-income-aside" aria-label={t('budget.expectedIncome')}>
<div className="section-heading"><h2>{t('budget.expectedIncome')}</h2></div>
{summary.data?.expectedIncomes.length ? (
<ul className="expected-income-list">
{summary.data.expectedIncomes.map((income) => (
<li key={income.id}>
<div><strong>{income.sourceName ?? t('budget.noSource')}</strong><small>{formatDate(income.expectedOn, locale)} · {t(`budget.income.status.${income.status}`)}</small></div>
<span className="num">{formatMoney(income.amountKopecks, locale)}</span>
</li>
))}
</ul>
) : <p className="muted">{t('overview.noExpectedIncome')}</p>}
</aside>
</div>
)}
</QueryGate>
{planTarget ? (
+114 -42
View File
@@ -1,14 +1,21 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import type { FormEvent } from 'react'
import type { ExpectedIncome } from '@/api/types'
import { BudgetBlockers } from '@/components/budget/BudgetBlockers'
import { Button } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { MoneyField, TextField } from '@/components/ui/fields'
import { Modal } from '@/components/ui/Modal'
import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate'
import { useBudgetCategories, useBudgetSummary } from '@/hooks/useBudget'
import { useBudgetCategories, useBudgetSummary, useExpectedIncomeMutation } from '@/hooks/useBudget'
import { useImports } from '@/hooks/useImports'
import { categoryLabel } from '@/lib/categoryLabel'
import { currentMonth, formatMonth } from '@/lib/dates'
import { currentMonth, formatDate, formatMonth, monthBounds, todayISO } from '@/lib/dates'
import { currentLocale } from '@/lib/locale'
import { formatMoney } from '@/lib/money'
import { formatMoney, kopecksToInput, parseAmountToKopecks } from '@/lib/money'
export function OverviewPage() {
const { t, i18n } = useTranslation()
@@ -16,10 +23,40 @@ export function OverviewPage() {
const month = currentMonth()
const summary = useBudgetSummary(month)
const categories = useBudgetCategories(month)
const incomeMutation = useExpectedIncomeMutation(month)
const imports = useImports()
const [incomeEditor, setIncomeEditor] = useState<ExpectedIncome | 'new' | null>(null)
const [deleteTarget, setDeleteTarget] = useState<ExpectedIncome | null>(null)
const [expectedOn, setExpectedOn] = useState(todayISO())
const [sourceName, setSourceName] = useState('')
const [amount, setAmount] = useState('')
const [note, setNote] = useState('')
const [formError, setFormError] = useState('')
const money = (value: number) => formatMoney(value, locale)
const attentionImports = (imports.data ?? []).filter((item) => item.status === 'review_required')
const openIncomeEditor = (income?: ExpectedIncome) => {
setExpectedOn(income?.expectedOn ?? todayISO())
setSourceName(income?.sourceName ?? '')
setAmount(income ? kopecksToInput(income.amountKopecks) : '')
setNote(income?.note ?? '')
setFormError('')
setIncomeEditor(income ?? 'new')
}
const saveIncome = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const amountKopecks = parseAmountToKopecks(amount)
if (!amountKopecks || amountKopecks <= 0 || !expectedOn.startsWith(`${month}-`)) {
setFormError(t('overview.incomeInvalid'))
return
}
const payload = { expectedOn, amountKopecks, sourceName: sourceName.trim() || null, note: note.trim() || null }
const onSuccess = () => setIncomeEditor(null)
if (incomeEditor === 'new') incomeMutation.create.mutate(payload, { onSuccess })
else if (incomeEditor) incomeMutation.update.mutate({ id: incomeEditor.id, payload }, { onSuccess })
}
return (
<div className="overview-page">
<PageHeader
@@ -47,51 +84,86 @@ export function OverviewPage() {
<div className="stat"><dt>{t('budget.spentTotal')}</dt><dd className="num">{money(data.spentTotalKopecks)}</dd><small>{t('overview.ofFunded')}</small></div>
</dl>
<div className="overview-workbench">
<section className="month-story">
<div className="section-heading">
<h2>{t('overview.monthStory')}</h2>
<span>{t('overview.liveState')}</span>
</div>
<ol className="story-list">
<li><span>{t('overview.poolAndIncome')}</span><strong className="num">{money(data.actualPoolKopecks + data.fundedTotalKopecks)}</strong></li>
<li><span>{t('overview.funded')}</span><strong className="num neg">−{money(data.fundedTotalKopecks)}</strong></li>
<li><span>{t('overview.spent')}</span><strong className="num neg">−{money(data.spentTotalKopecks)}</strong></li>
<li><span>{t('overview.poolRemaining')}</span><strong className="num">{money(data.actualPoolKopecks)}</strong></li>
</ol>
<section className="overview-categories" aria-label={t('budget.categories')}>
<div className="section-heading"><h2>{t('budget.categories')}</h2><Link to="/budget">{t('overview.openBudget')}</Link></div>
<QueryGate query={categories}>
{(items) => items.filter((item) => !item.archived).length > 0 ? (
<table className="data-table">
<caption className="visually-hidden">{t('budget.categories')}</caption>
<thead><tr>
<th scope="col">{t('common.name')}</th>
<th scope="col" className="num">{t('budget.planned')}</th>
<th scope="col" className="num">{t('budget.funded')}</th>
<th scope="col" className="num">{t('budget.spent')}</th>
<th scope="col" className="num">{t('budget.remaining')}</th>
</tr></thead>
<tbody>{items.filter((item) => !item.archived).map((item) => (
<tr key={item.id}>
<th scope="row">{categoryLabel(item, t)}</th>
<td className="num" data-label={t('budget.planned')}>{money(item.plannedKopecks)}</td>
<td className="num" data-label={t('budget.funded')}>{money(item.fundedKopecks)}</td>
<td className="num" data-label={t('budget.spent')}>{money(item.spentKopecks)}</td>
<td className={`num ${item.availableKopecks < 0 ? 'neg' : ''}`} data-label={t('budget.remaining')}>{money(item.availableKopecks)}</td>
</tr>
))}</tbody>
</table>
) : <p className="muted">{t('budget.noCategories')}</p>}
</QueryGate>
</section>
<aside className="decision-panel">
<div className="section-heading"><h2>{t('overview.closeReadiness')}</h2><span>{data.blockers.length} {t('overview.blockers')}</span></div>
{data.blockers.length > 0 ? (
<BudgetBlockers blockers={data.blockers} month={month} />
) : <p>{t('overview.readyToClose')}</p>}
{attentionImports.length > 0 ? <p>{t('overview.importsNeedReview', { count: attentionImports.length })}</p> : null}
<Link className="btn btn-subtle" to="/review">{t('overview.reviewBlockers')}</Link>
</aside>
<div className="overview-aside">
<section className="expected-income" aria-label={t('budget.expectedIncome')}>
<div className="section-heading">
<h2>{t('budget.expectedIncome')}</h2>
{data.status === 'open' ? <Button variant="ghost" onClick={() => openIncomeEditor()}>{t('overview.addIncome')}</Button> : null}
</div>
{data.expectedIncomes.length ? (
<ul className="expected-income-list">
{data.expectedIncomes.map((income) => (
<li key={income.id}>
<div><strong>{income.sourceName ?? t('budget.noSource')}</strong><small>{formatDate(income.expectedOn, locale)} · {t(`budget.income.status.${income.status}`)}</small></div>
<span className="num">{money(income.amountKopecks)}</span>
{data.status === 'open' ? <div className="income-actions">
<Button variant="ghost" onClick={() => openIncomeEditor(income)}>{t('common.edit')}</Button>
<Button variant="ghost" onClick={() => setDeleteTarget(income)}>{t('common.delete')}</Button>
</div> : null}
</li>
))}
</ul>
) : <p className="muted">{t('overview.noExpectedIncome')}</p>}
</section>
<aside className="decision-panel">
<div className="section-heading"><h2>{t('overview.closeReadiness')}</h2><span>{data.blockers.length} {t('overview.blockers')}</span></div>
{data.blockers.length > 0 ? <BudgetBlockers blockers={data.blockers} month={month} /> : <p>{t('overview.readyToClose')}</p>}
{attentionImports.length > 0 ? <p>{t('overview.importsNeedReview', { count: attentionImports.length })}</p> : null}
<Link className="btn btn-subtle" to="/review">{t('overview.reviewBlockers')}</Link>
</aside>
</div>
</div>
</>
)}
</QueryGate>
<section className="category-pulse" aria-label={t('overview.categoryPulse')}>
<div className="section-heading"><h2>{t('overview.categoryPulse')}</h2><Link to="/budget">{t('overview.openBudget')}</Link></div>
<QueryGate query={categories}>
{(items) => (
<div className="pulse-grid">
{items.filter((item) => !item.archived).slice(0, 4).map((item) => {
const ratio = item.fundedKopecks > 0 ? Math.min(item.spentKopecks / item.fundedKopecks, 1) : 0
return (
<article className="pulse-item" key={item.id}>
<span>{categoryLabel(item, t)}</span>
<strong className="num">{money(item.availableKopecks)}</strong>
<small>{t('budget.available')}</small>
<span className="pulse-track"><span style={{ width: `${ratio * 100}%` }} /></span>
</article>
)
})}
</div>
)}
</QueryGate>
</section>
<div className="mobile-primary-action"><Link className="btn btn-primary" to="/operations">{t('overview.newOperation')}</Link></div>
<Modal open={incomeEditor !== null} onClose={() => setIncomeEditor(null)} title={t(incomeEditor === 'new' ? 'overview.addIncome' : 'overview.editIncome')}>
<form className="form-grid" onSubmit={saveIncome}>
<TextField label={t('budget.incomeSource')} value={sourceName} onChange={(event) => setSourceName(event.target.value)} />
<MoneyField label={t('common.amount')} value={amount} onChange={(event) => setAmount(event.target.value)} required />
<TextField label={t('common.date')} type="date" min={monthBounds(month).start} max={monthBounds(month).end} value={expectedOn} onChange={(event) => setExpectedOn(event.target.value)} required />
<TextField label={t('overview.incomeNote')} value={note} onChange={(event) => setNote(event.target.value)} />
{formError ? <p className="field-error" role="alert">{formError}</p> : null}
<Button type="submit" disabled={incomeMutation.create.isPending || incomeMutation.update.isPending}>{t('common.save')}</Button>
</form>
</Modal>
<ConfirmDialog
open={deleteTarget !== null}
danger
title={t('overview.deleteIncome')}
body={t('overview.deleteIncomeConfirm')}
confirmLabel={t('common.delete')}
onClose={() => setDeleteTarget(null)}
onConfirm={() => {
if (deleteTarget) incomeMutation.remove.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null) })
}}
/>
</div>
)
}
+46 -10
View File
@@ -17,7 +17,7 @@
--amber-soft: #fff0c7;
--red: #b42f25;
--red-soft: #fbe2df;
--font-ui: 'Funnel Sans', 'Arial Narrow', Arial, sans-serif;
--font-ui: 'Funnel Sans', 'Onest', Arial, sans-serif;
--font-data: 'Geist Mono', 'SFMono-Regular', Consolas, monospace;
--sidebar: 238px;
}
@@ -206,6 +206,11 @@ a {
text-decoration: none;
}
.app-shell:lang(ru) .nav-pill,
.app-shell:lang(ru) .mobile-nav a {
font-family: 'Onest', sans-serif;
}
.nav-pill:hover { color: var(--white); background: #202020; }
.nav-pill[aria-current='page'] {
@@ -657,19 +662,29 @@ input[type='file']::file-selector-button { min-height: 30px; margin-right: 10px;
.categories-heading { margin: 34px 0 14px; }
.categories-heading .section-title { margin: 0; }
.budget-workspace { display: grid; grid-template-columns: minmax(0, 1fr) minmax(275px, 350px); align-items: start; gap: 20px; }
.budget-category-column { min-width: 0; }
.budget-category-column .categories-heading { margin-top: 0; }
.budget-income-aside { min-width: 0; background: var(--white); border: 1px solid var(--line); }
.budget-income-aside .section-heading { margin: 0; padding: 14px; background: var(--paper-deep); }
.budget-income-aside > .muted { padding: 0 14px 14px; font-size: 12px; }
.manager-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(130px, auto) auto; gap: 10px; align-items: end; margin-bottom: 18px; }
.manager-form { display: grid; min-width: 0; gap: 14px; margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid #3d3d3a; }
.manager-form-fields { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); gap: 10px; }
.manager-form-fields .field { min-width: 0; }
.manager-form-fields .input { min-width: 0; }
.manager-form .btn { min-height: 44px; }
.manager-tabs { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0; margin-bottom: 14px; border: 1px solid #3d3d3a; }
.manager-tabs .btn { min-width: 0; color: #858580; border: 0; border-right: 1px solid #3d3d3a; font-family: var(--font-data); font-size: 10px; }
.manager-tabs .btn:last-child { border-right: 0; }
.manager-tabs .btn-primary { color: var(--white); background: var(--blue); }
.manager-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; border-top: 1px solid #3d3d3a; }
.manager-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 10px 0; border-bottom: 1px solid #3d3d3a; }
.manager-row { display: grid; gap: 10px; min-width: 0; padding: 13px 0; border-bottom: 1px solid #3d3d3a; }
.manager-row-main { display: grid; gap: 3px; min-width: 0; }
.manager-row-name { font-weight: 500; }
.manager-row-meta { color: #858580; font-family: var(--font-data); font-size: 10px; }
.manager-row-actions .btn { min-height: 30px; padding: 5px 8px; font-size: 11px; }
.manager-row-name { overflow-wrap: anywhere; font-weight: 500; }
.manager-row-meta { color: #aaa9a5; font-family: var(--font-data); font-size: 10px; overflow-wrap: anywhere; }
.manager-row-actions { width: 100%; }
.manager-row-actions .btn { min-height: 32px; padding: 6px 9px; font-size: 11px; line-height: 1.25; white-space: normal; }
.steps {
display: grid;
@@ -744,7 +759,26 @@ input[type='file']::file-selector-button { min-height: 30px; margin-right: 10px;
.overview-page .page-header { margin-bottom: 20px; }
.mobile-pool { display: none; }
.overview-workbench { display: grid; grid-template-columns: minmax(0, 1.75fr) minmax(260px, 0.75fr); gap: 16px; margin-bottom: 28px; }
.overview-workbench { display: grid; grid-template-columns: minmax(0, 1fr) minmax(275px, 350px); align-items: start; gap: 20px; margin-bottom: 28px; }
.overview-categories { min-width: 0; }
.overview-categories .data-table { table-layout: fixed; }
.overview-categories .data-table th:first-child { width: 26%; }
.overview-categories .data-table td { white-space: nowrap; }
.overview-aside { display: grid; gap: 16px; min-width: 0; }
.expected-income { background: var(--white); border: 1px solid var(--line); }
.expected-income .section-heading { margin: 0; padding: 14px; background: var(--paper-deep); }
.expected-income-list { margin: 0; padding: 0; list-style: none; }
.expected-income-list li { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 14px; border-top: 1px solid var(--line); }
.expected-income-list li { flex-wrap: wrap; }
.expected-income-list li > div { display: grid; gap: 4px; min-width: 0; }
.expected-income-list .income-actions { display: flex; width: 100%; gap: 4px; }
.income-actions .btn { min-height: 28px; padding: 4px 8px; color: var(--blue-dark); font-size: 11px; }
.income-actions .btn:hover { color: var(--white); background: var(--blue-dark); }
.expected-income .section-heading .btn { min-height: 30px; padding: 5px 8px; color: var(--blue-dark); white-space: nowrap; }
.expected-income-list strong { overflow-wrap: anywhere; font-size: 13px; font-weight: 600; }
.expected-income-list small { color: var(--muted); font-size: 11px; }
.expected-income-list .num { flex-shrink: 0; font-size: 12px; }
.expected-income > .muted { padding: 0 14px 14px; font-size: 12px; }
.month-story { padding: 18px 0 0; }
.story-list { margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--line); }
.story-list li { display: flex; justify-content: space-between; gap: 20px; padding: 14px 4px; border-bottom: 1px solid var(--line); }
@@ -852,9 +886,10 @@ input[type='file']::file-selector-button { min-height: 30px; margin-right: 10px;
.mobile-pool strong { margin: 4px 0; font-size: 28px; }
.overview-stats .stat:first-child { display: none; }
.overview-stats .stat:nth-child(4) { display: block; }
.overview-workbench { display: block; margin-top: 20px; }
.overview-workbench { display: grid; grid-template-columns: minmax(0, 1fr); margin-top: 20px; }
.overview-categories .data-table { table-layout: auto; }
.month-story { display: none; }
.decision-panel { margin-bottom: 24px; padding: 14px; color: var(--ink); background: var(--amber-soft); }
.decision-panel { padding: 14px; color: var(--ink); background: var(--amber-soft); }
.decision-panel .section-heading { border-color: #d4ad52; }
.decision-panel .section-heading h2,
.decision-panel p,
@@ -862,7 +897,8 @@ input[type='file']::file-selector-button { min-height: 30px; margin-right: 10px;
.decision-panel li { border-color: #e0c57f; }
.decision-panel .btn { color: var(--ink); background: transparent; border: 1px solid var(--ink); }
.decision-panel .blocker-link { color: var(--blue); }
.manager-form { grid-template-columns: 1fr; }
.manager-form-fields { grid-template-columns: 1fr; }
.budget-workspace { grid-template-columns: minmax(0, 1fr); }
.manager-row-actions { width: 100%; }
.manager-row-actions .btn { flex: 1; }
.pulse-grid { display: block; border: 0; }
+7 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import userEvent from '@testing-library/user-event'
import { within } from '@testing-library/react'
import { fireEvent, within } from '@testing-library/react'
import { api } from '@/api/endpoints'
import type { BudgetCategory, BudgetSummary, CategoryDefinition } from '@/api/types'
import { BudgetPage } from '@/pages/BudgetPage'
@@ -142,6 +142,12 @@ describe('BudgetPage category manager lifecycle', () => {
expect(await screen.findByText('Category “Dining” created')).toBeInTheDocument()
})
it('closes the drawer when the backdrop is clicked', async () => {
const { screen, dialog } = await openManager()
fireEvent.click(dialog, { clientX: -10 })
expect(screen.queryByRole('dialog', { name: 'Manage categories' })).not.toBeInTheDocument()
})
it('creates a category with multiple MCC codes', async () => {
createCategoryMock.mockResolvedValue(definition({ id: 'def-new', mccCodes: [5999, 5994] }))
const { dialog } = await openManager()
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import userEvent from '@testing-library/user-event'
import { within } from '@testing-library/react'
import { api } from '@/api/endpoints'
import type { BudgetSummary, ExpectedIncome } from '@/api/types'
import { OverviewPage } from '@/pages/OverviewPage'
import { currentMonth, todayISO } from '@/lib/dates'
import { renderWithProviders } from './helpers'
vi.mock('@/api/endpoints', () => ({
api: {
budget: {
summary: vi.fn(), categories: vi.fn(),
createExpectedIncome: vi.fn(), updateExpectedIncome: vi.fn(), deleteExpectedIncome: vi.fn(),
},
imports: { list: vi.fn() },
},
}))
const month = currentMonth()
const income: ExpectedIncome = {
id: 'income-1', expectedOn: todayISO(), amountKopecks: 250000,
sourceName: 'Salary', note: null, matchedKopecks: 0, status: 'expected',
}
const summary: BudgetSummary = {
month, status: 'open', projectedPoolKopecks: 250000, actualPoolKopecks: 0,
plannedTotalKopecks: 0, fundedTotalKopecks: 0, spentTotalKopecks: 0,
availableKopecks: 0, warnings: [], blockers: [], expectedIncomes: [income],
}
describe('This month expected income', () => {
beforeEach(() => {
vi.mocked(api.budget.summary).mockResolvedValue(summary)
vi.mocked(api.budget.categories).mockResolvedValue([{
id: 'groceries', name: 'Groceries', nameKey: null, kind: 'monthly', system: false,
archived: false, creditAccountId: null, plannedKopecks: 500000,
fundedKopecks: 300000, spentKopecks: 100000, availableKopecks: 200000,
}])
vi.mocked(api.imports.list).mockResolvedValue([])
})
afterEach(() => vi.clearAllMocks())
it('shows income alongside the category table without category management', async () => {
const screen = renderWithProviders(<OverviewPage />)
expect(await screen.findByText('Salary')).toBeInTheDocument()
expect(screen.getByRole('table', { name: 'Categories' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Manage categories' })).not.toBeInTheDocument()
})
it('creates an expected income for the current month', async () => {
vi.mocked(api.budget.createExpectedIncome).mockResolvedValue(income)
const screen = renderWithProviders(<OverviewPage />)
await screen.findByText('Salary')
await userEvent.click(screen.getByRole('button', { name: 'Add income' }))
const dialog = screen.getByRole('dialog', { name: 'Add income' })
await userEvent.type(within(dialog).getByLabelText('Source'), 'Side job')
await userEvent.type(within(dialog).getByLabelText('Amount'), '1234.50')
await userEvent.click(within(dialog).getByRole('button', { name: 'Save' }))
expect(api.budget.createExpectedIncome).toHaveBeenCalledWith(month, {
expectedOn: todayISO(), amountKopecks: 123450, sourceName: 'Side job', note: null,
})
})
it('edits and deletes an existing expected income', async () => {
vi.mocked(api.budget.updateExpectedIncome).mockResolvedValue(income)
vi.mocked(api.budget.deleteExpectedIncome).mockResolvedValue(undefined)
const screen = renderWithProviders(<OverviewPage />)
await screen.findByText('Salary')
await userEvent.click(screen.getByRole('button', { name: 'Edit' }))
const editor = screen.getByRole('dialog', { name: 'Edit expected income' })
await userEvent.clear(within(editor).getByLabelText('Amount'))
await userEvent.type(within(editor).getByLabelText('Amount'), '3500')
await userEvent.click(within(editor).getByRole('button', { name: 'Save' }))
expect(api.budget.updateExpectedIncome).toHaveBeenCalledWith(month, 'income-1', {
expectedOn: todayISO(), amountKopecks: 350000, sourceName: 'Salary', note: null,
})
await userEvent.click(screen.getByRole('button', { name: 'Delete' }))
const confirm = screen.getByRole('dialog', { name: 'Delete expected income' })
await userEvent.click(within(confirm).getByRole('button', { name: 'Delete' }))
expect(api.budget.deleteExpectedIncome).toHaveBeenCalledWith(month, 'income-1')
})
})
+37 -30
View File
@@ -1,33 +1,33 @@
{
"0": "Category",
"0": "imports/router.py",
"1": "budgeting/router.py",
"2": "db/models.py",
"3": "AppError",
"4": "imports/router.py",
"2": "Base",
"3": "main.py",
"4": "test_import_savings.py",
"5": "alfa_xlsx.py",
"6": "system-account-picker.test.tsx",
"7": "types.ts",
"8": "router.tsx",
"8": "LoginPage.tsx",
"9": "ledger/router.py",
"10": "@testing-library/jest-dom",
"11": "compilerOptions",
"12": "ReviewQueuePage.tsx",
"13": "@testing-library/user-event",
"12": "AnalyticsPage.tsx",
"13": "mcc.py",
"14": "devDependencies",
"15": "ledger/service.py",
"16": "dependencies",
"17": "ImportDetailPage.tsx",
"17": "useOperations.ts",
"18": "OperationsPage.tsx",
"19": "Base",
"19": "analytics/service.py",
"20": "apiErrors.ts",
"21": "adapters/base.py",
"21": "test_adapter_contracts.py",
"22": "BudgetPage.tsx",
"23": "client.ts",
"24": "fields.tsx",
"24": "imports/service.py",
"25": "Docker Compose Development Stack",
"26": "Financial Space",
"27": "scripts",
"28": "@types/react",
"28": "router.tsx",
"29": "General Pool",
"30": "TestBaseContract",
"31": "Per-File Envelope Encryption",
@@ -37,20 +37,20 @@
"35": "Closed Beta Launch Gates",
"36": "package.json",
"37": "Financial Tracker MVP",
"38": "test_adapter_contracts.py",
"38": "TestRocketbankDebitContract",
"39": "generate-api.mjs",
"40": "schema.d.ts",
"41": "test_api.py",
"42": "i18n-parity.test.ts",
"43": "Expected Income",
"44": "helpers.tsx",
"45": "ledger/models.py",
"44": "api",
"45": "AccountsPage.tsx",
"46": "category-manager.test.tsx",
"47": "graphify",
"48": "@types/react-dom",
"48": "index.ts",
"49": "typescript-eslint",
"50": "Ближайший край: накопления, категории и блокировки бюджета",
"51": "vitest",
"51": "RocketbankDebitPdfAdapter",
"52": "Favicon SVG (ledger lines icon)",
"53": "init-app-role.sh",
"54": "app/__init__.py",
@@ -62,36 +62,43 @@
"60": "vite.config.ts",
"61": "finance-backend",
"62": "LocalDirectoryBlobStore",
"63": "OzonDebitPdfAdapter",
"63": "TestOzonDebitContract",
"64": "Gitea CI and real statement imports",
"65": "20260918_0002_hold_lifecycle.py",
"66": "eslint-plugin-react-hooks",
"67": "import-hold-resolution.test.tsx",
"68": "budgeting/service.py",
"69": "TestAlfaDebitContract",
"70": "vite",
"71": "mcc.py",
"72": "TestAlfaSavingsRule",
"70": "AppLayout.tsx",
"71": "20260922_0003_savings_system.py",
"72": "jsdom",
"73": "savings-convert-revert.test.tsx",
"74": "get_settings",
"75": "ColumnChart.tsx",
"76": "dependencies.py",
"77": "AGENTS.md",
"78": "graphify.js",
"79": "test_savings.py",
"79": "Session",
"80": "test_category_definitions.py",
"81": "budget-plan.test.tsx",
"82": "imports/service.py",
"82": "TestAlfaSavingsRule",
"83": "exports/service.py",
"84": "test_imports.py",
"84": "@testing-library/react",
"85": "identity/service.py",
"86": "Session",
"86": "test_import_holds.py",
"87": "get_or_create_period",
"88": "20260923_0005_optional_hold_reason.py",
"89": "test_import_savings.py",
"90": "conftest.py",
"89": "typescript",
"90": "useAuth.ts",
"91": "test_review_queue_batch.py",
"92": "review-queue-save.test.tsx",
"94": "account-adjustment.test.tsx",
"99": "0001-fixes.md"
"92": "@vitejs/plugin-react",
"93": "useImports.ts",
"94": "describeApiError",
"95": "app_error_handler",
"96": "review-queue-save.test.tsx",
"97": "перевод_между_своими.md",
"98": "0002-fixes.md",
"99": "0001-fixes.md",
"100": "detect_adapter",
"101": "review-queue-month.test.tsx"
}
+1 -1
View File
@@ -1 +1 @@
{"0": "6d1d9dee2594feb8", "1": "6ce69c6c91bfb10d", "2": "c2a9c6c75ef7b06e", "3": "07ad68ae268274e3", "4": "6e29ff77b331c271", "5": "753b54d66d56a181", "6": "fafcd7f26e85d11e", "7": "7196de2bd58ce7b6", "8": "a1304e2f6d040c08", "9": "12777a923784a450", "10": "0a5a0ba97778625e", "11": "f039d542469fefc7", "12": "68a6ed0d02f19f50", "13": "8744084aeb65fbd3", "14": "b3e752fefc98c002", "15": "2aa78b1e6532083d", "16": "8ef6cf9118db3195", "17": "8baf7195f33c896e", "18": "89eb4ee52c62e004", "19": "ba69db1a2f1f3a58", "20": "683cad078c7e0ed9", "21": "ca6369f4becd6192", "22": "f4a28fa280943a77", "23": "f3004ec80ca9f448", "24": "8965526f063eed24", "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": "b6d52a30ac873a97", "39": "0e87c765d5713ad6", "40": "ce680f01cc87ac2b", "41": "24380e4087fb70d5", "42": "c58209407d4237e8", "43": "54905c779f6306ec", "44": "b6e6b302dfb2b4b7", "45": "c484c809d185e550", "46": "ab5b47947fe3bf98", "47": "2f8c667574e48967", "48": "08332675f638af21", "49": "4ed4ad08eaab7c1c", "50": "39e65f28c37e0e46", "51": "ba6724f5637cd5d9", "52": "fdf8b4d4bf2be849", "53": "66f8ffc9c3fbedf2", "54": "73723cec09ed2417", "55": "f9e8dac18e2e7bdf", "56": "86619fe91fc50452", "57": "8a3d1d863ddb4d2f", "58": "33678aa2c758e454", "59": "99c0fcbdc4f53a0e", "60": "cd42295e6b9fcac1", "61": "3712295d0a11267c", "62": "f787d08450bb4ac6", "63": "337e92dfeec532be", "64": "fa2cf116b4b9871b", "65": "f3a0edb87b006f25", "66": "a0aa708a9e56bca1", "67": "d58d1cd140a5fa40", "68": "3e5a785c54610ba0", "69": "0d74f7f6d933b4d1", "70": "e049e07b5afc08cf", "71": "c5240b928a15b0fa", "72": "2b92b704e5454347", "73": "e404eb8a5c1d93eb", "74": "ec0076e638dfa420", "75": "c11d4d615ce6b927", "76": "36f3b631daec8343", "77": "97ec841e4f367258", "78": "c9051bc3ad4170f1", "79": "0f585e9a81c4fb6c", "80": "f091cbed551517ed", "81": "30a50b463d66a82d", "82": "80551df1b95b31a0", "83": "9a369c70bcf0858b", "84": "9eb0c7e75d99ef54", "85": "ad0253a8a1e41307", "86": "98ecf4f0948d7a51", "87": "eeef008da0eeea12", "88": "f6b2e16972be0df8", "89": "bb20719f64caa678", "90": "491a658d3322d1ec", "91": "d520dd258c357f5d", "92": "48aeb4d25174096d", "94": "7f935939209226f1", "99": "719211c5f13f5583"}
{"0": "a3eec3e115dd18ab", "1": "c27501115524f813", "2": "aebb6d2f52ce7e1c", "3": "4c33cf87d94fa437", "4": "6664a6bcf0c8fc35", "5": "755dc69bb9e66ed9", "6": "5f666b37102bd0e3", "7": "b5b13d699ed2bb2c", "8": "7401aeb7ceaeda0d", "9": "12777a923784a450", "10": "0a5a0ba97778625e", "11": "f039d542469fefc7", "12": "22ac35c4ca449ea9", "13": "c5240b928a15b0fa", "14": "6e988a39e737f2e4", "15": "19c9db4d67450852", "16": "9fe5c851ea02c082", "17": "784e36d3016ebecb", "18": "170db1e7ebfb683d", "19": "c21f2f279d69d652", "20": "85e49032e2cac2d3", "21": "ba54fd97c91a51d3", "22": "7a54822cd1958230", "23": "f3004ec80ca9f448", "24": "3ced84211744963d", "25": "f01c92b243645b4e", "26": "54c3380f579b6729", "27": "d8d767c824110809", "28": "b165fb5e39a272ef", "29": "3147966ac3ee3b0f", "30": "7552b18e805318e1", "31": "099ee7edfccfb30f", "32": "0778a13a682f0cbb", "33": "6be20de557b963e9", "34": "da7b9f31790187c3", "35": "17bba31989deabe4", "36": "267c43d4a566a343", "37": "cff206ec60c41144", "38": "c9cd0ce5f2cf7cba", "39": "0e87c765d5713ad6", "40": "ce680f01cc87ac2b", "41": "24380e4087fb70d5", "42": "c58209407d4237e8", "43": "54905c779f6306ec", "44": "edf6a7f7e4a244de", "45": "ad8dc3b0df5922f3", "46": "ab5b47947fe3bf98", "47": "2f8c667574e48967", "48": "14650a356e308605", "49": "4ed4ad08eaab7c1c", "50": "39e65f28c37e0e46", "51": "2fb44d8350e43e52", "52": "fdf8b4d4bf2be849", "53": "66f8ffc9c3fbedf2", "54": "73723cec09ed2417", "55": "f9e8dac18e2e7bdf", "56": "86619fe91fc50452", "57": "8a3d1d863ddb4d2f", "58": "015eb51e77115067", "59": "99c0fcbdc4f53a0e", "60": "cd42295e6b9fcac1", "61": "3712295d0a11267c", "62": "f787d08450bb4ac6", "63": "dd6c938c47e2c6c9", "64": "fa2cf116b4b9871b", "65": "f3a0edb87b006f25", "66": "a0aa708a9e56bca1", "67": "d58d1cd140a5fa40", "68": "cadc0543d2eb4318", "69": "0d74f7f6d933b4d1", "70": "c490dc676aca8db4", "71": "ea7c38b08bfb0f43", "72": "12858d9498b8f2dc", "73": "e404eb8a5c1d93eb", "74": "ef6090e1505912ff", "75": "c11d4d615ce6b927", "76": "8c9859706b3f23f9", "77": "97ec841e4f367258", "78": "c9051bc3ad4170f1", "79": "366cb4d699d4af5d", "80": "f091cbed551517ed", "81": "5bbe65ee1b43d1d4", "82": "cd194738a7a82be2", "83": "cf129a9b660744ef", "84": "2735bfc7a6304c2e", "85": "879153c4e9273e22", "86": "d50761d9712fa60c", "87": "33d576b3c0601a2a", "88": "f6b2e16972be0df8", "89": "c0b1e53d3ba8b2bf", "90": "f3e64dc4e14483ba", "91": "d520dd258c357f5d", "92": "40c7bb70cfb7a834", "93": "7bd0d5828fb0701d", "94": "de541478274e13ab", "95": "511e2b5f5a529368", "96": "48aeb4d25174096d", "97": "7134dc1a92d0f766", "98": "42502f5fa028f50d", "99": "719211c5f13f5583", "100": "06d1498c4a5206dd", "101": "3fc1e93cf13ab7a9"}
+34 -30
View File
@@ -1,9 +1,9 @@
{
"0": "Category",
"0": "Settings",
"1": "budgeting/router.py",
"2": "db/models.py",
"3": "AppError",
"4": "imports/router.py",
"2": "Base",
"3": "main.py",
"4": "imports/service.py",
"5": "alfa_xlsx.py",
"6": "system-account-picker.test.tsx",
"7": "types.ts",
@@ -11,25 +11,25 @@
"9": "ledger/router.py",
"10": "@testing-library/jest-dom",
"11": "compilerOptions",
"12": "ReviewQueuePage.tsx",
"13": "@testing-library/user-event",
"12": "BudgetPage.tsx",
"13": "mcc.py",
"14": "devDependencies",
"15": "ledger/service.py",
"16": "dependencies",
"17": "ImportDetailPage.tsx",
"17": "useOperations.ts",
"18": "OperationsPage.tsx",
"19": "Base",
"19": "Session",
"20": "apiErrors.ts",
"21": "adapters/base.py",
"22": "BudgetPage.tsx",
"21": "UnsupportedStatementLayout",
"22": "ImportDetailPage.tsx",
"23": "client.ts",
"24": "fields.tsx",
"24": "set_space_context",
"25": "Docker Compose Development Stack",
"26": "Financial Space",
"27": "scripts",
"28": "@types/react",
"28": "ImportsPage.tsx",
"29": "General Pool",
"30": "TestBaseContract",
"30": "test_adapter_contracts.py",
"31": "Per-File Envelope Encryption",
"32": "Operation",
"33": "Versioned Statement Imports",
@@ -37,20 +37,20 @@
"35": "Closed Beta Launch Gates",
"36": "package.json",
"37": "Financial Tracker MVP",
"38": "test_adapter_contracts.py",
"38": "TestRocketbankDebitContract",
"39": "generate-api.mjs",
"40": "schema.d.ts",
"41": "test_api.py",
"42": "i18n-parity.test.ts",
"43": "Expected Income",
"44": "helpers.tsx",
"45": "ledger/models.py",
"44": "api",
"45": "fields.tsx",
"46": "category-manager.test.tsx",
"47": "graphify",
"48": "@types/react-dom",
"48": "BudgetBlockers.tsx",
"49": "typescript-eslint",
"50": "Ближайший край: накопления, категории и блокировки бюджета",
"51": "vitest",
"51": "UTCDateTime",
"52": "Favicon SVG (ledger lines icon)",
"53": "init-app-role.sh",
"54": "app/__init__.py",
@@ -62,36 +62,40 @@
"60": "vite.config.ts",
"61": "finance-backend",
"62": "LocalDirectoryBlobStore",
"63": "OzonDebitPdfAdapter",
"63": "TestOzonDebitContract",
"64": "Gitea CI and real statement imports",
"65": "20260918_0002_hold_lifecycle.py",
"66": "eslint-plugin-react-hooks",
"67": "import-hold-resolution.test.tsx",
"68": "budgeting/service.py",
"69": "TestAlfaDebitContract",
"70": "vite",
"71": "mcc.py",
"72": "TestAlfaSavingsRule",
"70": "celery_app.py",
"71": "20260922_0003_savings_system.py",
"72": "jsdom",
"73": "savings-convert-revert.test.tsx",
"74": "get_settings",
"75": "ColumnChart.tsx",
"76": "dependencies.py",
"76": "spaces/router.py",
"77": "AGENTS.md",
"78": "graphify.js",
"79": "test_savings.py",
"79": "AppError",
"80": "test_category_definitions.py",
"81": "budget-plan.test.tsx",
"82": "imports/service.py",
"82": "spaces/service.py",
"83": "exports/service.py",
"84": "test_imports.py",
"84": "@testing-library/react",
"85": "identity/service.py",
"86": "Session",
"86": "test_import_holds.py",
"87": "get_or_create_period",
"88": "20260923_0005_optional_hold_reason.py",
"89": "test_import_savings.py",
"89": "typescript",
"90": "conftest.py",
"91": "test_review_queue_batch.py",
"92": "review-queue-save.test.tsx",
"94": "account-adjustment.test.tsx",
"92": "@vitejs/plugin-react",
"93": "useImports.ts",
"95": "dependencies.py",
"96": "_login",
"97": "перевод_между_своими.md",
"98": "0002-fixes.md",
"99": "0001-fixes.md"
}
+173 -163
View File
@@ -1,25 +1,25 @@
# Graph Report - finance (2026-09-23)
## Corpus Check
- 187 files · ~90,160 words
- 190 files · ~91,840 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 1667 nodes · 5182 edges · 95 communities (72 shown, 16 thin omitted)
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 91 edges (avg confidence: 0.93)
- 1702 nodes · 5362 edges · 99 communities (74 shown, 16 thin omitted)
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 96 edges (avg confidence: 0.93)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `63d06501`
- Built from commit: `970fe9da`
- 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)
- Category
- Settings
- budgeting/router.py
- db/models.py
- AppError
- imports/router.py
- Base
- main.py
- imports/service.py
- alfa_xlsx.py
- system-account-picker.test.tsx
- types.ts
@@ -27,25 +27,25 @@
- ledger/router.py
- @testing-library/jest-dom
- compilerOptions
- ReviewQueuePage.tsx
- @testing-library/user-event
- BudgetPage.tsx
- mcc.py
- devDependencies
- ledger/service.py
- dependencies
- ImportDetailPage.tsx
- useOperations.ts
- OperationsPage.tsx
- Base
- Session
- apiErrors.ts
- adapters/base.py
- BudgetPage.tsx
- UnsupportedStatementLayout
- ImportDetailPage.tsx
- client.ts
- fields.tsx
- set_space_context
- Docker Compose Development Stack
- Financial Space
- scripts
- @types/react
- ImportsPage.tsx
- General Pool
- TestBaseContract
- test_adapter_contracts.py
- Per-File Envelope Encryption
- Operation
- Versioned Statement Imports
@@ -53,68 +53,70 @@
- Closed Beta Launch Gates
- package.json
- Financial Tracker MVP
- test_adapter_contracts.py
- TestRocketbankDebitContract
- generate-api.mjs
- schema.d.ts
- test_api.py
- i18n-parity.test.ts
- Expected Income
- helpers.tsx
- ledger/models.py
- api
- fields.tsx
- category-manager.test.tsx
- graphify
- @types/react-dom
- BudgetBlockers.tsx
- typescript-eslint
- Ближайший край: накопления, категории и блокировки бюджета
- vitest
- UTCDateTime
- Favicon SVG (ledger lines icon)
- init-app-role.sh
- Payee
- test_private_fixtures.py
- finance-backend
- LocalDirectoryBlobStore
- OzonDebitPdfAdapter
- TestOzonDebitContract
- Gitea CI and real statement imports
- 20260918_0002_hold_lifecycle.py
- eslint-plugin-react-hooks
- import-hold-resolution.test.tsx
- budgeting/service.py
- TestAlfaDebitContract
- vite
- mcc.py
- TestAlfaSavingsRule
- celery_app.py
- 20260922_0003_savings_system.py
- jsdom
- savings-convert-revert.test.tsx
- get_settings
- ColumnChart.tsx
- dependencies.py
- spaces/router.py
- AGENTS.md
- test_savings.py
- AppError
- test_category_definitions.py
- budget-plan.test.tsx
- imports/service.py
- spaces/service.py
- exports/service.py
- test_imports.py
- @testing-library/react
- identity/service.py
- Session
- test_import_holds.py
- get_or_create_period
- 20260923_0005_optional_hold_reason.py
- test_import_savings.py
- typescript
- conftest.py
- test_review_queue_batch.py
- review-queue-save.test.tsx
- account-adjustment.test.tsx
- @vitejs/plugin-react
- useImports.ts
- dependencies.py
- _login
## God Nodes (most connected - your core abstractions)
1. `Session` - 211 edges
2. `AppError` - 72 edges
1. `Session` - 221 edges
2. `AppError` - 76 edges
3. `Base` - 48 edges
4. `conflict()` - 43 edges
5. `get_settings()` - 40 edges
6. `PkMixin` - 40 edges
7. `record()` - 39 edges
8. `TimestampMixin` - 38 edges
9. `StatementDocument` - 36 edges
10. `create_expense()` - 36 edges
4. `record()` - 44 edges
5. `conflict()` - 43 edges
6. `get_settings()` - 40 edges
7. `PkMixin` - 40 edges
8. `get_or_create_period()` - 39 edges
9. `TimestampMixin` - 38 edges
10. `bad_request()` - 36 edges
## Surprising Connections (you probably didn't know these)
- `Typed Same-Origin API Client` --conceptually_related_to--> `Same-Origin Reverse Proxy` [INFERRED]
@@ -136,43 +138,39 @@
- **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 (95 total, 16 thin omitted)
### Community 0 - "Category"
Cohesion: 0.20
Nodes (15): balances(), cash_flow(), category_spend(), plan_fact(), date, UUID, category_for_mcc(), Category owning the MCC code, else the system «Остальное» fallback. (+7 more)
## Communities (99 total, 16 thin omitted)
### Community 1 - "budgeting/router.py"
Cohesion: 0.11
Nodes (52): balances(), cash_flow(), category_spend(), plan_fact(), Context, date, Db, get (+44 more)
Nodes (59): balances(), cash_flow(), category_spend(), plan_fact(), Context, date, Db, get (+51 more)
### Community 2 - "db/models.py"
### Community 2 - "Base"
Cohesion: 0.15
Nodes (34): downgrade(), upgrade(), AuditEvent, Append-only domain/security event. Never stores raw financial payloads., ExpectedIncome, ExpectedIncomeMatch, FundingMovement, PeriodClosure (+26 more)
### Community 3 - "main.py"
Cohesion: 0.17
Nodes (23): AuditEvent, Append-only domain/security event. Never stores raw financial payloads., ExpectedIncome, ExpectedIncomeMatch, FundingMovement, PeriodClosure, PlanAssignment, Links one actual income operation to one expected income (1:1 per operation). (+15 more)
Nodes (17): get_db(), get_engine(), get_session_factory(), sessionmaker, app_error_handler(), health(), healthz(), get (+9 more)
### Community 3 - "AppError"
Cohesion: 0.19
Nodes (11): AppError, Any, Domain error rendered as a machine-readable JSON error payload., unprocessable(), decrypt_file(), encrypt_file(), EncryptedFile, Per-file random DEK; DEK wrapped with the KEK from the secret manager. (+3 more)
### Community 4 - "imports/router.py"
Cohesion: 0.05
Nodes (79): _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… (+71 more)
### Community 4 - "imports/service.py"
Cohesion: 0.06
Nodes (105): datetime, utcnow(), RequestContext, magic_kind(), ParseRun, Immutable extracted bank fact with document origin coordinates., SourceRecord, StatementDocument (+97 more)
### Community 5 - "alfa_xlsx.py"
Cohesion: 0.12
Nodes (32): AlfaCreditXlsxAdapter, AlfaDebitXlsxAdapter, _AlfaHeaderMeta, AlfaXlsxAdapterBase, _cell_str(), _debit_find_header(), _debit_header_metadata(), _debit_header_value() (+24 more)
Cohesion: 0.15
Nodes (23): _AlfaHeaderMeta, AlfaXlsxAdapterBase, _cell_str(), _debit_find_header(), _debit_header_metadata(), _debit_header_value(), _debit_norm(), _debit_operation_rows() (+15 more)
### Community 6 - "system-account-picker.test.tsx"
Cohesion: 0.11
Nodes (15): AdjustBalanceResult, BudgetCategory, Operation, Page, budgetCategoriesMock, listAccountsMock, listOperationsMock, categoriesMock (+7 more)
Nodes (13): BudgetCategory, Page, budgetCategoriesMock, listAccountsMock, listOperationsMock, categoriesMock, importsListMock, importSummary (+5 more)
### Community 7 - "types.ts"
Cohesion: 0.08
Nodes (35): AccountStatus, AccountType, AdjustBalancePayload, BalanceItem, BalancesReport, BudgetStatus, BudgetWarning, CashFlowMonth (+27 more)
Cohesion: 0.07
Nodes (38): AcceptInvitePayload, AccountStatus, AccountType, AdjustBalancePayload, BalanceItem, BalancesReport, BudgetStatus, BudgetWarning (+30 more)
### Community 8 - "router.tsx"
Cohesion: 0.07
Nodes (43): AcceptInvitePayload, LoginPayload, PasswordResetPayload, AcceptInvitePage, AccountsPage, AnalyticsPage, BudgetPage, ImportDetailPage (+35 more)
Cohesion: 0.08
Nodes (45): AcceptInvitePage, AccountsPage, AnalyticsPage, BudgetPage, LoginPage, OperationsPage, OverviewPage, RequireAuth() (+37 more)
### Community 9 - "ledger/router.py"
Cohesion: 0.15
@@ -182,53 +180,57 @@ Nodes (38): account_json(), AccountBody, accounts(), adjust_balance(), AdjustBal
Cohesion: 0.07
Nodes (28): compilerOptions, baseUrl, esModuleInterop, forceConsistentCasingInFileNames, isolatedModules, jsx, lib, module (+20 more)
### Community 12 - "ReviewQueuePage.tsx"
Cohesion: 0.09
Nodes (43): NotFoundPage, PlanFactBars(), PlanFactBarsProps, PlanFactRow, EmptyState(), EmptyStateProps, MonthSwitcher(), MonthSwitcherProps (+35 more)
### Community 12 - "BudgetPage.tsx"
Cohesion: 0.11
Nodes (40): NotFoundPage, PlanFactBars(), PlanFactBarsProps, PlanFactRow, MonthSwitcher(), MonthSwitcherProps, PageHeader(), PageHeaderProps (+32 more)
### Community 13 - "mcc.py"
Cohesion: 0.14
Nodes (23): _create_tenant_policy(), _default_keys(), downgrade(), _has_table(), _space_ids(), upgrade(), _default_categories(), default_category_system_keys() (+15 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)
Cohesion: 0.09
Nodes (23): eslint, @eslint/js, eslint-plugin-jsx-a11y, devDependencies, eslint, @eslint/js, eslint-plugin-jsx-a11y, globals (+15 more)
### Community 15 - "ledger/service.py"
Cohesion: 0.13
Nodes (48): ensure_others_category(), The system «Остальное» fallback category, created on first use., Operation, Posting, Signed balance change of one account within one operation. Sign convention…, User financial fact. amount_kopecks is always positive; direction lives in…, account_balance(), _apply_suggestion() (+40 more)
Cohesion: 0.15
Nodes (45): Operation, Posting, Signed balance change of one account within one operation. Sign convention…, User financial fact. amount_kopecks is always positive; direction lives in…, account_balance(), _apply_suggestion(), archive_account(), categorize_batch() (+37 more)
### Community 16 - "dependencies"
Cohesion: 0.09
Nodes (23): @fontsource/funnel-sans, @fontsource/geist-mono, dependencies, @fontsource/funnel-sans, @fontsource/geist-mono, @hookform/resolvers, i18next, react (+15 more)
### Community 17 - "ImportDetailPage.tsx"
Cohesion: 0.08
Nodes (45): api, invalidationRoots, queryKeys, SESSION_ROOT, sessionInvalidationRoot, SPACE_ROOT, spaceInvalidationRoot, HoldResolutionAction (+37 more)
Nodes (25): @fontsource/funnel-sans, @fontsource/geist-mono, @fontsource/onest, dependencies, @fontsource/funnel-sans, @fontsource/geist-mono, @fontsource/onest, @hookform/resolvers (+17 more)
### Community 17 - "useOperations.ts"
Cohesion: 0.15
Nodes (17): invalidationRoots, queryKeys, SESSION_ROOT, sessionInvalidationRoot, SPACE_ROOT, spaceInvalidationRoot, CategorizeBatchPayload, CreateOperationPayload (+9 more)
### Community 18 - "OperationsPage.tsx"
Cohesion: 0.11
Nodes (46): CategorizeBatchPayload, CreateOperationPayload, isSystemAccount(), Locale, useAccounts(), useActiveAccounts(), useUserAccounts(), useCategorizeBatch() (+38 more)
Cohesion: 0.12
Nodes (40): Account, isSystemAccount(), Operation, useAccounts(), useActiveAccounts(), useAdjustBalance(), useArchiveAccount(), useCreateAccount() (+32 more)
### Community 19 - "Base"
Cohesion: 0.15
Nodes (22): downgrade(), upgrade(), BankCategoryMapping, CategorizationRule, CategoryMcc, IncomeSource, MccCode, Payee (+14 more)
### Community 19 - "Session"
Cohesion: 0.12
Nodes (33): balances(), cash_flow(), category_spend(), plan_fact(), date, UUID, _balance_until(), ensure_credit_reserve_category() (+25 more)
### Community 20 - "apiErrors.ts"
Cohesion: 0.09
Nodes (24): isApiError(), BudgetBlocker, App(), queryClient, AppRoutes(), blockerCta(), blockerText(), BudgetBlockers() (+16 more)
Cohesion: 0.11
Nodes (19): isApiError(), Locale, App(), queryClient, AppRoutes(), LangSwitcher(), ToastApi, ToastContext (+11 more)
### Community 21 - "adapters/base.py"
Cohesion: 0.12
Nodes (20): account_fingerprint(), _mask_digits(), money_to_kopecks(), normalize_description(), date, Parse statement money strings like '-1 234,56 RUR', '+ 500.00 ₽', '12 345'.…, record_fingerprint(), StatementDraft (+12 more)
### Community 22 - "BudgetPage.tsx"
### Community 21 - "UnsupportedStatementLayout"
Cohesion: 0.14
Nodes (27): CreatableCategoryKind, CategoryManagerModal(), CREATE_KINDS, CreateCategoryForm(), MccCodesModal(), parseMccCodes(), RenameModal(), Badge() (+19 more)
Nodes (22): account_fingerprint(), amount_to_kopecks(), _mask_digits(), money_to_kopecks(), Parse statement money strings like '-1 234,56 RUR', '+ 500.00 ₽', '12 345'.…, Stable diagnostic for unknown bank layouts; never guesses., SourceRow, StatementDraft (+14 more)
### Community 22 - "ImportDetailPage.tsx"
Cohesion: 0.09
Nodes (40): CreatableCategoryKind, CreateCategoryPayload, PatchCategoryDefinitionPayload, ImportDetailPage, CategoryManagerModal(), CREATE_KINDS, CreateCategoryForm(), MccCodesModal() (+32 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.21
Nodes (11): FieldChrome, InputProps, MoneyField(), MoneyFieldProps, SelectField(), SelectFieldProps, SelectProps, TextareaField() (+3 more)
### Community 24 - "set_space_context"
Cohesion: 0.15
Nodes (20): Any, Transaction-local RLS context. No-op on SQLite (tests); fail-closed on…, Transactional scope for workers/endpoints that own the transaction lifecycle., session_scope(), set_space_context(), download(), export_status(), Context (+12 more)
### Community 25 - "Docker Compose Development Stack"
Cohesion: 0.29
@@ -242,13 +244,17 @@ Nodes (12): Credit Reserve, Financial Account, Financial Space, Membership, User
Cohesion: 0.22
Nodes (9): scripts, api:generate, build, dev, lint, preview, test, test:watch (+1 more)
### Community 28 - "ImportsPage.tsx"
Cohesion: 0.20
Nodes (11): ImportsPage, EmptyState(), EmptyStateProps, ErrorState(), QueryGate(), QueryGateProps, Spinner(), useImports() (+3 more)
### Community 29 - "General Pool"
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.21
Nodes (3): make_draft(), make_source_row(), TestBaseContract
### Community 30 - "test_adapter_contracts.py"
Cohesion: 0.11
Nodes (11): AlfaCreditXlsxAdapter, Protocol, StatementAdapter, detect_adapter(), OzonCreditPdfAdapter, make_draft(), make_source_row(), Sanitized synthetic contract tests for the evidenced statement adapters. These… (+3 more)
### Community 31 - "Per-File Envelope Encryption"
Cohesion: 0.29
@@ -278,9 +284,9 @@ Nodes (4): name, private, type, version
Cohesion: 0.18
Nodes (11): Audited Break-Glass Access, Cross-Space Referential Integrity, Defense-in-Depth Authorization, Financial Space Ownership, Transaction-Local RLS Context, Budget Invariants, Critical User Path, Financial Tracker MVP (+3 more)
### Community 38 - "test_adapter_contracts.py"
Cohesion: 0.13
Nodes (8): minimal_pdf(), _QuotaSettings, Sanitized synthetic contract tests for the evidenced statement adapters. These…, Build a small valid single-font PDF with empty or ASCII-only pages., same_page_rocket_lines(), synthetic_rocket_lines(), TestRocketbankDebitContract, MonkeyPatch
### Community 38 - "TestRocketbankDebitContract"
Cohesion: 0.18
Nodes (5): Rocketbank debit statement contract, evidenced against the real document.…, RocketbankDebitPdfAdapter, same_page_rocket_lines(), synthetic_rocket_lines(), TestRocketbankDebitContract
### Community 39 - "generate-api.mjs"
Cohesion: 0.40
@@ -294,13 +300,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.18
Nodes (11): User, DESKTOP_NAV, meMock, MOBILE_NAV, user, createTestQueryClient(), renderWithProviders(), loginMock (+3 more)
### Community 44 - "api"
Cohesion: 0.08
Nodes (23): api, AdjustBalanceResult, User, adjustBalanceMock, listMock, createMock, listMock, DESKTOP_NAV (+15 more)
### Community 45 - "ledger/models.py"
Cohesion: 0.26
Nodes (11): HoldResolution, Append-only, tenant-scoped settlement of one HOLD source record. The extracted…, Account, Financial account. Balance is always derived: opening + confirmed postings., FinancialSpace, Membership, test_postgres_rls_requires_matching_transaction_context(), test_s3_blob_round_trip() (+3 more)
### Community 45 - "fields.tsx"
Cohesion: 0.21
Nodes (11): FieldChrome, InputProps, MoneyField(), MoneyFieldProps, SelectField(), SelectFieldProps, SelectProps, TextareaField() (+3 more)
### Community 46 - "category-manager.test.tsx"
Cohesion: 0.14
@@ -310,21 +316,29 @@ Nodes (12): CategoryDefinition, categoriesMock, createCategoryMock, definitions,
Cohesion: 0.17
Nodes (11): command, enabled, timeout, type, mcp, graphify, plugin, $schema (+3 more)
### Community 48 - "BudgetBlockers.tsx"
Cohesion: 0.43
Nodes (6): BudgetBlocker, blockerCta(), blockerText(), BudgetBlockers(), BudgetBlockersProps, Cta
### Community 50 - "Ближайший край: накопления, категории и блокировки бюджета"
Cohesion: 0.10
Nodes (20): 1. Доменная основа накоплений, 2. Альфа debit: «Копилка для сдачи», 3. Управление категориями, 4. Понятные и устранимые блокировки, Backend-контракт, Frontend, Ближайший край: накопления, категории и блокировки бюджета, Бюджетная семантика (+12 more)
### Community 51 - "UTCDateTime"
Cohesion: 0.40
Nodes (4): Any, Datetimes stored as naive UTC in the DB and exposed tz-aware (UTC) in Python., UTCDateTime, TypeDecorator
### Community 58 - "test_private_fixtures.py"
Cohesion: 0.10
Nodes (24): Protocol, StatementAdapter, detect_adapter(), Any, Reconcile parsed rows against declared bank controls with stable diagnostics.…, _reconciliation_summary(), _check(), _fail() (+16 more)
Cohesion: 0.13
Nodes (21): Any, Reconcile parsed rows against declared bank controls with stable diagnostics.…, _reconciliation_summary(), _check(), _fail(), _load_entries(), parsed(), fixture (+13 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.18
Nodes (6): _ozon_normalize(), OzonDebitPdfAdapter, Ozon debit statement contract, evidenced against the real document. A 'Справка…, ozon_extracted(), TestOzonDebitContract, Pattern
### Community 63 - "TestOzonDebitContract"
Cohesion: 0.15
Nodes (6): minimal_pdf(), ozon_extracted(), _QuotaSettings, Build a small valid single-font PDF with empty or ASCII-only pages., TestOzonDebitContract, MonkeyPatch
### Community 64 - "Gitea CI and real statement imports"
Cohesion: 0.29
@@ -335,40 +349,40 @@ Cohesion: 0.80
Nodes (4): downgrade(), _has_check_constraint(), _has_column(), upgrade()
### Community 67 - "import-hold-resolution.test.tsx"
Cohesion: 0.15
Nodes (11): ImportDetail, ImportRow, ImportSummary, confirmMock, detailFixture(), getImportMock, postedRow(), getImportMock (+3 more)
Cohesion: 0.17
Nodes (9): ImportRow, confirmMock, detailFixture(), getImportMock, postedRow(), getImportMock, resolvedHold, resolveHoldMock (+1 more)
### Community 68 - "budgeting/service.py"
Cohesion: 0.16
Nodes (39): BudgetPeriod, Calendar month budget period in the space timezone (month granularity)., add_expected_income(), archive_category(), _balance_until(), _close_blockers(), close_period(), _closure_snapshot() (+31 more)
Cohesion: 0.15
Nodes (45): Any, record(), BudgetPeriod, Calendar month budget period in the space timezone (month granularity)., add_expected_income(), archive_category(), _close_blockers(), _closure_snapshot() (+37 more)
### Community 69 - "TestAlfaDebitContract"
Cohesion: 0.18
Nodes (3): alfa_workbook_bytes(), synthetic_alfa_rows(), TestAlfaDebitContract
Cohesion: 0.11
Nodes (7): AlfaDebitXlsxAdapter, Alfa debit statement contract, evidenced against the real export layout.…, alfa_workbook_bytes(), «Копилка для сдачи» contract: exact normalized phrase, statement-side source…, synthetic_alfa_rows(), TestAlfaDebitContract, TestAlfaSavingsRule
### Community 71 - "mcc.py"
Cohesion: 0.14
Nodes (23): _create_tenant_policy(), _default_keys(), downgrade(), _has_table(), _space_ids(), upgrade(), _default_categories(), default_category_system_keys() (+15 more)
### Community 71 - "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 73 - "savings-convert-revert.test.tsx"
Cohesion: 0.20
Nodes (6): budgetCategoriesMock, listAccountsMock, listOperationsMock, operations, revertMock, savingsTransferMock
### Community 74 - "get_settings"
Cohesion: 0.12
Nodes (30): get_settings(), Settings, send_token_email(), accept_invite(), AcceptInviteBody, confirm_password_reset(), create_invite(), InviteBody (+22 more)
Cohesion: 0.16
Nodes (26): get_settings(), send_token_email(), accept_invite(), AcceptInviteBody, confirm_password_reset(), create_invite(), InviteBody, login() (+18 more)
### Community 75 - "ColumnChart.tsx"
Cohesion: 0.24
Nodes (7): CashFlowColumn, ColumnChart(), ColumnChartProps, BarRow, HorizontalBars(), HorizontalBarsProps, rows
### Community 76 - "dependencies.py"
Cohesion: 0.09
Nodes (39): current_context(), current_session(), alias, Context, CurrentSession, Db, Header, User (+31 more)
### Community 76 - "spaces/router.py"
Cohesion: 0.19
Nodes (20): forbidden(), cancel_deletion(), current(), deletion_status(), DeletionBody, invite_member(), MemberInviteBody, alias (+12 more)
### Community 79 - "test_savings.py"
Cohesion: 0.14
Nodes (33): adjust_balance(), convert_expense_to_savings(), ensure_savings_resources(), Account, date, Operation, User, UUID (+25 more)
### Community 79 - "AppError"
Cohesion: 0.12
Nodes (43): AppError, conflict(), Any, Domain error rendered as a machine-readable JSON error payload., unprocessable(), cancel_deletion(), adjust_balance(), convert_expense_to_savings() (+35 more)
### Community 80 - "test_category_definitions.py"
Cohesion: 0.50
@@ -378,38 +392,30 @@ Nodes (8): bootstrap(), TestClient, HTTP contract for category definitions, syst
Cohesion: 0.25
Nodes (7): BudgetSummary, categories, categoriesMock, month, summary, summaryMock, updatePlanMock
### Community 82 - "imports/service.py"
Cohesion: 0.18
Nodes (29): Any, record(), utcnow(), conflict(), not_found(), magic_kind(), _apply_hold_matches(), _category_id_for_source() (+21 more)
### Community 82 - "spaces/service.py"
Cohesion: 0.20
Nodes (16): User, HoldResolution, Append-only, tenant-scoped settlement of one HOLD source record. The extracted…, Account, Financial account. Balance is always derived: opening + confirmed postings., FinancialSpace, Membership, get_membership() (+8 more)
### Community 83 - "exports/service.py"
Cohesion: 0.13
Nodes (19): DataExport, build_export(), consume_export(), _csv_bytes(), get_export(), _json_rows(), purge_due_spaces(), datetime (+11 more)
### Community 84 - "test_imports.py"
Cohesion: 0.17
Nodes (29): DedupCandidate, ParseRun, Immutable extracted bank fact with document origin coordinates., SourceRecord, SourceRecordLink, StatementDocument, _build_candidates(), _match_hold_resolutions() (+21 more)
Cohesion: 0.11
Nodes (22): DataExport, build_export(), consume_export(), _csv_bytes(), get_export(), _json_rows(), purge_due_spaces(), datetime (+14 more)
### Community 85 - "identity/service.py"
Cohesion: 0.26
Nodes (20): bad_request(), unauthorized(), accept_invitation(), authenticate(), create_invitation(), create_password_reset(), create_session(), get_active_session() (+12 more)
Cohesion: 0.30
Nodes (16): accept_invitation(), authenticate(), create_invitation(), create_password_reset(), create_session(), normalize_email(), User, UUID (+8 more)
### Community 86 - "Session"
Cohesion: 0.16
Nodes (41): Session, add_record(), _draft(), hold_document(), make_document(), date, Path, TestClient (+33 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 87 - "get_or_create_period"
Cohesion: 0.34
Nodes (14): get_or_create_period(), validate_year_month(), Budget semantics for savings transfers, adjustments and archived history., savings_row(), seed(), test_adjustment_changes_only_envelope_available_not_pool(), test_adjustment_on_regular_account_keeps_pool_and_totals(), test_archived_category_history_stays_in_totals() (+6 more)
Cohesion: 0.21
Nodes (25): close_period(), compute_period_state(), create_category(), get_or_create_period(), Deterministic budget state. Resource model: - income lands in the general pool;…, Budget semantics for savings transfers, adjustments and archived history., savings_row(), seed() (+17 more)
### Community 88 - "20260923_0005_optional_hold_reason.py"
Cohesion: 0.83
Nodes (3): downgrade(), _reason_constraint_name(), upgrade()
### Community 89 - "test_import_savings.py"
Cohesion: 0.32
Nodes (17): get_savings_account(), add_record(), make_document(), date, User, Import contract for the «Копилка для сдачи» savings_transfer decision., seed(), test_confirm_creates_savings_transfer_with_system_category() (+9 more)
### Community 90 - "conftest.py"
Cohesion: 0.27
Nodes (6): client(), db(), db_factory(), fixture, sessionmaker, TestClient
@@ -418,33 +424,37 @@ Nodes (6): client(), db(), db_factory(), fixture, sessionmaker, TestClient
Cohesion: 0.60
Nodes (10): bootstrap(), category_ids(), create_account(), create_expense(), operations_by_id(), TestClient, test_batch_assigns_picked_categories_and_moves_leftovers_to_others(), test_batch_is_atomic_when_one_category_is_unknown() (+2 more)
### Community 92 - "review-queue-save.test.tsx"
Cohesion: 0.25
Nodes (6): categories, categoriesMock, categorizeBatchMock, categorizeMock, importsListMock, operationsListMock
### Community 93 - "useImports.ts"
Cohesion: 0.26
Nodes (10): ConfirmImportPayload, ImportStatus, SaveRowDecisionPayload, useImport(), ACTIVE_STATUSES, IMPORT_POLL_MS, importListPollInterval(), importPollInterval() (+2 more)
### Community 94 - "account-adjustment.test.tsx"
Cohesion: 0.20
Nodes (5): Account, adjustBalanceMock, listMock, createMock, listMock
### Community 95 - "dependencies.py"
Cohesion: 0.17
Nodes (15): current_context(), current_session(), alias, Context, CurrentSession, Db, Header, User (+7 more)
### Community 96 - "_login"
Cohesion: 1.00
Nodes (3): _login(), TestClient, test_expected_income_month_api_crud_and_summary()
## Knowledge Gaps
- **267 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` (+262 more)
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 468 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
- **268 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` (+263 more)
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 475 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 `Category`, `budgeting/router.py`, `db/models.py`, `imports/router.py`, `alfa_xlsx.py`, `ledger/service.py`, `Base`, `ledger/models.py`, `budgeting/service.py`, `mcc.py`, `dependencies.py`, `test_savings.py`, `imports/service.py`, `exports/service.py`, `test_imports.py`, `identity/service.py`, `get_or_create_period`, `test_import_savings.py`, `conftest.py`?**
_High betweenness centrality (0.067) - this node is a cross-community bridge._
- **Why does `AppError` connect `AppError` to `imports/router.py`, `alfa_xlsx.py`, `test_adapter_contracts.py`, `budgeting/service.py`, `get_settings`, `dependencies.py`, `ledger/service.py`, `test_savings.py`, `imports/service.py`, `test_imports.py`, `identity/service.py`, `adapters/base.py`, `get_or_create_period`, `Session`, `test_import_savings.py`, `OzonDebitPdfAdapter`?**
_High betweenness centrality (0.049) - this node is a cross-community bridge._
- **Why does `TestRocketbankDebitContract` connect `test_adapter_contracts.py` to `AppError`, `adapters/base.py`?**
- **Why does `Session` connect `Session` to `Base`, `main.py`, `budgeting/service.py`, `imports/service.py`, `20260922_0003_savings_system.py`, `spaces/router.py`, `mcc.py`, `AppError`, `ledger/service.py`, `spaces/service.py`, `exports/service.py`, `UTCDateTime`, `identity/service.py`, `test_import_holds.py`, `get_or_create_period`, `set_space_context`, `conftest.py`, `dependencies.py`?**
_High betweenness centrality (0.071) - this node is a cross-community bridge._
- **Why does `AppError` connect `AppError` to `main.py`, `budgeting/service.py`, `alfa_xlsx.py`, `imports/service.py`, `TestRocketbankDebitContract`, `get_settings`, `spaces/router.py`, `ledger/service.py`, `exports/service.py`, `UnsupportedStatementLayout`, `test_import_holds.py`, `get_or_create_period`, `set_space_context`, `TestOzonDebitContract`, `test_adapter_contracts.py`, `dependencies.py`?**
_High betweenness centrality (0.047) - this node is a cross-community bridge._
- **Why does `get_settings()` connect `get_settings` to `Settings`, `main.py`, `imports/service.py`, `alfa_xlsx.py`, `celery_app.py`, `spaces/service.py`, `exports/service.py`, `identity/service.py`, `UnsupportedStatementLayout`, `test_import_holds.py`, `conftest.py`, `dependencies.py`?**
_High betweenness centrality (0.019) - 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 42 inferred relationships involving `AppError` (e.g. with `request_export()` and `login()`) actually correct?**
_`AppError` has 42 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?**
_267 weakly-connected nodes found - possible documentation gaps or missing edges._
_268 weakly-connected nodes found - possible documentation gaps or missing edges._
File diff suppressed because it is too large Load Diff
+72 -54
View File
@@ -30,9 +30,9 @@
"semantic_hash": ""
},
"backend/app/analytics/service.py": {
"mtime": 1790061159.3943744,
"seen": 1790096479.536424,
"ast_hash": "58ac24a62b4cf8a986527f79d456127d",
"mtime": 1790158886.5589485,
"seen": 1790164233.4191206,
"ast_hash": "3d87f3a80dc16a7781b5b802a0a66039",
"semantic_hash": ""
},
"backend/app/audit/__init__.py": {
@@ -66,15 +66,15 @@
"semantic_hash": "039081cab3d91704e8f8a7152a981304"
},
"backend/app/budgeting/router.py": {
"mtime": 1790140570.7235522,
"seen": 1790140596.4645138,
"ast_hash": "5c8ea9e79dbe5450c2891c40931f0f4d",
"mtime": 1790164219.7100794,
"seen": 1790164233.4191263,
"ast_hash": "e4c01ff67e84d606da20520c634aada1",
"semantic_hash": ""
},
"backend/app/budgeting/service.py": {
"mtime": 1790139967.779716,
"seen": 1790140596.4645145,
"ast_hash": "8b936c8c80b35123143fcc6bed627172",
"mtime": 1790164039.9056563,
"seen": 1790164233.4191272,
"ast_hash": "9856f61057a9ddc569b2d8ae5d78f688",
"semantic_hash": ""
},
"backend/app/categorization/__init__.py": {
@@ -222,9 +222,9 @@
"semantic_hash": ""
},
"backend/app/imports/adapters/ozon_pdf.py": {
"mtime": 1789733868.836172,
"seen": 1789735433.158129,
"ast_hash": "dfa6dc9ccf2c655e6c2eeaceea0cf06a",
"mtime": 1790163956.5875351,
"seen": 1790164233.4191487,
"ast_hash": "a0aa31d71ab7a45a479178cd630d8b05",
"semantic_hash": ""
},
"backend/app/imports/adapters/rocketbank_pdf.py": {
@@ -288,15 +288,15 @@
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"backend/app/ledger/models.py": {
"mtime": 1790060833.8770478,
"seen": 1790096479.5364609,
"ast_hash": "86891c404082d8d2c84214f31d8616ff",
"mtime": 1790158886.5589485,
"seen": 1790164233.4191582,
"ast_hash": "38e4db3531f1672e57e86ec36d7b1ed7",
"semantic_hash": ""
},
"backend/app/ledger/router.py": {
"mtime": 1790155278.0175776,
"seen": 1790155776.860304,
"ast_hash": "24b2565478f257f38561fabff646130a",
"mtime": 1790158886.5589485,
"seen": 1790164233.419159,
"ast_hash": "37c07d8487d317af02a3762099bce24b",
"semantic_hash": ""
},
"backend/app/ledger/service.py": {
@@ -372,15 +372,15 @@
"semantic_hash": "bfec0778131788a91a0bc5c9ec2ad8ba"
},
"backend/tests/test_budget.py": {
"mtime": 1790141279.1849978,
"seen": 1790141340.361456,
"ast_hash": "81578f618d0758c7f9e2f00c921f2594",
"mtime": 1790164269.4717507,
"seen": 1790164324.1326215,
"ast_hash": "6d01ee3dd3fdc66f65ba85ba6a3909fc",
"semantic_hash": ""
},
"backend/tests/test_imports.py": {
"mtime": 1790140440.029933,
"seen": 1790140596.4645612,
"ast_hash": "9e568b07fa4d77eb55c36ef748fb0f9b",
"mtime": 1790164055.474866,
"seen": 1790164233.4191756,
"ast_hash": "580d49a52a4cfc72ce23b21cc983f833",
"semantic_hash": ""
},
"backend/tests/test_ledger_properties.py": {
@@ -402,9 +402,9 @@
"semantic_hash": "ed54799a921d10394f826893acb46876"
},
"frontend/package.json": {
"mtime": 1790055350.366911,
"seen": 1790096479.5364823,
"ast_hash": "172e2880b6084c1fc423ea759b99280a",
"mtime": 1790164273.1148,
"seen": 1790164324.1326368,
"ast_hash": "6e94a9e65d8bbac0083b73acea30af23",
"semantic_hash": ""
},
"frontend/scripts/generate-api.mjs": {
@@ -672,9 +672,9 @@
"semantic_hash": "53e014a77ff3070820a83458af08a5fd"
},
"frontend/src/pages/OperationsPage.tsx": {
"mtime": 1790155897.8159056,
"seen": 1790156681.728144,
"ast_hash": "5fd3db4e96df49497a42d9040b02ff58",
"mtime": 1790157347.0632977,
"seen": 1790157380.8711965,
"ast_hash": "79e817d97de60b1c3aa88a69312089c4",
"semantic_hash": ""
},
"frontend/src/pages/ResetPasswordPage.tsx": {
@@ -888,9 +888,9 @@
"semantic_hash": ""
},
"backend/tests/test_adapter_contracts.py": {
"mtime": 1790061478.1675427,
"seen": 1790096479.5364716,
"ast_hash": "9d446a10c953a14c8d4962eff50f5b97",
"mtime": 1790164118.1117096,
"seen": 1790164233.419169,
"ast_hash": "a9bc17e60e6795f552aba5dee534c9ba",
"semantic_hash": ""
},
"backend/tests/test_import_holds.py": {
@@ -900,9 +900,9 @@
"semantic_hash": ""
},
"backend/tests/test_private_fixtures.py": {
"mtime": 1789737822.4871244,
"seen": 1789737916.7915628,
"ast_hash": "f14cd118f1e8d6b5088536d50d2b965a",
"mtime": 1790164067.7670314,
"seen": 1790164233.419178,
"ast_hash": "268632b4cd05c2538fac5f22b28480ba",
"semantic_hash": ""
},
".gitea/workflows/ci.yml": {
@@ -948,15 +948,15 @@
"semantic_hash": ""
},
"backend/alembic/versions/20260922_0003_savings_system.py": {
"mtime": 1790078031.6120079,
"seen": 1790096479.536419,
"ast_hash": "12acc1a6c82583b06da88974500498a7",
"mtime": 1790158886.5589485,
"seen": 1790164233.4191144,
"ast_hash": "94f0fec8f2edfdefd3d00ece470867c2",
"semantic_hash": ""
},
"backend/app/ledger/savings.py": {
"mtime": 1790097195.1310222,
"seen": 1790129548.0529978,
"ast_hash": "5367de3a6184c6df07170721a6a0fcf8",
"mtime": 1790158886.5589485,
"seen": 1790164233.4191597,
"ast_hash": "8b17828223421e8062069382024a7ae3",
"semantic_hash": ""
},
"backend/tests/test_budget_savings.py": {
@@ -972,15 +972,15 @@
"semantic_hash": ""
},
"backend/tests/test_import_savings.py": {
"mtime": 1790129378.2103765,
"seen": 1790129548.0530114,
"ast_hash": "7bf7f4500852520a2a975a452911af1c",
"mtime": 1790164033.0365639,
"seen": 1790164233.419175,
"ast_hash": "aee4dbfbada3e53720c7c5dd64014c26",
"semantic_hash": ""
},
"backend/tests/test_savings.py": {
"mtime": 1790097195.1310337,
"seen": 1790129548.053015,
"ast_hash": "c5eb8af5219e2f5cc153d09c0c4a37a1",
"mtime": 1790164079.4361887,
"seen": 1790164233.4191797,
"ast_hash": "db9a0c71ac67e3c104a4fcd9ecb83062",
"semantic_hash": ""
},
"frontend/src/components/budget/BudgetBlockers.tsx": {
@@ -1122,9 +1122,9 @@
"semantic_hash": ""
},
"backend/tests/test_review_queue_batch.py": {
"mtime": 1790155761.6000907,
"seen": 1790155776.860325,
"ast_hash": "ca92b0417d73b849012c90803a4a6885",
"mtime": 1790158886.5589485,
"seen": 1790164233.419179,
"ast_hash": "0074192ea42734bcc988792b5de3624b",
"semantic_hash": ""
},
"frontend/src/test/review-queue-save.test.tsx": {
@@ -1146,9 +1146,27 @@
"semantic_hash": ""
},
"frontend/src/test/ledger-view.test.tsx": {
"mtime": 1790156126.5019412,
"seen": 1790156681.7281582,
"ast_hash": "dbd335027673613bbee4cb47aa4b6983",
"mtime": 1790157401.4480782,
"seen": 1790157424.7858286,
"ast_hash": "325d3bc51b743c4fdde3c00733ce9c99",
"semantic_hash": ""
},
"backend/tests/test_expected_income.py": {
"mtime": 1790164281.3358464,
"seen": 1790164324.132625,
"ast_hash": "459ccb83896076b6acdeb11870bf344c",
"semantic_hash": ""
},
"docs/questions/\u043f\u0435\u0440\u0435\u0432\u043e\u0434_\u043c\u0435\u0436\u0434\u0443_\u0441\u0432\u043e\u0438\u043c\u0438.md": {
"mtime": 1790159262.1681526,
"seen": 1790164233.4192622,
"ast_hash": "25245974c220fc36138fda26f8202e38",
"semantic_hash": ""
},
"docs/tasks/0002-fixes.md": {
"mtime": 1790159106.0020006,
"seen": 1790164233.4192643,
"ast_hash": "772e67f454e325d9bd207036e190d511",
"semantic_hash": ""
}
}
+184 -155
View File
@@ -1,49 +1,49 @@
# Graph Report - finance (2026-09-23)
## Corpus Check
- 187 files · ~90,166 words
- 191 files · ~92,817 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 1667 nodes · 5182 edges · 95 communities (72 shown, 16 thin omitted)
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 91 edges (avg confidence: 0.93)
- 1708 nodes · 5406 edges · 102 communities (78 shown, 15 thin omitted)
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 96 edges (avg confidence: 0.93)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `63d06501`
- Built from commit: `970fe9da`
- 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)
- Category
- budgeting/router.py
- db/models.py
- AppError
- imports/router.py
- budgeting/router.py
- Base
- main.py
- test_import_savings.py
- alfa_xlsx.py
- system-account-picker.test.tsx
- types.ts
- router.tsx
- LoginPage.tsx
- ledger/router.py
- @testing-library/jest-dom
- compilerOptions
- ReviewQueuePage.tsx
- @testing-library/user-event
- AnalyticsPage.tsx
- mcc.py
- devDependencies
- ledger/service.py
- dependencies
- ImportDetailPage.tsx
- useOperations.ts
- OperationsPage.tsx
- Base
- analytics/service.py
- apiErrors.ts
- adapters/base.py
- test_adapter_contracts.py
- BudgetPage.tsx
- client.ts
- fields.tsx
- imports/service.py
- Docker Compose Development Stack
- Financial Space
- scripts
- @types/react
- router.tsx
- General Pool
- TestBaseContract
- Per-File Envelope Encryption
@@ -53,68 +53,73 @@
- Closed Beta Launch Gates
- package.json
- Financial Tracker MVP
- test_adapter_contracts.py
- TestRocketbankDebitContract
- generate-api.mjs
- schema.d.ts
- test_api.py
- i18n-parity.test.ts
- Expected Income
- helpers.tsx
- ledger/models.py
- api
- AccountsPage.tsx
- category-manager.test.tsx
- graphify
- @types/react-dom
- index.ts
- typescript-eslint
- Ближайший край: накопления, категории и блокировки бюджета
- vitest
- RocketbankDebitPdfAdapter
- Favicon SVG (ledger lines icon)
- init-app-role.sh
- Payee
- test_private_fixtures.py
- finance-backend
- LocalDirectoryBlobStore
- OzonDebitPdfAdapter
- TestOzonDebitContract
- Gitea CI and real statement imports
- 20260918_0002_hold_lifecycle.py
- eslint-plugin-react-hooks
- import-hold-resolution.test.tsx
- budgeting/service.py
- TestAlfaDebitContract
- vite
- mcc.py
- TestAlfaSavingsRule
- AppLayout.tsx
- 20260922_0003_savings_system.py
- jsdom
- savings-convert-revert.test.tsx
- get_settings
- ColumnChart.tsx
- dependencies.py
- AGENTS.md
- test_savings.py
- Session
- test_category_definitions.py
- budget-plan.test.tsx
- imports/service.py
- TestAlfaSavingsRule
- exports/service.py
- test_imports.py
- @testing-library/react
- identity/service.py
- Session
- test_import_holds.py
- get_or_create_period
- 20260923_0005_optional_hold_reason.py
- test_import_savings.py
- conftest.py
- typescript
- useAuth.ts
- test_review_queue_batch.py
- @vitejs/plugin-react
- useImports.ts
- describeApiError
- app_error_handler
- review-queue-save.test.tsx
- account-adjustment.test.tsx
- detect_adapter
- review-queue-month.test.tsx
## God Nodes (most connected - your core abstractions)
1. `Session` - 211 edges
2. `AppError` - 72 edges
1. `Session` - 221 edges
2. `AppError` - 76 edges
3. `Base` - 48 edges
4. `conflict()` - 43 edges
5. `get_settings()` - 40 edges
6. `PkMixin` - 40 edges
7. `record()` - 39 edges
8. `TimestampMixin` - 38 edges
9. `StatementDocument` - 36 edges
10. `create_expense()` - 36 edges
4. `record()` - 44 edges
5. `conflict()` - 43 edges
6. `get_settings()` - 40 edges
7. `PkMixin` - 40 edges
8. `get_or_create_period()` - 39 edges
9. `TimestampMixin` - 38 edges
10. `bad_request()` - 36 edges
## Surprising Connections (you probably didn't know these)
- `Typed Same-Origin API Client` --conceptually_related_to--> `Same-Origin Reverse Proxy` [INFERRED]
@@ -136,43 +141,43 @@
- **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 (95 total, 16 thin omitted)
## Communities (102 total, 15 thin omitted)
### Community 0 - "Category"
Cohesion: 0.20
Nodes (15): balances(), cash_flow(), category_spend(), plan_fact(), date, UUID, category_for_mcc(), Category owning the MCC code, else the system «Остальное» fallback. (+7 more)
### Community 0 - "imports/router.py"
Cohesion: 0.15
Nodes (35): confirm(), ConfirmBody, decide_row(), DecisionBody, delete_source(), detail(), HoldResolveBody, imports() (+27 more)
### Community 1 - "budgeting/router.py"
Cohesion: 0.11
Nodes (52): balances(), cash_flow(), category_spend(), plan_fact(), Context, date, Db, get (+44 more)
Cohesion: 0.15
Nodes (51): AssignmentItem, assignments(), AssignmentsBody, categories(), category_definition_json(), category_definitions(), CategoryBody, CategoryDefinitionPatchBody (+43 more)
### Community 2 - "db/models.py"
Cohesion: 0.17
Nodes (23): AuditEvent, Append-only domain/security event. Never stores raw financial payloads., ExpectedIncome, ExpectedIncomeMatch, FundingMovement, PeriodClosure, PlanAssignment, Links one actual income operation to one expected income (1:1 per operation). (+15 more)
### Community 2 - "Base"
Cohesion: 0.09
Nodes (49): downgrade(), upgrade(), AuditEvent, Append-only domain/security event. Never stores raw financial payloads., ExpectedIncome, ExpectedIncomeMatch, FundingMovement, PeriodClosure (+41 more)
### Community 3 - "AppError"
Cohesion: 0.19
Nodes (11): AppError, Any, Domain error rendered as a machine-readable JSON error payload., unprocessable(), decrypt_file(), encrypt_file(), EncryptedFile, Per-file random DEK; DEK wrapped with the KEK from the secret manager. (+3 more)
### Community 3 - "main.py"
Cohesion: 0.20
Nodes (15): get_db(), get_engine(), get_session_factory(), sessionmaker, health(), healthz(), get, readiness() (+7 more)
### Community 4 - "imports/router.py"
Cohesion: 0.05
Nodes (79): _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… (+71 more)
### Community 4 - "test_import_savings.py"
Cohesion: 0.13
Nodes (41): RequestContext, ParseRun, Immutable extracted bank fact with document origin coordinates., SourceRecord, StatementDocument, _build_candidates(), add_record(), make_document() (+33 more)
### Community 5 - "alfa_xlsx.py"
Cohesion: 0.12
Nodes (32): AlfaCreditXlsxAdapter, AlfaDebitXlsxAdapter, _AlfaHeaderMeta, AlfaXlsxAdapterBase, _cell_str(), _debit_find_header(), _debit_header_metadata(), _debit_header_value() (+24 more)
Cohesion: 0.15
Nodes (26): AlfaDebitXlsxAdapter, _AlfaHeaderMeta, AlfaXlsxAdapterBase, _cell_str(), _debit_find_header(), _debit_header_metadata(), _debit_header_value(), _debit_norm() (+18 more)
### Community 6 - "system-account-picker.test.tsx"
Cohesion: 0.11
Nodes (15): AdjustBalanceResult, BudgetCategory, Operation, Page, budgetCategoriesMock, listAccountsMock, listOperationsMock, categoriesMock (+7 more)
Cohesion: 0.12
Nodes (13): Account, AdjustBalanceResult, BudgetCategory, Operation, adjustBalanceMock, listMock, budgetCategoriesMock, listAccountsMock (+5 more)
### Community 7 - "types.ts"
Cohesion: 0.08
Nodes (35): AccountStatus, AccountType, AdjustBalancePayload, BalanceItem, BalancesReport, BudgetStatus, BudgetWarning, CashFlowMonth (+27 more)
### Community 8 - "router.tsx"
Cohesion: 0.07
Nodes (43): AcceptInvitePayload, LoginPayload, PasswordResetPayload, AcceptInvitePage, AccountsPage, AnalyticsPage, BudgetPage, ImportDetailPage (+35 more)
Nodes (38): AcceptInvitePayload, AccountStatus, AccountType, BalanceItem, BalancesReport, BudgetStatus, BudgetWarning, CashFlowMonth (+30 more)
### Community 8 - "LoginPage.tsx"
Cohesion: 0.21
Nodes (11): AcceptInvitePage, LoginPage, RequireAuth(), AuthLayout(), AuthLayoutProps, TextField(), useMe(), AcceptInvitePage() (+3 more)
### Community 9 - "ledger/router.py"
Cohesion: 0.15
@@ -182,53 +187,57 @@ Nodes (38): account_json(), AccountBody, accounts(), adjust_balance(), AdjustBal
Cohesion: 0.07
Nodes (28): compilerOptions, baseUrl, esModuleInterop, forceConsistentCasingInFileNames, isolatedModules, jsx, lib, module (+20 more)
### Community 12 - "ReviewQueuePage.tsx"
Cohesion: 0.09
Nodes (43): NotFoundPage, PlanFactBars(), PlanFactBarsProps, PlanFactRow, EmptyState(), EmptyStateProps, MonthSwitcher(), MonthSwitcherProps (+35 more)
### Community 12 - "AnalyticsPage.tsx"
Cohesion: 0.13
Nodes (24): PlanFactBars(), PlanFactBarsProps, PlanFactRow, MonthSwitcher(), MonthSwitcherProps, useAnalyticsBalances(), useAnalyticsCashFlow(), useAnalyticsCategorySpend() (+16 more)
### Community 13 - "mcc.py"
Cohesion: 0.14
Nodes (23): _create_tenant_policy(), _default_keys(), downgrade(), _has_table(), _space_ids(), upgrade(), _default_categories(), default_category_system_keys() (+15 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)
Cohesion: 0.09
Nodes (23): eslint, @eslint/js, eslint-plugin-jsx-a11y, devDependencies, eslint, @eslint/js, eslint-plugin-jsx-a11y, globals (+15 more)
### Community 15 - "ledger/service.py"
Cohesion: 0.13
Nodes (48): ensure_others_category(), The system «Остальное» fallback category, created on first use., Operation, Posting, Signed balance change of one account within one operation. Sign convention…, User financial fact. amount_kopecks is always positive; direction lives in…, account_balance(), _apply_suggestion() (+40 more)
Cohesion: 0.14
Nodes (47): ensure_others_category(), The system «Остальное» fallback category, created on first use., Operation, Posting, Signed balance change of one account within one operation. Sign convention…, User financial fact. amount_kopecks is always positive; direction lives in…, account_balance(), _apply_suggestion() (+39 more)
### Community 16 - "dependencies"
Cohesion: 0.09
Nodes (23): @fontsource/funnel-sans, @fontsource/geist-mono, dependencies, @fontsource/funnel-sans, @fontsource/geist-mono, @hookform/resolvers, i18next, react (+15 more)
### Community 17 - "ImportDetailPage.tsx"
Cohesion: 0.08
Nodes (45): api, invalidationRoots, queryKeys, SESSION_ROOT, sessionInvalidationRoot, SPACE_ROOT, spaceInvalidationRoot, HoldResolutionAction (+37 more)
Nodes (25): @fontsource/funnel-sans, @fontsource/geist-mono, @fontsource/onest, dependencies, @fontsource/funnel-sans, @fontsource/geist-mono, @fontsource/onest, @hookform/resolvers (+17 more)
### Community 17 - "useOperations.ts"
Cohesion: 0.15
Nodes (17): invalidationRoots, queryKeys, SESSION_ROOT, sessionInvalidationRoot, SPACE_ROOT, spaceInvalidationRoot, CategorizeBatchPayload, CreateOperationPayload (+9 more)
### Community 18 - "OperationsPage.tsx"
Cohesion: 0.11
Nodes (46): CategorizeBatchPayload, CreateOperationPayload, isSystemAccount(), Locale, useAccounts(), useActiveAccounts(), useUserAccounts(), useCategorizeBatch() (+38 more)
Nodes (42): AdjustBalancePayload, CreateAccountPayload, isSystemAccount(), Locale, useAccounts(), useActiveAccounts(), useAdjustBalance(), useArchiveAccount() (+34 more)
### Community 19 - "Base"
Cohesion: 0.15
Nodes (22): downgrade(), upgrade(), BankCategoryMapping, CategorizationRule, CategoryMcc, IncomeSource, MccCode, Payee (+14 more)
### Community 19 - "analytics/service.py"
Cohesion: 0.20
Nodes (18): balances(), cash_flow(), category_spend(), plan_fact(), Context, date, Db, get (+10 more)
### Community 20 - "apiErrors.ts"
Cohesion: 0.09
Nodes (24): isApiError(), BudgetBlocker, App(), queryClient, AppRoutes(), blockerCta(), blockerText(), BudgetBlockers() (+16 more)
Cohesion: 0.33
Nodes (6): isApiError(), App(), queryClient, AppRoutes(), apiErrorCode(), container
### Community 21 - "adapters/base.py"
Cohesion: 0.12
Nodes (20): account_fingerprint(), _mask_digits(), money_to_kopecks(), normalize_description(), date, Parse statement money strings like '-1 234,56 RUR', '+ 500.00 ₽', '12 345'.…, record_fingerprint(), StatementDraft (+12 more)
### Community 21 - "test_adapter_contracts.py"
Cohesion: 0.13
Nodes (21): AlfaCreditXlsxAdapter, 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 (+13 more)
### Community 22 - "BudgetPage.tsx"
Cohesion: 0.14
Nodes (27): CreatableCategoryKind, CategoryManagerModal(), CREATE_KINDS, CreateCategoryForm(), MccCodesModal(), parseMccCodes(), RenameModal(), Badge() (+19 more)
Cohesion: 0.17
Nodes (28): CreatableCategoryKind, CategoryManagerModal(), CREATE_KINDS, CreateCategoryForm(), MccCodesModal(), parseMccCodes(), RenameModal(), useBudgetCategories() (+20 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.21
Nodes (11): FieldChrome, InputProps, MoneyField(), MoneyFieldProps, SelectField(), SelectFieldProps, SelectProps, TextareaField() (+3 more)
### Community 24 - "imports/service.py"
Cohesion: 0.15
Nodes (33): Any, record(), get_income_source(), Replace the category's MCC code set; codes are unique per space., set_category_mcc_codes(), conflict(), not_found(), Any (+25 more)
### Community 25 - "Docker Compose Development Stack"
Cohesion: 0.29
@@ -242,6 +251,10 @@ Nodes (12): Credit Reserve, Financial Account, Financial Space, Membership, User
Cohesion: 0.22
Nodes (9): scripts, api:generate, build, dev, lint, preview, test, test:watch (+1 more)
### Community 28 - "router.tsx"
Cohesion: 0.15
Nodes (11): AccountsPage, AnalyticsPage, BudgetPage, ImportDetailPage, ImportsPage, NotFoundPage, OperationsPage, OverviewPage (+3 more)
### Community 29 - "General Pool"
Cohesion: 0.29
Nodes (8): Budget Period, Funding, General Pool, Monthly Category, Plan Assignment, Savings Envelope, Immutable Closure Snapshots, Plan and Funding Separation
@@ -278,9 +291,9 @@ Nodes (4): name, private, type, version
Cohesion: 0.18
Nodes (11): Audited Break-Glass Access, Cross-Space Referential Integrity, Defense-in-Depth Authorization, Financial Space Ownership, Transaction-Local RLS Context, Budget Invariants, Critical User Path, Financial Tracker MVP (+3 more)
### Community 38 - "test_adapter_contracts.py"
Cohesion: 0.13
Nodes (8): minimal_pdf(), _QuotaSettings, Sanitized synthetic contract tests for the evidenced statement adapters. These…, Build a small valid single-font PDF with empty or ASCII-only pages., same_page_rocket_lines(), synthetic_rocket_lines(), TestRocketbankDebitContract, MonkeyPatch
### Community 38 - "TestRocketbankDebitContract"
Cohesion: 0.24
Nodes (3): same_page_rocket_lines(), synthetic_rocket_lines(), TestRocketbankDebitContract
### Community 39 - "generate-api.mjs"
Cohesion: 0.40
@@ -294,13 +307,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.18
Nodes (11): User, DESKTOP_NAV, meMock, MOBILE_NAV, user, createTestQueryClient(), renderWithProviders(), loginMock (+3 more)
### Community 44 - "api"
Cohesion: 0.14
Nodes (14): api, User, createMock, listMock, DESKTOP_NAV, meMock, MOBILE_NAV, user (+6 more)
### Community 45 - "ledger/models.py"
Cohesion: 0.26
Nodes (11): HoldResolution, Append-only, tenant-scoped settlement of one HOLD source record. The extracted…, Account, Financial account. Balance is always derived: opening + confirmed postings., FinancialSpace, Membership, test_postgres_rls_requires_matching_transaction_context(), test_s3_blob_round_trip() (+3 more)
### Community 45 - "AccountsPage.tsx"
Cohesion: 0.10
Nodes (37): Badge(), BadgeTone, Button(), ButtonProps, Variant, ConfirmDialog(), ConfirmDialogProps, EmptyState() (+29 more)
### Community 46 - "category-manager.test.tsx"
Cohesion: 0.14
@@ -310,21 +323,29 @@ Nodes (12): CategoryDefinition, categoriesMock, createCategoryMock, definitions,
Cohesion: 0.17
Nodes (11): command, enabled, timeout, type, mcp, graphify, plugin, $schema (+3 more)
### Community 48 - "index.ts"
Cohesion: 0.22
Nodes (10): BudgetBlocker, blockerCta(), blockerText(), BudgetBlockers(), BudgetBlockersProps, Cta, createI18n(), detectLocale() (+2 more)
### Community 50 - "Ближайший край: накопления, категории и блокировки бюджета"
Cohesion: 0.10
Nodes (20): 1. Доменная основа накоплений, 2. Альфа debit: «Копилка для сдачи», 3. Управление категориями, 4. Понятные и устранимые блокировки, Backend-контракт, Frontend, Ближайший край: накопления, категории и блокировки бюджета, Бюджетная семантика (+12 more)
### Community 51 - "RocketbankDebitPdfAdapter"
Cohesion: 0.22
Nodes (7): _date_from(), _PendingRecord, date, Rocketbank debit statement contract, evidenced against the real document.…, RocketbankDebitPdfAdapter, open_pdf(), _quota_error()
### Community 58 - "test_private_fixtures.py"
Cohesion: 0.10
Nodes (24): Protocol, StatementAdapter, detect_adapter(), Any, Reconcile parsed rows against declared bank controls with stable diagnostics.…, _reconciliation_summary(), _check(), _fail() (+16 more)
Cohesion: 0.12
Nodes (22): _draft_json(), 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.18
Nodes (6): _ozon_normalize(), OzonDebitPdfAdapter, Ozon debit statement contract, evidenced against the real document. A 'Справка…, ozon_extracted(), TestOzonDebitContract, Pattern
### Community 63 - "TestOzonDebitContract"
Cohesion: 0.15
Nodes (6): minimal_pdf(), ozon_extracted(), _QuotaSettings, Build a small valid single-font PDF with empty or ASCII-only pages., TestOzonDebitContract, MonkeyPatch
### Community 64 - "Gitea CI and real statement imports"
Cohesion: 0.29
@@ -339,16 +360,20 @@ Cohesion: 0.15
Nodes (11): ImportDetail, ImportRow, ImportSummary, confirmMock, detailFixture(), getImportMock, postedRow(), getImportMock (+3 more)
### Community 68 - "budgeting/service.py"
Cohesion: 0.16
Nodes (39): BudgetPeriod, Calendar month budget period in the space timezone (month granularity)., add_expected_income(), archive_category(), _balance_until(), _close_blockers(), close_period(), _closure_snapshot() (+31 more)
Cohesion: 0.13
Nodes (50): BudgetPeriod, Calendar month budget period in the space timezone (month granularity)., add_expected_income(), archive_category(), _balance_until(), _close_blockers(), close_period(), _closure_snapshot() (+42 more)
### Community 69 - "TestAlfaDebitContract"
Cohesion: 0.18
Nodes (3): alfa_workbook_bytes(), synthetic_alfa_rows(), TestAlfaDebitContract
### Community 71 - "mcc.py"
Cohesion: 0.14
Nodes (23): _create_tenant_policy(), _default_keys(), downgrade(), _has_table(), _space_ids(), upgrade(), _default_categories(), default_category_system_keys() (+15 more)
### Community 70 - "AppLayout.tsx"
Cohesion: 0.19
Nodes (10): AppLayout(), MOBILE_NAV_ITEMS, MOBILE_NAV_ROUTES, NAV_ICONS, NAV_ITEMS, NavIconName, LangSwitcher(), useLogout() (+2 more)
### Community 71 - "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 73 - "savings-convert-revert.test.tsx"
Cohesion: 0.20
@@ -363,88 +388,92 @@ Cohesion: 0.24
Nodes (7): CashFlowColumn, ColumnChart(), ColumnChartProps, BarRow, HorizontalBars(), HorizontalBarsProps, rows
### Community 76 - "dependencies.py"
Cohesion: 0.09
Nodes (39): current_context(), current_session(), alias, Context, CurrentSession, Db, Header, User (+31 more)
Cohesion: 0.10
Nodes (39): current_context(), Context, CurrentSession, Db, Header, require_csrf(), forbidden(), cancel_deletion() (+31 more)
### Community 79 - "test_savings.py"
### Community 79 - "Session"
Cohesion: 0.14
Nodes (33): adjust_balance(), convert_expense_to_savings(), ensure_savings_resources(), Account, date, Operation, User, UUID (+25 more)
Nodes (43): AppError, Domain error rendered as a machine-readable JSON error payload., Session, Category, Budget category. Kept in ledger module to avoid circular imports., adjust_balance(), convert_expense_to_savings(), ensure_savings_resources() (+35 more)
### Community 80 - "test_category_definitions.py"
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 - "imports/service.py"
Cohesion: 0.18
Nodes (29): Any, record(), utcnow(), conflict(), not_found(), magic_kind(), _apply_hold_matches(), _category_id_for_source() (+21 more)
Cohesion: 0.15
Nodes (11): BudgetSummary, ExpectedIncome, categories, categoriesMock, month, summary, summaryMock, updatePlanMock (+3 more)
### Community 83 - "exports/service.py"
Cohesion: 0.13
Nodes (19): DataExport, build_export(), consume_export(), _csv_bytes(), get_export(), _json_rows(), purge_due_spaces(), datetime (+11 more)
### Community 84 - "test_imports.py"
Cohesion: 0.17
Nodes (29): DedupCandidate, ParseRun, Immutable extracted bank fact with document origin coordinates., SourceRecord, SourceRecordLink, StatementDocument, _build_candidates(), _match_hold_resolutions() (+21 more)
Cohesion: 0.07
Nodes (45): Any, Transaction-local RLS context. No-op on SQLite (tests); fail-closed on…, Transactional scope for workers/endpoints that own the transaction lifecycle., session_scope(), set_space_context(), download(), export_status(), Context (+37 more)
### Community 85 - "identity/service.py"
Cohesion: 0.26
Nodes (20): bad_request(), unauthorized(), accept_invitation(), authenticate(), create_invitation(), create_password_reset(), create_session(), get_active_session() (+12 more)
Cohesion: 0.19
Nodes (24): current_session(), alias, User, unauthorized(), accept_invitation(), authenticate(), create_invitation(), create_password_reset() (+16 more)
### Community 86 - "Session"
Cohesion: 0.16
Nodes (41): Session, add_record(), _draft(), hold_document(), make_document(), date, Path, TestClient (+33 more)
### Community 86 - "test_import_holds.py"
Cohesion: 0.13
Nodes (42): normalize_description(), date, record_fingerprint(), add_record(), _draft(), hold_document(), make_document(), date (+34 more)
### Community 87 - "get_or_create_period"
Cohesion: 0.34
Nodes (14): get_or_create_period(), validate_year_month(), Budget semantics for savings transfers, adjustments and archived history., savings_row(), seed(), test_adjustment_changes_only_envelope_available_not_pool(), test_adjustment_on_regular_account_keeps_pool_and_totals(), test_archived_category_history_stays_in_totals() (+6 more)
Cohesion: 0.18
Nodes (27): compute_period_state(), create_category(), get_or_create_period(), Deterministic budget state. Resource model: - income lands in the general pool;…, category_for_mcc(), get_category_mcc_codes(), UUID, Category owning the MCC code, else the system «Остальное» fallback. (+19 more)
### Community 88 - "20260923_0005_optional_hold_reason.py"
Cohesion: 0.83
Nodes (3): downgrade(), _reason_constraint_name(), upgrade()
### Community 89 - "test_import_savings.py"
Cohesion: 0.32
Nodes (17): get_savings_account(), add_record(), make_document(), date, User, Import contract for the «Копилка для сдачи» savings_transfer decision., seed(), test_confirm_creates_savings_transfer_with_system_category() (+9 more)
### Community 90 - "conftest.py"
Cohesion: 0.27
Nodes (6): client(), db(), db_factory(), fixture, sessionmaker, TestClient
### Community 90 - "useAuth.ts"
Cohesion: 0.36
Nodes (8): ResetPasswordPage, useAcceptInvite(), useConfirmPasswordReset(), useLogin(), useRequestPasswordReset(), useSessionMutation(), ResetPasswordPage(), ResetValues
### Community 91 - "test_review_queue_batch.py"
Cohesion: 0.60
Nodes (10): bootstrap(), category_ids(), create_account(), create_expense(), operations_by_id(), TestClient, test_batch_assigns_picked_categories_and_moves_leftovers_to_others(), test_batch_is_atomic_when_one_category_is_unknown() (+2 more)
### Community 92 - "review-queue-save.test.tsx"
### Community 93 - "useImports.ts"
Cohesion: 0.13
Nodes (24): ImportStatus, ToastApi, ToastContext, ToastItem, ToastKind, ToastProvider(), useToast(), useConfirmImport() (+16 more)
### Community 94 - "describeApiError"
Cohesion: 0.44
Nodes (8): useCancelDeletion(), useRequestDeletion(), useRequestExport(), useSpaceDeletion(), describeApiError(), DeletionForm(), DeletionFormValues, SettingsPage()
### Community 95 - "app_error_handler"
Cohesion: 0.32
Nodes (8): app_error_handler(), Request, security_headers(), stale_data_handler(), exception_handler, JSONResponse, middleware, StaleDataError
### Community 96 - "review-queue-save.test.tsx"
Cohesion: 0.25
Nodes (6): categories, categoriesMock, categorizeBatchMock, categorizeMock, importsListMock, operationsListMock
### Community 94 - "account-adjustment.test.tsx"
Cohesion: 0.20
Nodes (5): Account, adjustBalanceMock, listMock, createMock, listMock
### Community 100 - "detect_adapter"
Cohesion: 0.29
Nodes (4): Protocol, StatementAdapter, detect_adapter(), Workbook
### Community 101 - "review-queue-month.test.tsx"
Cohesion: 0.33
Nodes (5): Page, categoriesMock, importsListMock, importSummary, operationsListMock
## Knowledge Gaps
- **267 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` (+262 more)
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 468 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.
- **270 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` (+265 more)
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 477 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
- **15 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 `Category`, `budgeting/router.py`, `db/models.py`, `imports/router.py`, `alfa_xlsx.py`, `ledger/service.py`, `Base`, `ledger/models.py`, `budgeting/service.py`, `mcc.py`, `dependencies.py`, `test_savings.py`, `imports/service.py`, `exports/service.py`, `test_imports.py`, `identity/service.py`, `get_or_create_period`, `test_import_savings.py`, `conftest.py`?**
_High betweenness centrality (0.067) - this node is a cross-community bridge._
- **Why does `AppError` connect `AppError` to `imports/router.py`, `alfa_xlsx.py`, `test_adapter_contracts.py`, `budgeting/service.py`, `get_settings`, `dependencies.py`, `ledger/service.py`, `test_savings.py`, `imports/service.py`, `test_imports.py`, `identity/service.py`, `adapters/base.py`, `get_or_create_period`, `Session`, `test_import_savings.py`, `OzonDebitPdfAdapter`?**
_High betweenness centrality (0.049) - this node is a cross-community bridge._
- **Why does `TestRocketbankDebitContract` connect `test_adapter_contracts.py` to `AppError`, `adapters/base.py`?**
- **Why does `Session` connect `Session` to `Base`, `main.py`, `budgeting/service.py`, `test_import_savings.py`, `20260922_0003_savings_system.py`, `dependencies.py`, `mcc.py`, `ledger/service.py`, `analytics/service.py`, `exports/service.py`, `identity/service.py`, `test_import_holds.py`, `get_or_create_period`, `imports/service.py`?**
_High betweenness centrality (0.071) - this node is a cross-community bridge._
- **Why does `AppError` connect `Session` to `imports/router.py`, `main.py`, `budgeting/service.py`, `alfa_xlsx.py`, `TestRocketbankDebitContract`, `test_import_savings.py`, `get_settings`, `dependencies.py`, `ledger/service.py`, `exports/service.py`, `RocketbankDebitPdfAdapter`, `identity/service.py`, `test_adapter_contracts.py`, `get_or_create_period`, `imports/service.py`, `test_import_holds.py`, `TestOzonDebitContract`, `app_error_handler`?**
_High betweenness centrality (0.047) - this node is a cross-community bridge._
- **Why does `get_settings()` connect `get_settings` to `imports/router.py`, `Base`, `main.py`, `alfa_xlsx.py`, `dependencies.py`, `exports/service.py`, `RocketbankDebitPdfAdapter`, `identity/service.py`, `test_import_holds.py`, `imports/service.py`?**
_High betweenness centrality (0.019) - 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 42 inferred relationships involving `AppError` (e.g. with `request_export()` and `login()`) actually correct?**
_`AppError` has 42 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?**
_267 weakly-connected nodes found - possible documentation gaps or missing edges._
_270 weakly-connected nodes found - possible documentation gaps or missing edges._
File diff suppressed because one or more lines are too long
+11243 -8111
View File
File diff suppressed because it is too large Load Diff
+108 -84
View File
@@ -30,9 +30,9 @@
"semantic_hash": ""
},
"backend/app/analytics/service.py": {
"mtime": 1790061159.3943744,
"seen": 1790096479.536424,
"ast_hash": "58ac24a62b4cf8a986527f79d456127d",
"mtime": 1790158886.5589485,
"seen": 1790164233.4191206,
"ast_hash": "3d87f3a80dc16a7781b5b802a0a66039",
"semantic_hash": ""
},
"backend/app/audit/__init__.py": {
@@ -66,15 +66,15 @@
"semantic_hash": "039081cab3d91704e8f8a7152a981304"
},
"backend/app/budgeting/router.py": {
"mtime": 1790140570.7235522,
"seen": 1790140596.4645138,
"ast_hash": "5c8ea9e79dbe5450c2891c40931f0f4d",
"mtime": 1790164219.7100794,
"seen": 1790164233.4191263,
"ast_hash": "e4c01ff67e84d606da20520c634aada1",
"semantic_hash": ""
},
"backend/app/budgeting/service.py": {
"mtime": 1790139967.779716,
"seen": 1790140596.4645145,
"ast_hash": "8b936c8c80b35123143fcc6bed627172",
"mtime": 1790164039.9056563,
"seen": 1790164233.4191272,
"ast_hash": "9856f61057a9ddc569b2d8ae5d78f688",
"semantic_hash": ""
},
"backend/app/categorization/__init__.py": {
@@ -222,9 +222,9 @@
"semantic_hash": ""
},
"backend/app/imports/adapters/ozon_pdf.py": {
"mtime": 1789733868.836172,
"seen": 1789735433.158129,
"ast_hash": "dfa6dc9ccf2c655e6c2eeaceea0cf06a",
"mtime": 1790163956.5875351,
"seen": 1790164233.4191487,
"ast_hash": "a0aa31d71ab7a45a479178cd630d8b05",
"semantic_hash": ""
},
"backend/app/imports/adapters/rocketbank_pdf.py": {
@@ -288,15 +288,15 @@
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"backend/app/ledger/models.py": {
"mtime": 1790060833.8770478,
"seen": 1790096479.5364609,
"ast_hash": "86891c404082d8d2c84214f31d8616ff",
"mtime": 1790158886.5589485,
"seen": 1790164233.4191582,
"ast_hash": "38e4db3531f1672e57e86ec36d7b1ed7",
"semantic_hash": ""
},
"backend/app/ledger/router.py": {
"mtime": 1790155278.0175776,
"seen": 1790155776.860304,
"ast_hash": "24b2565478f257f38561fabff646130a",
"mtime": 1790158886.5589485,
"seen": 1790164233.419159,
"ast_hash": "37c07d8487d317af02a3762099bce24b",
"semantic_hash": ""
},
"backend/app/ledger/service.py": {
@@ -372,15 +372,15 @@
"semantic_hash": "bfec0778131788a91a0bc5c9ec2ad8ba"
},
"backend/tests/test_budget.py": {
"mtime": 1790141279.1849978,
"seen": 1790141340.361456,
"ast_hash": "81578f618d0758c7f9e2f00c921f2594",
"mtime": 1790164269.4717507,
"seen": 1790164324.1326215,
"ast_hash": "6d01ee3dd3fdc66f65ba85ba6a3909fc",
"semantic_hash": ""
},
"backend/tests/test_imports.py": {
"mtime": 1790140440.029933,
"seen": 1790140596.4645612,
"ast_hash": "9e568b07fa4d77eb55c36ef748fb0f9b",
"mtime": 1790164055.474866,
"seen": 1790164233.4191756,
"ast_hash": "580d49a52a4cfc72ce23b21cc983f833",
"semantic_hash": ""
},
"backend/tests/test_ledger_properties.py": {
@@ -402,9 +402,9 @@
"semantic_hash": "ed54799a921d10394f826893acb46876"
},
"frontend/package.json": {
"mtime": 1790055350.366911,
"seen": 1790096479.5364823,
"ast_hash": "172e2880b6084c1fc423ea759b99280a",
"mtime": 1790164273.1148,
"seen": 1790164324.1326368,
"ast_hash": "6e94a9e65d8bbac0083b73acea30af23",
"semantic_hash": ""
},
"frontend/scripts/generate-api.mjs": {
@@ -426,9 +426,9 @@
"semantic_hash": "dbea075a006b7703340f7f93e69d53ac"
},
"frontend/src/api/endpoints.ts": {
"mtime": 1790155318.8911343,
"seen": 1790155776.8603325,
"ast_hash": "972cada63fe04b77326f9fd51c6a0fff",
"mtime": 1790164934.5157416,
"seen": 1790165332.699026,
"ast_hash": "4b26317b0d9c940df9b7b173e6de15c4",
"semantic_hash": ""
},
"frontend/src/api/generated/schema.d.ts": {
@@ -444,9 +444,9 @@
"semantic_hash": ""
},
"frontend/src/api/types.ts": {
"mtime": 1790155310.2330165,
"seen": 1790155776.8603358,
"ast_hash": "168d7c87c05b4bf5205cde1bc45ff75b",
"mtime": 1790164934.5147417,
"seen": 1790165332.6990294,
"ast_hash": "bf75f61d6fa226a6a073770cbacf3784",
"semantic_hash": ""
},
"frontend/src/app/router.tsx": {
@@ -474,9 +474,9 @@
"semantic_hash": "f581486baa48447ec4c573acef3190ef"
},
"frontend/src/components/layout/AppLayout.tsx": {
"mtime": 1790155877.7546387,
"seen": 1790156681.7281113,
"ast_hash": "b2485ee6009655cd96a2acda54c4e873",
"mtime": 1790164788.5197659,
"seen": 1790165332.6990376,
"ast_hash": "0d03099bc00a95bd3889434b26541fe8",
"semantic_hash": ""
},
"frontend/src/components/layout/LangSwitcher.tsx": {
@@ -516,9 +516,9 @@
"semantic_hash": "6827eb04b914e8c675e842fea74b5ba5"
},
"frontend/src/components/ui/Modal.tsx": {
"mtime": 1790055350.367207,
"seen": 1790096479.5365033,
"ast_hash": "4250ebda526b86679ba5eb965feccf59",
"mtime": 1790165073.2735536,
"seen": 1790165332.6990447,
"ast_hash": "bf0bf25b1397d8368099127a570b17a7",
"semantic_hash": ""
},
"frontend/src/components/ui/MonthSwitcher.tsx": {
@@ -576,9 +576,9 @@
"semantic_hash": "40f9f47b108ec0b484f9564bd1d319cb"
},
"frontend/src/hooks/useBudget.ts": {
"mtime": 1790140175.05337,
"seen": 1790140596.464592,
"ast_hash": "ffe50957304981998e7b92838ae15bdb",
"mtime": 1790164934.5157416,
"seen": 1790165332.6990545,
"ast_hash": "0e193a3e55e814a92e5aa38319c3eadb",
"semantic_hash": ""
},
"frontend/src/hooks/useImports.ts": {
@@ -606,21 +606,21 @@
"semantic_hash": "243512f6e095deeaa29393b7c831777f"
},
"frontend/src/i18n/locales/en.json": {
"mtime": 1790155431.9488008,
"seen": 1790155776.860359,
"ast_hash": "a92d8d64ae7b7e47bf83e625ea901611",
"mtime": 1790164982.1323862,
"seen": 1790165332.6990597,
"ast_hash": "d18260df743714adc9e30465612f01db",
"semantic_hash": ""
},
"frontend/src/i18n/locales/ru.json": {
"mtime": 1790155435.0697088,
"seen": 1790155776.8603597,
"ast_hash": "31dc876ab451fbd85e91efde3c33f904",
"mtime": 1790164982.1333861,
"seen": 1790165332.6990604,
"ast_hash": "9428a9c9e6497f077c274e82150ad4b3",
"semantic_hash": ""
},
"frontend/src/main.tsx": {
"mtime": 1790055350.367207,
"seen": 1790096479.5365248,
"ast_hash": "b88ee6ed2ae6eab1f3db7097b6912329",
"mtime": 1790165287.3710241,
"seen": 1790165332.6990693,
"ast_hash": "cf297f4697f483026ef7a7800d0a8d8f",
"semantic_hash": ""
},
"frontend/src/pages/AcceptInvitePage.tsx": {
@@ -642,9 +642,9 @@
"semantic_hash": "4a83070566fde66ff6423b1064cde4e8"
},
"frontend/src/pages/BudgetPage.tsx": {
"mtime": 1790097127.972161,
"seen": 1790129548.053057,
"ast_hash": "7435e885343ed139a64dfbe152b44624",
"mtime": 1790164892.6851754,
"seen": 1790165332.6990728,
"ast_hash": "8484ee3d99574a8e794702f895bb8b85",
"semantic_hash": ""
},
"frontend/src/pages/ImportDetailPage.tsx": {
@@ -888,9 +888,9 @@
"semantic_hash": ""
},
"backend/tests/test_adapter_contracts.py": {
"mtime": 1790061478.1675427,
"seen": 1790096479.5364716,
"ast_hash": "9d446a10c953a14c8d4962eff50f5b97",
"mtime": 1790164118.1117096,
"seen": 1790164233.419169,
"ast_hash": "a9bc17e60e6795f552aba5dee534c9ba",
"semantic_hash": ""
},
"backend/tests/test_import_holds.py": {
@@ -900,9 +900,9 @@
"semantic_hash": ""
},
"backend/tests/test_private_fixtures.py": {
"mtime": 1789737822.4871244,
"seen": 1789737916.7915628,
"ast_hash": "f14cd118f1e8d6b5088536d50d2b965a",
"mtime": 1790164067.7670314,
"seen": 1790164233.419178,
"ast_hash": "268632b4cd05c2538fac5f22b28480ba",
"semantic_hash": ""
},
".gitea/workflows/ci.yml": {
@@ -942,21 +942,21 @@
"semantic_hash": ""
},
"frontend/src/pages/OverviewPage.tsx": {
"mtime": 1790077489.1771908,
"seen": 1790096479.5365322,
"ast_hash": "6a426b63545f169490e67d52ca231180",
"mtime": 1790164982.131386,
"seen": 1790165332.6990778,
"ast_hash": "8acb02afddacb8cb78a2e21ace07ab4c",
"semantic_hash": ""
},
"backend/alembic/versions/20260922_0003_savings_system.py": {
"mtime": 1790078031.6120079,
"seen": 1790096479.536419,
"ast_hash": "12acc1a6c82583b06da88974500498a7",
"mtime": 1790158886.5589485,
"seen": 1790164233.4191144,
"ast_hash": "94f0fec8f2edfdefd3d00ece470867c2",
"semantic_hash": ""
},
"backend/app/ledger/savings.py": {
"mtime": 1790097195.1310222,
"seen": 1790129548.0529978,
"ast_hash": "5367de3a6184c6df07170721a6a0fcf8",
"mtime": 1790158886.5589485,
"seen": 1790164233.4191597,
"ast_hash": "8b17828223421e8062069382024a7ae3",
"semantic_hash": ""
},
"backend/tests/test_budget_savings.py": {
@@ -972,15 +972,15 @@
"semantic_hash": ""
},
"backend/tests/test_import_savings.py": {
"mtime": 1790129378.2103765,
"seen": 1790129548.0530114,
"ast_hash": "7bf7f4500852520a2a975a452911af1c",
"mtime": 1790164033.0365639,
"seen": 1790164233.419175,
"ast_hash": "aee4dbfbada3e53720c7c5dd64014c26",
"semantic_hash": ""
},
"backend/tests/test_savings.py": {
"mtime": 1790097195.1310337,
"seen": 1790129548.053015,
"ast_hash": "c5eb8af5219e2f5cc153d09c0c4a37a1",
"mtime": 1790164079.4361887,
"seen": 1790164233.4191797,
"ast_hash": "db9a0c71ac67e3c104a4fcd9ecb83062",
"semantic_hash": ""
},
"frontend/src/components/budget/BudgetBlockers.tsx": {
@@ -990,9 +990,9 @@
"semantic_hash": ""
},
"frontend/src/components/budget/CategoryManagerModal.tsx": {
"mtime": 1790141684.261269,
"seen": 1790142012.6081603,
"ast_hash": "3320a39025aec0d67df0a5f8a4bf6d4b",
"mtime": 1790164788.520766,
"seen": 1790165332.6990325,
"ast_hash": "25e3f09e8aa689ddec8b4324b23bea99",
"semantic_hash": ""
},
"frontend/src/lib/accountLabel.ts": {
@@ -1056,9 +1056,9 @@
"semantic_hash": ""
},
"frontend/src/test/category-manager.test.tsx": {
"mtime": 1790141753.6681683,
"seen": 1790142012.6082017,
"ast_hash": "9d2f35f1471a69a6a73d1a55cf81cb2a",
"mtime": 1790165029.0430212,
"seen": 1790165332.6990855,
"ast_hash": "61dc003a42028373615bc6937f593247",
"semantic_hash": ""
},
"frontend/src/test/import-hold-resolution.test.tsx": {
@@ -1122,9 +1122,9 @@
"semantic_hash": ""
},
"backend/tests/test_review_queue_batch.py": {
"mtime": 1790155761.6000907,
"seen": 1790155776.860325,
"ast_hash": "ca92b0417d73b849012c90803a4a6885",
"mtime": 1790158886.5589485,
"seen": 1790164233.419179,
"ast_hash": "0074192ea42734bcc988792b5de3624b",
"semantic_hash": ""
},
"frontend/src/test/review-queue-save.test.tsx": {
@@ -1150,5 +1150,29 @@
"seen": 1790157424.7858286,
"ast_hash": "325d3bc51b743c4fdde3c00733ce9c99",
"semantic_hash": ""
},
"backend/tests/test_expected_income.py": {
"mtime": 1790164281.3358464,
"seen": 1790164324.132625,
"ast_hash": "459ccb83896076b6acdeb11870bf344c",
"semantic_hash": ""
},
"docs/questions/\u043f\u0435\u0440\u0435\u0432\u043e\u0434_\u043c\u0435\u0436\u0434\u0443_\u0441\u0432\u043e\u0438\u043c\u0438.md": {
"mtime": 1790159262.1681526,
"seen": 1790164233.4192622,
"ast_hash": "25245974c220fc36138fda26f8202e38",
"semantic_hash": ""
},
"docs/tasks/0002-fixes.md": {
"mtime": 1790159106.0020006,
"seen": 1790164233.4192643,
"ast_hash": "772e67f454e325d9bd207036e190d511",
"semantic_hash": ""
},
"frontend/src/test/expected-income.test.tsx": {
"mtime": 1790165037.09313,
"seen": 1790165332.6990879,
"ast_hash": "3433f5080e5121f6b015ad5dc27448a5",
"semantic_hash": ""
}
}
+8
View File
@@ -0,0 +1,8 @@
[pytest]
pythonpath = backend
testpaths = backend/tests
addopts = -m "not private_fixtures"
markers =
private_fixtures: real-statement private suite; run explicitly with -m private_fixtures
filterwarnings =
ignore::DeprecationWarning