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 datetime import date
from typing import Annotated, Literal from typing import Annotated, Literal
from fastapi import APIRouter, Query from fastapi import APIRouter, Query, Response
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
from app.budgeting import service from app.budgeting import service
from app.categorization import service as categorization from app.categorization import service as categorization
@@ -37,10 +37,28 @@ class FundingBody(BaseModel):
class ExpectedIncomeBody(BaseModel): class ExpectedIncomeBody(BaseModel):
expected_on: date model_config = ConfigDict(populate_by_name=True)
amount_kopecks: int = Field(gt=0)
source_name: str | None = Field(default=None, max_length=200) expected_on: date = Field(alias="expectedOn")
note: str | None = Field(default=None, max_length=500) 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): 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 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: def frontend_summary(state: dict) -> dict:
monthly = state["monthly_categories"] monthly = state["monthly_categories"]
envelopes = state["envelopes"] envelopes = state["envelopes"]
@@ -90,21 +124,7 @@ def frontend_summary(state: dict) -> dict:
"availableKopecks": available, "availableKopecks": available,
"warnings": [{"code": code} for code in state["warnings"]], "warnings": [{"code": code} for code in state["warnings"]],
"blockers": state["close_blockers"], "blockers": state["close_blockers"],
"expectedIncomes": [ "expectedIncomes": [expected_income_json(item) for item in state["expected_incomes"]],
{
"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"]
],
} }
@@ -308,8 +328,20 @@ def fund(year: int, month: int, body: FundingBody, db: Db, context: Context, _cs
return {"id": movement.id} 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) @router.post("/{year}/{month}/expected-incomes", status_code=201)
def expected_income( def create_expected_income(
year: int, year: int,
month: int, month: int,
body: ExpectedIncomeBody, body: ExpectedIncomeBody,
@@ -325,7 +357,90 @@ def expected_income(
period=period, period=period,
**body.model_dump(), **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") @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) row["planned"] = planned_by_cat.get(cid, 0)
planned_total = sum(planned_by_cat.values()) planned_total = sum(planned_by_cat.values())
expected = db.query(ExpectedIncome).filter_by(period_id=period.id).all() expected_items = expected_income_items(db, space_id=space_id, period=period)
expected_total = sum(e.amount_kopecks for e in expected) expected_total = sum(item["amount_kopecks"] for item in expected_items)
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
uncategorized_count = sum(1 for op in ops if op.type == "expense" and op.category_id is None) uncategorized_count = sum(1 for op in ops if op.type == "expense" and op.category_id is None)
negative_envelopes = [ negative_envelopes = [
@@ -465,16 +456,7 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri
"income_total": income_total, "income_total": income_total,
"planned_total": planned_total, "planned_total": planned_total,
"expected_income_total": expected_total, "expected_income_total": expected_total,
"expected_incomes": [ "expected_incomes": expected_items,
{
"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
],
"monthly_categories": monthly_rows, "monthly_categories": monthly_rows,
"envelopes": envelope_rows, "envelopes": envelope_rows,
"credit_reserves": credit_reserves, "credit_reserves": credit_reserves,
@@ -717,6 +699,65 @@ def set_assignments(
return result 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( def add_expected_income(
db: Session, db: Session,
*, *,
@@ -731,6 +772,7 @@ def add_expected_income(
require_open(period) require_open(period)
if amount_kopecks <= 0: if amount_kopecks <= 0:
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive") raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
_ensure_expected_date_in_period(period, expected_on)
income = ExpectedIncome( income = ExpectedIncome(
space_id=space_id, space_id=space_id,
period_id=period.id, period_id=period.id,
@@ -741,6 +783,13 @@ def add_expected_income(
) )
db.add(income) db.add(income)
db.flush() 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 return income
@@ -749,15 +798,14 @@ def match_expected_income(
*, *,
space_id: uuid.UUID, space_id: uuid.UUID,
user: User, user: User,
period: BudgetPeriod,
expected_income_id: uuid.UUID, expected_income_id: uuid.UUID,
operation_id: uuid.UUID, operation_id: uuid.UUID,
amount_kopecks: int | None = None, amount_kopecks: int | None = None,
) -> ExpectedIncomeMatch: ) -> ExpectedIncomeMatch:
expected = ( expected = get_period_expected_income(
db.query(ExpectedIncome).filter_by(id=expected_income_id, space_id=space_id).one_or_none() 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() 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": 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") raise not_found("BUDGET_INCOME_OPERATION_NOT_FOUND", "Confirmed income operation not found")
@@ -776,15 +824,128 @@ def match_expected_income(
) )
match = ExpectedIncomeMatch( match = ExpectedIncomeMatch(
space_id=space_id, space_id=space_id,
expected_income_id=expected.id, expected_income_id=expected_income_id,
operation_id=operation.id, operation_id=operation.id,
amount_kopecks=amount, amount_kopecks=amount,
) )
db.add(match) db.add(match)
db.flush() 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 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( def fund(
db: Session, db: Session,
*, *,
+3 -3
View File
@@ -170,7 +170,7 @@ class OzonDebitPdfAdapter:
def parse(self, raw: bytes) -> StatementDraft: def parse(self, raw: bytes) -> StatementDraft:
pages_text: list[str] = [] 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: with open_pdf(raw) as pdf:
if not pdf.pages: if not pdf.pages:
raise UnsupportedStatementLayout("PDF has no pages") raise UnsupportedStatementLayout("PDF has no pages")
@@ -186,7 +186,7 @@ class OzonDebitPdfAdapter:
return self._draft_from_extracted(pages_text, pages_tables) return self._draft_from_extracted(pages_text, pages_tables)
def _draft_from_extracted( 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: ) -> StatementDraft:
page_zero = _ozon_normalize(pages_text[0]) page_zero = _ozon_normalize(pages_text[0])
if self.bank_marker not in page_zero or self.title_marker not in page_zero: 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") raise UnsupportedStatementLayout(f"Ozon {what} total line not found")
return match.group(1) 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: if cells is None:
raise UnsupportedStatementLayout("unexpected Ozon table row shape") raise UnsupportedStatementLayout("unexpected Ozon table row shape")
texts = [_ozon_cell(cell) for cell in cells] 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 import io
from datetime import date from datetime import date
from typing import Any
import pytest import pytest
from openpyxl import Workbook from openpyxl import Workbook
@@ -39,7 +40,7 @@ NBSP = "\u00a0"
def make_source_row(**overrides: object) -> SourceRow: def make_source_row(**overrides: object) -> SourceRow:
values: dict[str, object] = { values: dict[str, Any] = {
"external_id": None, "external_id": None,
"occurred_on": date(2026, 1, 1), "occurred_on": date(2026, 1, 1),
"posted_on": date(2026, 1, 2), "posted_on": date(2026, 1, 2),
@@ -52,11 +53,11 @@ def make_source_row(**overrides: object) -> SourceRow:
"row_ref": "row1", "row_ref": "row1",
} }
values.update(overrides) values.update(overrides)
return SourceRow(**values) # type: ignore[arg-type] return SourceRow(**values)
def make_draft(**overrides: object) -> StatementDraft: def make_draft(**overrides: object) -> StatementDraft:
values: dict[str, object] = { values: dict[str, Any] = {
"adapter_id": "alfa_debit_xlsx", "adapter_id": "alfa_debit_xlsx",
"parser_version": ALFA_DEBIT_XLSX_VERSION, "parser_version": ALFA_DEBIT_XLSX_VERSION,
"bank_code": "alfa", "bank_code": "alfa",
@@ -70,7 +71,7 @@ def make_draft(**overrides: object) -> StatementDraft:
"rows": (), "rows": (),
} }
values.update(overrides) values.update(overrides)
return StatementDraft(**values) # type: ignore[arg-type] return StatementDraft(**values)
class TestBaseContract: class TestBaseContract:
@@ -560,7 +561,7 @@ class TestAlfaSavingsRule:
None, None,
"416,90 RUR", "416,90 RUR",
] ]
savings_row = [ savings_row: list[object] = [
"03.09.2026", "03.09.2026",
"03.09.2026", "03.09.2026",
None, None,
@@ -603,7 +604,7 @@ class TestAlfaSavingsRule:
assert all(not row.savings_transfer for row in draft.rows) 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 = [ pages_text = [
"\n".join( "\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), occurred_on=date(2026, 5, 2),
category_id=others.id, 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") custom = create_category(db, space_id=space.id, user=user, kind="monthly", name="My shops")
codes = categorization.set_category_mcc_codes( codes = categorization.set_category_mcc_codes(
db, space_id=space.id, category_id=custom.id, codes=[5999, 5994] db, space_id=space.id, category_id=custom.id, codes=[5999, 5994]
) )
assert codes == [5994, 5999] 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 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 custom.archived = False
with pytest.raises(AppError) as exc: with pytest.raises(AppError) as exc:
categorization.set_category_mcc_codes( 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 import uuid
from datetime import date from datetime import date
from types import SimpleNamespace from types import SimpleNamespace
from typing import cast
import pytest import pytest
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.dependencies import RequestContext
from app.errors import AppError from app.errors import AppError
from app.identity.models import User from app.identity.models import User
from app.imports import router as imports_router 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() db.flush()
savings_account = get_savings_account(db, space_id=space.id) 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() operations = db.query(Operation).filter_by(space_id=space.id).all()
assert len(operations) == 1 assert len(operations) == 1
transfer = operations[0] transfer = operations[0]
@@ -233,6 +236,7 @@ def test_statement_mapping_rejects_system_account(db: Session) -> None:
user, space, account = seed(db) user, space, account = seed(db)
document, _run = make_document(db, space, account, user=user) document, _run = make_document(db, space, account, user=user)
savings_account = get_savings_account(db, space_id=space.id) savings_account = get_savings_account(db, space_id=space.id)
assert savings_account is not None
with pytest.raises(AppError) as exc: with pytest.raises(AppError) as exc:
service.set_review_confirmation( service.set_review_confirmation(
@@ -358,7 +362,7 @@ def test_unresolved_hold_counts_and_month_filter(db: Session) -> None:
assert service.unresolved_hold_counts( assert service.unresolved_hold_counts(
db, space_id=space.id, document_ids=[document.id], month=may db, space_id=space.id, document_ids=[document.id], month=may
) == {document.id: 1} ) == {document.id: 1}
context = SimpleNamespace(space=space) context = cast(RequestContext, SimpleNamespace(space=space))
july_items = imports_router.imports(db, context, month="2026-07") july_items = imports_router.imports(db, context, month="2026-07")
assert july_items[0]["id"] == document.id assert july_items[0]["id"] == document.id
assert july_items[0]["unresolvedHoldCount"] == 0 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: def test_confirm_savings_transfer_rejects_savings_statement_account(db: Session) -> None:
user, space, account = seed(db) user, space, account = seed(db)
savings_account = get_savings_account(db, space_id=space.id) 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, run = make_document(db, space, savings_account, user=user)
document.account_id = savings_account.id document.account_id = savings_account.id
add_record(db, document, run, description=SAVINGS_PHRASE) add_record(db, document, run, description=SAVINGS_PHRASE)
+8 -2
View File
@@ -2,10 +2,12 @@ import uuid
from contextlib import contextmanager from contextlib import contextmanager
from datetime import date from datetime import date
from types import SimpleNamespace from types import SimpleNamespace
from typing import cast
import pytest import pytest
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.dependencies import RequestContext
from app.errors import AppError from app.errors import AppError
from app.identity.models import User from app.identity.models import User
from app.imports import router as imports_router 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) 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() 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: 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.add(candidate)
db.flush() 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]["decision"] == "pending"
assert response["rows"][0]["candidates"] == [ assert response["rows"][0]["candidates"] == [
+2 -1
View File
@@ -20,6 +20,7 @@ Privacy contract (docs/parsers/private-fixture-gate.md):
import json import json
import os import os
from pathlib import Path from pathlib import Path
from typing import NoReturn
import pytest import pytest
@@ -75,7 +76,7 @@ def _check(ok: bool, name: str, what: str) -> None:
assert ok, f"{name}: {what} mismatch" 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) 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 postings[0].amount_kopecks == -25_000
assert account_balance(db, account) == 475_000 assert account_balance(db, account) == 475_000
event = db.query(AuditEvent).filter_by(event_type="account.balance_adjusted").one() 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["old_balance_kopecks"] == 500_000
assert event.detail["new_balance_kopecks"] == 475_000 assert event.detail["new_balance_kopecks"] == 475_000
assert event.detail["delta_kopecks"] == -25_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) convert_expense_to_savings(db, space_id=space.id, user=user, operation_id=expense.id)
assert exc.value.code == "LEDGER_SAVINGS_CONVERT_INVALID" 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( transfer = create_transfer(
db, db,
space_id=space.id, space_id=space.id,
user=user, user=user,
source_account_id=account.id, 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, amount_kopecks=1_000,
occurred_on=date(2026, 3, 6), 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": { "dependencies": {
"@fontsource/funnel-sans": "^5.3.0", "@fontsource/funnel-sans": "^5.3.0",
"@fontsource/geist-mono": "^5.3.0", "@fontsource/geist-mono": "^5.3.0",
"@fontsource/onest": "^5.3.1",
"@hookform/resolvers": "^5.1.0", "@hookform/resolvers": "^5.1.0",
"@tanstack/react-query": "^5.80.0", "@tanstack/react-query": "^5.80.0",
"i18next": "^25.2.0", "i18next": "^25.2.0",
+8
View File
@@ -14,6 +14,9 @@ importers:
'@fontsource/geist-mono': '@fontsource/geist-mono':
specifier: ^5.3.0 specifier: ^5.3.0
version: 5.3.0 version: 5.3.0
'@fontsource/onest':
specifier: ^5.3.1
version: 5.3.1
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^5.1.0 specifier: ^5.1.0
version: 5.9.1(react-hook-form@7.87.0(react@19.3.0))(zod@3.25.76) 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': '@fontsource/geist-mono@5.3.0':
resolution: {integrity: sha512-UtJ1BBBCVpMYdIcW7nEB45UAoAw5M53ZXs2t0ciPW+IokuAAIc56M8+kW5tXbRJCTpDw4XTtU7proT6NdQAHTg==} resolution: {integrity: sha512-UtJ1BBBCVpMYdIcW7nEB45UAoAw5M53ZXs2t0ciPW+IokuAAIc56M8+kW5tXbRJCTpDw4XTtU7proT6NdQAHTg==}
'@fontsource/onest@5.3.1':
resolution: {integrity: sha512-2uA9HUzQHBB8H7T2R6inmrnqPofB6+svATHM/huIG640duJr4rm+uanmAIheTTlbZvRC0ujMtf2T4U4RX51oEQ==}
'@hookform/resolvers@5.9.1': '@hookform/resolvers@5.9.1':
resolution: {integrity: sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==} resolution: {integrity: sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==}
peerDependencies: peerDependencies:
@@ -2461,6 +2467,8 @@ snapshots:
'@fontsource/geist-mono@5.3.0': {} '@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)': '@hookform/resolvers@5.9.1(react-hook-form@7.87.0(react@19.3.0))(zod@3.25.76)':
dependencies: dependencies:
'@standard-schema/utils': 0.3.0 '@standard-schema/utils': 0.3.0
+8
View File
@@ -17,6 +17,8 @@ import type {
CreateCategoryPayload, CreateCategoryPayload,
CreateOperationPayload, CreateOperationPayload,
ExportInfo, ExportInfo,
ExpectedIncome,
ExpectedIncomePayload,
ImportDetail, ImportDetail,
ImportSummary, ImportSummary,
LoginPayload, LoginPayload,
@@ -94,6 +96,12 @@ export const api = {
apiClient.post<CategoryDefinition>('/budget/categories', { json: payload }), apiClient.post<CategoryDefinition>('/budget/categories', { json: payload }),
updateCategoryDefinition: (id: string, payload: PatchCategoryDefinitionPayload) => updateCategoryDefinition: (id: string, payload: PatchCategoryDefinitionPayload) =>
apiClient.patch<CategoryDefinition>(`/budget/category-definitions/${id}`, { json: payload }), 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: { imports: {
list: (month?: string) => list: (month?: string) =>
+8
View File
@@ -190,10 +190,18 @@ export interface ExpectedIncome {
expectedOn: string expectedOn: string
amountKopecks: number amountKopecks: number
sourceName: string | null sourceName: string | null
note?: string | null
matchedKopecks: number matchedKopecks: number
status: ExpectedIncomeStatus status: ExpectedIncomeStatus
} }
export interface ExpectedIncomePayload {
expectedOn: string
amountKopecks: number
sourceName: string | null
note: string | null
}
export interface BudgetSummary { export interface BudgetSummary {
month: string month: string
status: BudgetStatus status: BudgetStatus
@@ -59,18 +59,20 @@ function CreateCategoryForm() {
return ( return (
<form className="manager-form" onSubmit={submit} noValidate> <form className="manager-form" onSubmit={submit} noValidate>
<TextField <div className="manager-form-fields">
label={t('common.name')} <TextField
error={errors.name?.message} label={t('common.name')}
{...register('name')} error={errors.name?.message}
/> {...register('name')}
<SelectField label={t('accounts.type')} error={errors.kind?.message} {...register('kind')}> />
{CREATE_KINDS.map((kind) => ( <SelectField label={t('accounts.type')} error={errors.kind?.message} {...register('kind')}>
<option key={kind} value={kind}> {CREATE_KINDS.map((kind) => (
{t(`budget.category.kind.${kind}`)} <option key={kind} value={kind}>
</option> {t(`budget.category.kind.${kind}`)}
))} </option>
</SelectField> ))}
</SelectField>
</div>
{watch('kind') === 'monthly' ? ( {watch('kind') === 'monthly' ? (
<TextField <TextField
label={t('budget.manager.mccCodes')} 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)) const MOBILE_NAV_ITEMS = NAV_ITEMS.filter((item) => MOBILE_NAV_ROUTES.includes(item.to))
export function AppLayout() { export function AppLayout() {
const { t } = useTranslation() const { t, i18n } = useTranslation()
const me = useMe() const me = useMe()
const logout = useLogout() const logout = useLogout()
const navigate = useNavigate() const navigate = useNavigate()
@@ -124,7 +124,7 @@ export function AppLayout() {
}) })
return ( return (
<div className="app-shell"> <div className="app-shell" lang={i18n.language}>
<a className="skip-link" href="#main"> <a className="skip-link" href="#main">
{t('common.skipToContent')} {t('common.skipToContent')}
</a> </a>
+13
View File
@@ -21,6 +21,19 @@ export function Modal({ open, onClose, title, children }: ModalProps) {
if (!open && dialog.open) dialog.close() if (!open && dialog.open) dialog.close()
}, [open]) }, [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 ( return (
<dialog <dialog
ref={dialogRef} ref={dialogRef}
+24 -1
View File
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '@/api/endpoints' import { api } from '@/api/endpoints'
import { invalidationRoots, queryKeys } from '@/api/queryKeys' 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 { useToast } from '@/components/ui/Toaster'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { describeApiError } from '@/lib/apiErrors' 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() { export function useCategoryDefinitions() {
return useQuery({ return useQuery({
queryKey: queryKeys.budgetCategoryDefinitions(), queryKey: queryKeys.budgetCategoryDefinitions(),
+10 -1
View File
@@ -37,7 +37,14 @@
"readyToClose": "No blockers. This month is ready to close.", "readyToClose": "No blockers. This month is ready to close.",
"importsNeedReview": "{{count}} statement needs review", "importsNeedReview": "{{count}} statement needs review",
"reviewBlockers": "Review blockers", "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": { "authShell": {
"statement": "Your household ledger, sealed by design.", "statement": "Your household ledger, sealed by design.",
@@ -65,6 +72,8 @@
"create": "Create", "create": "Create",
"dash": "—", "dash": "—",
"date": "Date", "date": "Date",
"delete": "Delete",
"edit": "Edit",
"empty": "Nothing here yet", "empty": "Nothing here yet",
"loading": "Loading…", "loading": "Loading…",
"name": "Name", "name": "Name",
+10 -1
View File
@@ -37,7 +37,14 @@
"readyToClose": "Блокировок нет. Месяц можно закрывать.", "readyToClose": "Блокировок нет. Месяц можно закрывать.",
"importsNeedReview": "Выписок на проверке: {{count}}", "importsNeedReview": "Выписок на проверке: {{count}}",
"reviewBlockers": "Разобрать блокировки", "reviewBlockers": "Разобрать блокировки",
"categoryPulse": "Состояние категорий" "categoryPulse": "Состояние категорий",
"noExpectedIncome": "Ожидаемых доходов пока нет. Добавьте прогноз на этот месяц.",
"addIncome": "Добавить доход",
"editIncome": "Изменить ожидаемый доход",
"incomeNote": "Примечание (необязательно)",
"incomeInvalid": "Укажите положительную сумму и дату в этом месяце.",
"deleteIncome": "Удалить ожидаемый доход",
"deleteIncomeConfirm": "Удалить этот доход из прогноза месяца?"
}, },
"authShell": { "authShell": {
"statement": "Семейный гроссбух, закрытый по замыслу.", "statement": "Семейный гроссбух, закрытый по замыслу.",
@@ -65,6 +72,8 @@
"create": "Создать", "create": "Создать",
"dash": "—", "dash": "—",
"date": "Дата", "date": "Дата",
"delete": "Удалить",
"edit": "Изменить",
"empty": "Пока ничего нет", "empty": "Пока ничего нет",
"loading": "Загрузка…", "loading": "Загрузка…",
"name": "Название", "name": "Название",
+8
View File
@@ -4,6 +4,14 @@ import '@fontsource/funnel-sans/400.css'
import '@fontsource/funnel-sans/500.css' import '@fontsource/funnel-sans/500.css'
import '@fontsource/funnel-sans/600.css' import '@fontsource/funnel-sans/600.css'
import '@fontsource/funnel-sans/700.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/400.css'
import '@fontsource/geist-mono/500.css' import '@fontsource/geist-mono/500.css'
import '@fontsource/geist-mono/600.css' import '@fontsource/geist-mono/600.css'
+17 -42
View File
@@ -293,47 +293,8 @@ export function BudgetPage() {
</dl> </dl>
<QueryGate query={categories}> <QueryGate query={categories}>
{() => ( {() => (
<> <div className="budget-workspace">
{summary.data && summary.data.expectedIncomes.length > 0 ? ( <div className="budget-category-column">
<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}
<section aria-label={t('budget.categories')} id="categories"> <section aria-label={t('budget.categories')} id="categories">
<div className="section-heading categories-heading"> <div className="section-heading categories-heading">
<h2 className="section-title">{t('budget.categories')}</h2> <h2 className="section-title">{t('budget.categories')}</h2>
@@ -377,7 +338,21 @@ export function BudgetPage() {
</table> </table>
</section> </section>
) : null} ) : 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> </QueryGate>
{planTarget ? ( {planTarget ? (
+114 -42
View File
@@ -1,14 +1,21 @@
import { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import type { FormEvent } from 'react'
import type { ExpectedIncome } from '@/api/types'
import { BudgetBlockers } from '@/components/budget/BudgetBlockers' 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 { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate' 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 { useImports } from '@/hooks/useImports'
import { categoryLabel } from '@/lib/categoryLabel' 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 { currentLocale } from '@/lib/locale'
import { formatMoney } from '@/lib/money' import { formatMoney, kopecksToInput, parseAmountToKopecks } from '@/lib/money'
export function OverviewPage() { export function OverviewPage() {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
@@ -16,10 +23,40 @@ export function OverviewPage() {
const month = currentMonth() const month = currentMonth()
const summary = useBudgetSummary(month) const summary = useBudgetSummary(month)
const categories = useBudgetCategories(month) const categories = useBudgetCategories(month)
const incomeMutation = useExpectedIncomeMutation(month)
const imports = useImports() 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 money = (value: number) => formatMoney(value, locale)
const attentionImports = (imports.data ?? []).filter((item) => item.status === 'review_required') 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 ( return (
<div className="overview-page"> <div className="overview-page">
<PageHeader <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> <div className="stat"><dt>{t('budget.spentTotal')}</dt><dd className="num">{money(data.spentTotalKopecks)}</dd><small>{t('overview.ofFunded')}</small></div>
</dl> </dl>
<div className="overview-workbench"> <div className="overview-workbench">
<section className="month-story"> <section className="overview-categories" aria-label={t('budget.categories')}>
<div className="section-heading"> <div className="section-heading"><h2>{t('budget.categories')}</h2><Link to="/budget">{t('overview.openBudget')}</Link></div>
<h2>{t('overview.monthStory')}</h2> <QueryGate query={categories}>
<span>{t('overview.liveState')}</span> {(items) => items.filter((item) => !item.archived).length > 0 ? (
</div> <table className="data-table">
<ol className="story-list"> <caption className="visually-hidden">{t('budget.categories')}</caption>
<li><span>{t('overview.poolAndIncome')}</span><strong className="num">{money(data.actualPoolKopecks + data.fundedTotalKopecks)}</strong></li> <thead><tr>
<li><span>{t('overview.funded')}</span><strong className="num neg">−{money(data.fundedTotalKopecks)}</strong></li> <th scope="col">{t('common.name')}</th>
<li><span>{t('overview.spent')}</span><strong className="num neg">−{money(data.spentTotalKopecks)}</strong></li> <th scope="col" className="num">{t('budget.planned')}</th>
<li><span>{t('overview.poolRemaining')}</span><strong className="num">{money(data.actualPoolKopecks)}</strong></li> <th scope="col" className="num">{t('budget.funded')}</th>
</ol> <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> </section>
<aside className="decision-panel"> <div className="overview-aside">
<div className="section-heading"><h2>{t('overview.closeReadiness')}</h2><span>{data.blockers.length} {t('overview.blockers')}</span></div> <section className="expected-income" aria-label={t('budget.expectedIncome')}>
{data.blockers.length > 0 ? ( <div className="section-heading">
<BudgetBlockers blockers={data.blockers} month={month} /> <h2>{t('budget.expectedIncome')}</h2>
) : <p>{t('overview.readyToClose')}</p>} {data.status === 'open' ? <Button variant="ghost" onClick={() => openIncomeEditor()}>{t('overview.addIncome')}</Button> : null}
{attentionImports.length > 0 ? <p>{t('overview.importsNeedReview', { count: attentionImports.length })}</p> : null} </div>
<Link className="btn btn-subtle" to="/review">{t('overview.reviewBlockers')}</Link> {data.expectedIncomes.length ? (
</aside> <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> </div>
</> </>
)} )}
</QueryGate> </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> <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> </div>
) )
} }
+46 -10
View File
@@ -17,7 +17,7 @@
--amber-soft: #fff0c7; --amber-soft: #fff0c7;
--red: #b42f25; --red: #b42f25;
--red-soft: #fbe2df; --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; --font-data: 'Geist Mono', 'SFMono-Regular', Consolas, monospace;
--sidebar: 238px; --sidebar: 238px;
} }
@@ -206,6 +206,11 @@ a {
text-decoration: none; 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:hover { color: var(--white); background: #202020; }
.nav-pill[aria-current='page'] { .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 { margin: 34px 0 14px; }
.categories-heading .section-title { margin: 0; } .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-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 { 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 { 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:last-child { border-right: 0; }
.manager-tabs .btn-primary { color: var(--white); background: var(--blue); } .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-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-main { display: grid; gap: 3px; min-width: 0; }
.manager-row-name { font-weight: 500; } .manager-row-name { overflow-wrap: anywhere; font-weight: 500; }
.manager-row-meta { color: #858580; font-family: var(--font-data); font-size: 10px; } .manager-row-meta { color: #aaa9a5; font-family: var(--font-data); font-size: 10px; overflow-wrap: anywhere; }
.manager-row-actions .btn { min-height: 30px; padding: 5px 8px; font-size: 11px; } .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 { .steps {
display: grid; 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; } .overview-page .page-header { margin-bottom: 20px; }
.mobile-pool { display: none; } .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; } .month-story { padding: 18px 0 0; }
.story-list { margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--line); } .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); } .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; } .mobile-pool strong { margin: 4px 0; font-size: 28px; }
.overview-stats .stat:first-child { display: none; } .overview-stats .stat:first-child { display: none; }
.overview-stats .stat:nth-child(4) { display: block; } .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; } .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 { border-color: #d4ad52; }
.decision-panel .section-heading h2, .decision-panel .section-heading h2,
.decision-panel p, .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 li { border-color: #e0c57f; }
.decision-panel .btn { color: var(--ink); background: transparent; border: 1px solid var(--ink); } .decision-panel .btn { color: var(--ink); background: transparent; border: 1px solid var(--ink); }
.decision-panel .blocker-link { color: var(--blue); } .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 { width: 100%; }
.manager-row-actions .btn { flex: 1; } .manager-row-actions .btn { flex: 1; }
.pulse-grid { display: block; border: 0; } .pulse-grid { display: block; border: 0; }
+7 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import userEvent from '@testing-library/user-event' 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 { api } from '@/api/endpoints'
import type { BudgetCategory, BudgetSummary, CategoryDefinition } from '@/api/types' import type { BudgetCategory, BudgetSummary, CategoryDefinition } from '@/api/types'
import { BudgetPage } from '@/pages/BudgetPage' import { BudgetPage } from '@/pages/BudgetPage'
@@ -142,6 +142,12 @@ describe('BudgetPage category manager lifecycle', () => {
expect(await screen.findByText('Category “Dining” created')).toBeInTheDocument() 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 () => { it('creates a category with multiple MCC codes', async () => {
createCategoryMock.mockResolvedValue(definition({ id: 'def-new', mccCodes: [5999, 5994] })) createCategoryMock.mockResolvedValue(definition({ id: 'def-new', mccCodes: [5999, 5994] }))
const { dialog } = await openManager() 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", "1": "budgeting/router.py",
"2": "db/models.py", "2": "Base",
"3": "AppError", "3": "main.py",
"4": "imports/router.py", "4": "test_import_savings.py",
"5": "alfa_xlsx.py", "5": "alfa_xlsx.py",
"6": "system-account-picker.test.tsx", "6": "system-account-picker.test.tsx",
"7": "types.ts", "7": "types.ts",
"8": "router.tsx", "8": "LoginPage.tsx",
"9": "ledger/router.py", "9": "ledger/router.py",
"10": "@testing-library/jest-dom", "10": "@testing-library/jest-dom",
"11": "compilerOptions", "11": "compilerOptions",
"12": "ReviewQueuePage.tsx", "12": "AnalyticsPage.tsx",
"13": "@testing-library/user-event", "13": "mcc.py",
"14": "devDependencies", "14": "devDependencies",
"15": "ledger/service.py", "15": "ledger/service.py",
"16": "dependencies", "16": "dependencies",
"17": "ImportDetailPage.tsx", "17": "useOperations.ts",
"18": "OperationsPage.tsx", "18": "OperationsPage.tsx",
"19": "Base", "19": "analytics/service.py",
"20": "apiErrors.ts", "20": "apiErrors.ts",
"21": "adapters/base.py", "21": "test_adapter_contracts.py",
"22": "BudgetPage.tsx", "22": "BudgetPage.tsx",
"23": "client.ts", "23": "client.ts",
"24": "fields.tsx", "24": "imports/service.py",
"25": "Docker Compose Development Stack", "25": "Docker Compose Development Stack",
"26": "Financial Space", "26": "Financial Space",
"27": "scripts", "27": "scripts",
"28": "@types/react", "28": "router.tsx",
"29": "General Pool", "29": "General Pool",
"30": "TestBaseContract", "30": "TestBaseContract",
"31": "Per-File Envelope Encryption", "31": "Per-File Envelope Encryption",
@@ -37,20 +37,20 @@
"35": "Closed Beta Launch Gates", "35": "Closed Beta Launch Gates",
"36": "package.json", "36": "package.json",
"37": "Financial Tracker MVP", "37": "Financial Tracker MVP",
"38": "test_adapter_contracts.py", "38": "TestRocketbankDebitContract",
"39": "generate-api.mjs", "39": "generate-api.mjs",
"40": "schema.d.ts", "40": "schema.d.ts",
"41": "test_api.py", "41": "test_api.py",
"42": "i18n-parity.test.ts", "42": "i18n-parity.test.ts",
"43": "Expected Income", "43": "Expected Income",
"44": "helpers.tsx", "44": "api",
"45": "ledger/models.py", "45": "AccountsPage.tsx",
"46": "category-manager.test.tsx", "46": "category-manager.test.tsx",
"47": "graphify", "47": "graphify",
"48": "@types/react-dom", "48": "index.ts",
"49": "typescript-eslint", "49": "typescript-eslint",
"50": "Ближайший край: накопления, категории и блокировки бюджета", "50": "Ближайший край: накопления, категории и блокировки бюджета",
"51": "vitest", "51": "RocketbankDebitPdfAdapter",
"52": "Favicon SVG (ledger lines icon)", "52": "Favicon SVG (ledger lines icon)",
"53": "init-app-role.sh", "53": "init-app-role.sh",
"54": "app/__init__.py", "54": "app/__init__.py",
@@ -62,36 +62,43 @@
"60": "vite.config.ts", "60": "vite.config.ts",
"61": "finance-backend", "61": "finance-backend",
"62": "LocalDirectoryBlobStore", "62": "LocalDirectoryBlobStore",
"63": "OzonDebitPdfAdapter", "63": "TestOzonDebitContract",
"64": "Gitea CI and real statement imports", "64": "Gitea CI and real statement imports",
"65": "20260918_0002_hold_lifecycle.py", "65": "20260918_0002_hold_lifecycle.py",
"66": "eslint-plugin-react-hooks", "66": "eslint-plugin-react-hooks",
"67": "import-hold-resolution.test.tsx", "67": "import-hold-resolution.test.tsx",
"68": "budgeting/service.py", "68": "budgeting/service.py",
"69": "TestAlfaDebitContract", "69": "TestAlfaDebitContract",
"70": "vite", "70": "AppLayout.tsx",
"71": "mcc.py", "71": "20260922_0003_savings_system.py",
"72": "TestAlfaSavingsRule", "72": "jsdom",
"73": "savings-convert-revert.test.tsx", "73": "savings-convert-revert.test.tsx",
"74": "get_settings", "74": "get_settings",
"75": "ColumnChart.tsx", "75": "ColumnChart.tsx",
"76": "dependencies.py", "76": "dependencies.py",
"77": "AGENTS.md", "77": "AGENTS.md",
"78": "graphify.js", "78": "graphify.js",
"79": "test_savings.py", "79": "Session",
"80": "test_category_definitions.py", "80": "test_category_definitions.py",
"81": "budget-plan.test.tsx", "81": "budget-plan.test.tsx",
"82": "imports/service.py", "82": "TestAlfaSavingsRule",
"83": "exports/service.py", "83": "exports/service.py",
"84": "test_imports.py", "84": "@testing-library/react",
"85": "identity/service.py", "85": "identity/service.py",
"86": "Session", "86": "test_import_holds.py",
"87": "get_or_create_period", "87": "get_or_create_period",
"88": "20260923_0005_optional_hold_reason.py", "88": "20260923_0005_optional_hold_reason.py",
"89": "test_import_savings.py", "89": "typescript",
"90": "conftest.py", "90": "useAuth.ts",
"91": "test_review_queue_batch.py", "91": "test_review_queue_batch.py",
"92": "review-queue-save.test.tsx", "92": "@vitejs/plugin-react",
"94": "account-adjustment.test.tsx", "93": "useImports.ts",
"99": "0001-fixes.md" "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", "1": "budgeting/router.py",
"2": "db/models.py", "2": "Base",
"3": "AppError", "3": "main.py",
"4": "imports/router.py", "4": "imports/service.py",
"5": "alfa_xlsx.py", "5": "alfa_xlsx.py",
"6": "system-account-picker.test.tsx", "6": "system-account-picker.test.tsx",
"7": "types.ts", "7": "types.ts",
@@ -11,25 +11,25 @@
"9": "ledger/router.py", "9": "ledger/router.py",
"10": "@testing-library/jest-dom", "10": "@testing-library/jest-dom",
"11": "compilerOptions", "11": "compilerOptions",
"12": "ReviewQueuePage.tsx", "12": "BudgetPage.tsx",
"13": "@testing-library/user-event", "13": "mcc.py",
"14": "devDependencies", "14": "devDependencies",
"15": "ledger/service.py", "15": "ledger/service.py",
"16": "dependencies", "16": "dependencies",
"17": "ImportDetailPage.tsx", "17": "useOperations.ts",
"18": "OperationsPage.tsx", "18": "OperationsPage.tsx",
"19": "Base", "19": "Session",
"20": "apiErrors.ts", "20": "apiErrors.ts",
"21": "adapters/base.py", "21": "UnsupportedStatementLayout",
"22": "BudgetPage.tsx", "22": "ImportDetailPage.tsx",
"23": "client.ts", "23": "client.ts",
"24": "fields.tsx", "24": "set_space_context",
"25": "Docker Compose Development Stack", "25": "Docker Compose Development Stack",
"26": "Financial Space", "26": "Financial Space",
"27": "scripts", "27": "scripts",
"28": "@types/react", "28": "ImportsPage.tsx",
"29": "General Pool", "29": "General Pool",
"30": "TestBaseContract", "30": "test_adapter_contracts.py",
"31": "Per-File Envelope Encryption", "31": "Per-File Envelope Encryption",
"32": "Operation", "32": "Operation",
"33": "Versioned Statement Imports", "33": "Versioned Statement Imports",
@@ -37,20 +37,20 @@
"35": "Closed Beta Launch Gates", "35": "Closed Beta Launch Gates",
"36": "package.json", "36": "package.json",
"37": "Financial Tracker MVP", "37": "Financial Tracker MVP",
"38": "test_adapter_contracts.py", "38": "TestRocketbankDebitContract",
"39": "generate-api.mjs", "39": "generate-api.mjs",
"40": "schema.d.ts", "40": "schema.d.ts",
"41": "test_api.py", "41": "test_api.py",
"42": "i18n-parity.test.ts", "42": "i18n-parity.test.ts",
"43": "Expected Income", "43": "Expected Income",
"44": "helpers.tsx", "44": "api",
"45": "ledger/models.py", "45": "fields.tsx",
"46": "category-manager.test.tsx", "46": "category-manager.test.tsx",
"47": "graphify", "47": "graphify",
"48": "@types/react-dom", "48": "BudgetBlockers.tsx",
"49": "typescript-eslint", "49": "typescript-eslint",
"50": "Ближайший край: накопления, категории и блокировки бюджета", "50": "Ближайший край: накопления, категории и блокировки бюджета",
"51": "vitest", "51": "UTCDateTime",
"52": "Favicon SVG (ledger lines icon)", "52": "Favicon SVG (ledger lines icon)",
"53": "init-app-role.sh", "53": "init-app-role.sh",
"54": "app/__init__.py", "54": "app/__init__.py",
@@ -62,36 +62,40 @@
"60": "vite.config.ts", "60": "vite.config.ts",
"61": "finance-backend", "61": "finance-backend",
"62": "LocalDirectoryBlobStore", "62": "LocalDirectoryBlobStore",
"63": "OzonDebitPdfAdapter", "63": "TestOzonDebitContract",
"64": "Gitea CI and real statement imports", "64": "Gitea CI and real statement imports",
"65": "20260918_0002_hold_lifecycle.py", "65": "20260918_0002_hold_lifecycle.py",
"66": "eslint-plugin-react-hooks", "66": "eslint-plugin-react-hooks",
"67": "import-hold-resolution.test.tsx", "67": "import-hold-resolution.test.tsx",
"68": "budgeting/service.py", "68": "budgeting/service.py",
"69": "TestAlfaDebitContract", "69": "TestAlfaDebitContract",
"70": "vite", "70": "celery_app.py",
"71": "mcc.py", "71": "20260922_0003_savings_system.py",
"72": "TestAlfaSavingsRule", "72": "jsdom",
"73": "savings-convert-revert.test.tsx", "73": "savings-convert-revert.test.tsx",
"74": "get_settings", "74": "get_settings",
"75": "ColumnChart.tsx", "75": "ColumnChart.tsx",
"76": "dependencies.py", "76": "spaces/router.py",
"77": "AGENTS.md", "77": "AGENTS.md",
"78": "graphify.js", "78": "graphify.js",
"79": "test_savings.py", "79": "AppError",
"80": "test_category_definitions.py", "80": "test_category_definitions.py",
"81": "budget-plan.test.tsx", "81": "budget-plan.test.tsx",
"82": "imports/service.py", "82": "spaces/service.py",
"83": "exports/service.py", "83": "exports/service.py",
"84": "test_imports.py", "84": "@testing-library/react",
"85": "identity/service.py", "85": "identity/service.py",
"86": "Session", "86": "test_import_holds.py",
"87": "get_or_create_period", "87": "get_or_create_period",
"88": "20260923_0005_optional_hold_reason.py", "88": "20260923_0005_optional_hold_reason.py",
"89": "test_import_savings.py", "89": "typescript",
"90": "conftest.py", "90": "conftest.py",
"91": "test_review_queue_batch.py", "91": "test_review_queue_batch.py",
"92": "review-queue-save.test.tsx", "92": "@vitejs/plugin-react",
"94": "account-adjustment.test.tsx", "93": "useImports.ts",
"95": "dependencies.py",
"96": "_login",
"97": "перевод_между_своими.md",
"98": "0002-fixes.md",
"99": "0001-fixes.md" "99": "0001-fixes.md"
} }
+173 -163
View File
@@ -1,25 +1,25 @@
# Graph Report - finance (2026-09-23) # Graph Report - finance (2026-09-23)
## Corpus Check ## Corpus Check
- 187 files · ~90,160 words - 190 files · ~91,840 words
- Verdict: corpus is large enough that graph structure adds value. - Verdict: corpus is large enough that graph structure adds value.
## Summary ## Summary
- 1667 nodes · 5182 edges · 95 communities (72 shown, 16 thin omitted) - 1702 nodes · 5362 edges · 99 communities (74 shown, 16 thin omitted)
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 91 edges (avg confidence: 0.93) - Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 96 edges (avg confidence: 0.93)
- Token cost: 0 input · 0 output - Token cost: 0 input · 0 output
## Graph Freshness ## 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 `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost). - Run `graphify update .` after code changes (no API cost).
## Community Hubs (Navigation) ## Community Hubs (Navigation)
- Category - Settings
- budgeting/router.py - budgeting/router.py
- db/models.py - Base
- AppError - main.py
- imports/router.py - imports/service.py
- alfa_xlsx.py - alfa_xlsx.py
- system-account-picker.test.tsx - system-account-picker.test.tsx
- types.ts - types.ts
@@ -27,25 +27,25 @@
- ledger/router.py - ledger/router.py
- @testing-library/jest-dom - @testing-library/jest-dom
- compilerOptions - compilerOptions
- ReviewQueuePage.tsx - BudgetPage.tsx
- @testing-library/user-event - mcc.py
- devDependencies - devDependencies
- ledger/service.py - ledger/service.py
- dependencies - dependencies
- ImportDetailPage.tsx - useOperations.ts
- OperationsPage.tsx - OperationsPage.tsx
- Base - Session
- apiErrors.ts - apiErrors.ts
- adapters/base.py - UnsupportedStatementLayout
- BudgetPage.tsx - ImportDetailPage.tsx
- client.ts - client.ts
- fields.tsx - set_space_context
- Docker Compose Development Stack - Docker Compose Development Stack
- Financial Space - Financial Space
- scripts - scripts
- @types/react - ImportsPage.tsx
- General Pool - General Pool
- TestBaseContract - test_adapter_contracts.py
- Per-File Envelope Encryption - Per-File Envelope Encryption
- Operation - Operation
- Versioned Statement Imports - Versioned Statement Imports
@@ -53,68 +53,70 @@
- Closed Beta Launch Gates - Closed Beta Launch Gates
- package.json - package.json
- Financial Tracker MVP - Financial Tracker MVP
- test_adapter_contracts.py - TestRocketbankDebitContract
- generate-api.mjs - generate-api.mjs
- schema.d.ts - schema.d.ts
- test_api.py - test_api.py
- i18n-parity.test.ts - i18n-parity.test.ts
- Expected Income - Expected Income
- helpers.tsx - api
- ledger/models.py - fields.tsx
- category-manager.test.tsx - category-manager.test.tsx
- graphify - graphify
- @types/react-dom - BudgetBlockers.tsx
- typescript-eslint - typescript-eslint
- Ближайший край: накопления, категории и блокировки бюджета - Ближайший край: накопления, категории и блокировки бюджета
- vitest - UTCDateTime
- Favicon SVG (ledger lines icon) - Favicon SVG (ledger lines icon)
- init-app-role.sh - init-app-role.sh
- Payee - Payee
- test_private_fixtures.py - test_private_fixtures.py
- finance-backend - finance-backend
- LocalDirectoryBlobStore - LocalDirectoryBlobStore
- OzonDebitPdfAdapter - TestOzonDebitContract
- Gitea CI and real statement imports - Gitea CI and real statement imports
- 20260918_0002_hold_lifecycle.py - 20260918_0002_hold_lifecycle.py
- eslint-plugin-react-hooks - eslint-plugin-react-hooks
- import-hold-resolution.test.tsx - import-hold-resolution.test.tsx
- budgeting/service.py - budgeting/service.py
- TestAlfaDebitContract - TestAlfaDebitContract
- vite - celery_app.py
- mcc.py - 20260922_0003_savings_system.py
- TestAlfaSavingsRule - jsdom
- savings-convert-revert.test.tsx - savings-convert-revert.test.tsx
- get_settings - get_settings
- ColumnChart.tsx - ColumnChart.tsx
- dependencies.py - spaces/router.py
- AGENTS.md - AGENTS.md
- test_savings.py - AppError
- test_category_definitions.py - test_category_definitions.py
- budget-plan.test.tsx - budget-plan.test.tsx
- imports/service.py - spaces/service.py
- exports/service.py - exports/service.py
- test_imports.py - @testing-library/react
- identity/service.py - identity/service.py
- Session - test_import_holds.py
- get_or_create_period - get_or_create_period
- 20260923_0005_optional_hold_reason.py - 20260923_0005_optional_hold_reason.py
- test_import_savings.py - typescript
- conftest.py - conftest.py
- test_review_queue_batch.py - test_review_queue_batch.py
- review-queue-save.test.tsx - @vitejs/plugin-react
- account-adjustment.test.tsx - useImports.ts
- dependencies.py
- _login
## God Nodes (most connected - your core abstractions) ## God Nodes (most connected - your core abstractions)
1. `Session` - 211 edges 1. `Session` - 221 edges
2. `AppError` - 72 edges 2. `AppError` - 76 edges
3. `Base` - 48 edges 3. `Base` - 48 edges
4. `conflict()` - 43 edges 4. `record()` - 44 edges
5. `get_settings()` - 40 edges 5. `conflict()` - 43 edges
6. `PkMixin` - 40 edges 6. `get_settings()` - 40 edges
7. `record()` - 39 edges 7. `PkMixin` - 40 edges
8. `TimestampMixin` - 38 edges 8. `get_or_create_period()` - 39 edges
9. `StatementDocument` - 36 edges 9. `TimestampMixin` - 38 edges
10. `create_expense()` - 36 edges 10. `bad_request()` - 36 edges
## Surprising Connections (you probably didn't know these) ## Surprising Connections (you probably didn't know these)
- `Typed Same-Origin API Client` --conceptually_related_to--> `Same-Origin Reverse Proxy` [INFERRED] - `Typed Same-Origin API Client` --conceptually_related_to--> `Same-Origin Reverse Proxy` [INFERRED]
@@ -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] - **Financial Space Tenancy Boundary** — context_financial_space, context_membership, docs_adr_0001_financial_space_ownership_and_rls_defense_in_depth_authorization, docs_adr_0001_financial_space_ownership_and_rls_transaction_local_rls, docs_adr_0001_financial_space_ownership_and_rls_cross_space_integrity [EXTRACTED 1.00]
- **Safe Statement Import Flow** — context_statement_import, context_parse_run, context_source_record, docs_adr_0003_versioned_statement_imports_immutable_statement_document, docs_adr_0003_versioned_statement_imports_conservative_deduplication, docs_adr_0003_versioned_statement_imports_atomic_confirm_revert [EXTRACTED 1.00] - **Safe Statement Import Flow** — context_statement_import, context_parse_run, context_source_record, docs_adr_0003_versioned_statement_imports_immutable_statement_document, docs_adr_0003_versioned_statement_imports_conservative_deduplication, docs_adr_0003_versioned_statement_imports_atomic_confirm_revert [EXTRACTED 1.00]
## Communities (95 total, 16 thin omitted) ## Communities (99 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)
### Community 1 - "budgeting/router.py" ### Community 1 - "budgeting/router.py"
Cohesion: 0.11 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 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" ### Community 4 - "imports/service.py"
Cohesion: 0.19 Cohesion: 0.06
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) Nodes (105): datetime, utcnow(), RequestContext, magic_kind(), ParseRun, Immutable extracted bank fact with document origin coordinates., SourceRecord, StatementDocument (+97 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 5 - "alfa_xlsx.py" ### Community 5 - "alfa_xlsx.py"
Cohesion: 0.12 Cohesion: 0.15
Nodes (32): AlfaCreditXlsxAdapter, AlfaDebitXlsxAdapter, _AlfaHeaderMeta, AlfaXlsxAdapterBase, _cell_str(), _debit_find_header(), _debit_header_metadata(), _debit_header_value() (+24 more) 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" ### Community 6 - "system-account-picker.test.tsx"
Cohesion: 0.11 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" ### Community 7 - "types.ts"
Cohesion: 0.08 Cohesion: 0.07
Nodes (35): AccountStatus, AccountType, AdjustBalancePayload, BalanceItem, BalancesReport, BudgetStatus, BudgetWarning, CashFlowMonth (+27 more) Nodes (38): AcceptInvitePayload, AccountStatus, AccountType, AdjustBalancePayload, BalanceItem, BalancesReport, BudgetStatus, BudgetWarning (+30 more)
### Community 8 - "router.tsx" ### Community 8 - "router.tsx"
Cohesion: 0.07 Cohesion: 0.08
Nodes (43): AcceptInvitePayload, LoginPayload, PasswordResetPayload, AcceptInvitePage, AccountsPage, AnalyticsPage, BudgetPage, ImportDetailPage (+35 more) Nodes (45): AcceptInvitePage, AccountsPage, AnalyticsPage, BudgetPage, LoginPage, OperationsPage, OverviewPage, RequireAuth() (+37 more)
### Community 9 - "ledger/router.py" ### Community 9 - "ledger/router.py"
Cohesion: 0.15 Cohesion: 0.15
@@ -182,53 +180,57 @@ Nodes (38): account_json(), AccountBody, accounts(), adjust_balance(), AdjustBal
Cohesion: 0.07 Cohesion: 0.07
Nodes (28): compilerOptions, baseUrl, esModuleInterop, forceConsistentCasingInFileNames, isolatedModules, jsx, lib, module (+20 more) Nodes (28): compilerOptions, baseUrl, esModuleInterop, forceConsistentCasingInFileNames, isolatedModules, jsx, lib, module (+20 more)
### Community 12 - "ReviewQueuePage.tsx" ### Community 12 - "BudgetPage.tsx"
Cohesion: 0.09 Cohesion: 0.11
Nodes (43): NotFoundPage, PlanFactBars(), PlanFactBarsProps, PlanFactRow, EmptyState(), EmptyStateProps, MonthSwitcher(), MonthSwitcherProps (+35 more) 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" ### Community 14 - "devDependencies"
Cohesion: 0.10 Cohesion: 0.09
Nodes (21): eslint, @eslint/js, eslint-plugin-jsx-a11y, devDependencies, eslint, @eslint/js, eslint-plugin-jsx-a11y, globals (+13 more) 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" ### Community 15 - "ledger/service.py"
Cohesion: 0.13 Cohesion: 0.15
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) 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" ### 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 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" ### Community 18 - "OperationsPage.tsx"
Cohesion: 0.11 Cohesion: 0.12
Nodes (46): CategorizeBatchPayload, CreateOperationPayload, isSystemAccount(), Locale, useAccounts(), useActiveAccounts(), useUserAccounts(), useCategorizeBatch() (+38 more) Nodes (40): Account, isSystemAccount(), Operation, useAccounts(), useActiveAccounts(), useAdjustBalance(), useArchiveAccount(), useCreateAccount() (+32 more)
### Community 19 - "Base" ### Community 19 - "Session"
Cohesion: 0.15 Cohesion: 0.12
Nodes (22): downgrade(), upgrade(), BankCategoryMapping, CategorizationRule, CategoryMcc, IncomeSource, MccCode, Payee (+14 more) Nodes (33): balances(), cash_flow(), category_spend(), plan_fact(), date, UUID, _balance_until(), ensure_credit_reserve_category() (+25 more)
### Community 20 - "apiErrors.ts" ### Community 20 - "apiErrors.ts"
Cohesion: 0.09 Cohesion: 0.11
Nodes (24): isApiError(), BudgetBlocker, App(), queryClient, AppRoutes(), blockerCta(), blockerText(), BudgetBlockers() (+16 more) Nodes (19): isApiError(), Locale, App(), queryClient, AppRoutes(), LangSwitcher(), ToastApi, ToastContext (+11 more)
### Community 21 - "adapters/base.py" ### Community 21 - "UnsupportedStatementLayout"
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"
Cohesion: 0.14 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" ### Community 23 - "client.ts"
Cohesion: 0.26 Cohesion: 0.26
Nodes (10): apiClient, ApiError, buildUrl(), ErrorEnvelope, parseErrorEnvelope(), QueryValue, readCsrfToken(), request() (+2 more) Nodes (10): apiClient, ApiError, buildUrl(), ErrorEnvelope, parseErrorEnvelope(), QueryValue, readCsrfToken(), request() (+2 more)
### Community 24 - "fields.tsx" ### Community 24 - "set_space_context"
Cohesion: 0.21 Cohesion: 0.15
Nodes (11): FieldChrome, InputProps, MoneyField(), MoneyFieldProps, SelectField(), SelectFieldProps, SelectProps, TextareaField() (+3 more) 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" ### Community 25 - "Docker Compose Development Stack"
Cohesion: 0.29 Cohesion: 0.29
@@ -242,13 +244,17 @@ Nodes (12): Credit Reserve, Financial Account, Financial Space, Membership, User
Cohesion: 0.22 Cohesion: 0.22
Nodes (9): scripts, api:generate, build, dev, lint, preview, test, test:watch (+1 more) 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" ### Community 29 - "General Pool"
Cohesion: 0.29 Cohesion: 0.29
Nodes (8): Budget Period, Funding, General Pool, Monthly Category, Plan Assignment, Savings Envelope, Immutable Closure Snapshots, Plan and Funding Separation Nodes (8): Budget Period, Funding, General Pool, Monthly Category, Plan Assignment, Savings Envelope, Immutable Closure Snapshots, Plan and Funding Separation
### Community 30 - "TestBaseContract" ### Community 30 - "test_adapter_contracts.py"
Cohesion: 0.21 Cohesion: 0.11
Nodes (3): make_draft(), make_source_row(), TestBaseContract 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" ### Community 31 - "Per-File Envelope Encryption"
Cohesion: 0.29 Cohesion: 0.29
@@ -278,9 +284,9 @@ Nodes (4): name, private, type, version
Cohesion: 0.18 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) 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" ### Community 38 - "TestRocketbankDebitContract"
Cohesion: 0.13 Cohesion: 0.18
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 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" ### Community 39 - "generate-api.mjs"
Cohesion: 0.40 Cohesion: 0.40
@@ -294,13 +300,13 @@ Nodes (5): components, $defs, operations, paths, webhooks
Cohesion: 0.67 Cohesion: 0.67
Nodes (3): TestClient, test_invite_login_and_authenticated_space(), test_openapi_contains_critical_paths() Nodes (3): TestClient, test_invite_login_and_authenticated_space(), test_openapi_contains_critical_paths()
### Community 44 - "helpers.tsx" ### Community 44 - "api"
Cohesion: 0.18 Cohesion: 0.08
Nodes (11): User, DESKTOP_NAV, meMock, MOBILE_NAV, user, createTestQueryClient(), renderWithProviders(), loginMock (+3 more) Nodes (23): api, AdjustBalanceResult, User, adjustBalanceMock, listMock, createMock, listMock, DESKTOP_NAV (+15 more)
### Community 45 - "ledger/models.py" ### Community 45 - "fields.tsx"
Cohesion: 0.26 Cohesion: 0.21
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) Nodes (11): FieldChrome, InputProps, MoneyField(), MoneyFieldProps, SelectField(), SelectFieldProps, SelectProps, TextareaField() (+3 more)
### Community 46 - "category-manager.test.tsx" ### Community 46 - "category-manager.test.tsx"
Cohesion: 0.14 Cohesion: 0.14
@@ -310,21 +316,29 @@ Nodes (12): CategoryDefinition, categoriesMock, createCategoryMock, definitions,
Cohesion: 0.17 Cohesion: 0.17
Nodes (11): command, enabled, timeout, type, mcp, graphify, plugin, $schema (+3 more) 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 - "Ближайший край: накопления, категории и блокировки бюджета" ### Community 50 - "Ближайший край: накопления, категории и блокировки бюджета"
Cohesion: 0.10 Cohesion: 0.10
Nodes (20): 1. Доменная основа накоплений, 2. Альфа debit: «Копилка для сдачи», 3. Управление категориями, 4. Понятные и устранимые блокировки, Backend-контракт, Frontend, Ближайший край: накопления, категории и блокировки бюджета, Бюджетная семантика (+12 more) Nodes (20): 1. Доменная основа накоплений, 2. Альфа debit: «Копилка для сдачи», 3. Управление категориями, 4. Понятные и устранимые блокировки, Backend-контракт, Frontend, Ближайший край: накопления, категории и блокировки бюджета, Бюджетная семантика (+12 more)
### Community 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" ### Community 58 - "test_private_fixtures.py"
Cohesion: 0.10 Cohesion: 0.13
Nodes (24): Protocol, StatementAdapter, detect_adapter(), Any, Reconcile parsed rows against declared bank controls with stable diagnostics.…, _reconciliation_summary(), _check(), _fail() (+16 more) 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" ### Community 62 - "LocalDirectoryBlobStore"
Cohesion: 0.39 Cohesion: 0.39
Nodes (3): LocalDirectoryBlobStore, Path, S3-neutral local store; production swaps in an S3-compatible client. Nodes (3): LocalDirectoryBlobStore, Path, S3-neutral local store; production swaps in an S3-compatible client.
### Community 63 - "OzonDebitPdfAdapter" ### Community 63 - "TestOzonDebitContract"
Cohesion: 0.18 Cohesion: 0.15
Nodes (6): _ozon_normalize(), OzonDebitPdfAdapter, Ozon debit statement contract, evidenced against the real document. A 'Справка…, ozon_extracted(), TestOzonDebitContract, Pattern 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" ### Community 64 - "Gitea CI and real statement imports"
Cohesion: 0.29 Cohesion: 0.29
@@ -335,40 +349,40 @@ Cohesion: 0.80
Nodes (4): downgrade(), _has_check_constraint(), _has_column(), upgrade() Nodes (4): downgrade(), _has_check_constraint(), _has_column(), upgrade()
### Community 67 - "import-hold-resolution.test.tsx" ### Community 67 - "import-hold-resolution.test.tsx"
Cohesion: 0.15 Cohesion: 0.17
Nodes (11): ImportDetail, ImportRow, ImportSummary, confirmMock, detailFixture(), getImportMock, postedRow(), getImportMock (+3 more) Nodes (9): ImportRow, confirmMock, detailFixture(), getImportMock, postedRow(), getImportMock, resolvedHold, resolveHoldMock (+1 more)
### Community 68 - "budgeting/service.py" ### Community 68 - "budgeting/service.py"
Cohesion: 0.16 Cohesion: 0.15
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) 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" ### Community 69 - "TestAlfaDebitContract"
Cohesion: 0.18 Cohesion: 0.11
Nodes (3): alfa_workbook_bytes(), synthetic_alfa_rows(), TestAlfaDebitContract 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" ### Community 71 - "20260922_0003_savings_system.py"
Cohesion: 0.14 Cohesion: 0.32
Nodes (23): _create_tenant_policy(), _default_keys(), downgrade(), _has_table(), _space_ids(), upgrade(), _default_categories(), default_category_system_keys() (+15 more) 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" ### Community 73 - "savings-convert-revert.test.tsx"
Cohesion: 0.20 Cohesion: 0.20
Nodes (6): budgetCategoriesMock, listAccountsMock, listOperationsMock, operations, revertMock, savingsTransferMock Nodes (6): budgetCategoriesMock, listAccountsMock, listOperationsMock, operations, revertMock, savingsTransferMock
### Community 74 - "get_settings" ### Community 74 - "get_settings"
Cohesion: 0.12 Cohesion: 0.16
Nodes (30): get_settings(), Settings, send_token_email(), accept_invite(), AcceptInviteBody, confirm_password_reset(), create_invite(), InviteBody (+22 more) Nodes (26): get_settings(), send_token_email(), accept_invite(), AcceptInviteBody, confirm_password_reset(), create_invite(), InviteBody, login() (+18 more)
### Community 75 - "ColumnChart.tsx" ### Community 75 - "ColumnChart.tsx"
Cohesion: 0.24 Cohesion: 0.24
Nodes (7): CashFlowColumn, ColumnChart(), ColumnChartProps, BarRow, HorizontalBars(), HorizontalBarsProps, rows Nodes (7): CashFlowColumn, ColumnChart(), ColumnChartProps, BarRow, HorizontalBars(), HorizontalBarsProps, rows
### Community 76 - "dependencies.py" ### Community 76 - "spaces/router.py"
Cohesion: 0.09 Cohesion: 0.19
Nodes (39): current_context(), current_session(), alias, Context, CurrentSession, Db, Header, User (+31 more) Nodes (20): forbidden(), cancel_deletion(), current(), deletion_status(), DeletionBody, invite_member(), MemberInviteBody, alias (+12 more)
### Community 79 - "test_savings.py" ### Community 79 - "AppError"
Cohesion: 0.14 Cohesion: 0.12
Nodes (33): adjust_balance(), convert_expense_to_savings(), ensure_savings_resources(), Account, date, Operation, User, UUID (+25 more) 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" ### Community 80 - "test_category_definitions.py"
Cohesion: 0.50 Cohesion: 0.50
@@ -378,38 +392,30 @@ Nodes (8): bootstrap(), TestClient, HTTP contract for category definitions, syst
Cohesion: 0.25 Cohesion: 0.25
Nodes (7): BudgetSummary, categories, categoriesMock, month, summary, summaryMock, updatePlanMock Nodes (7): BudgetSummary, categories, categoriesMock, month, summary, summaryMock, updatePlanMock
### Community 82 - "imports/service.py" ### Community 82 - "spaces/service.py"
Cohesion: 0.18 Cohesion: 0.20
Nodes (29): Any, record(), utcnow(), conflict(), not_found(), magic_kind(), _apply_hold_matches(), _category_id_for_source() (+21 more) 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" ### Community 83 - "exports/service.py"
Cohesion: 0.13 Cohesion: 0.11
Nodes (19): DataExport, build_export(), consume_export(), _csv_bytes(), get_export(), _json_rows(), purge_due_spaces(), datetime (+11 more) Nodes (22): DataExport, build_export(), consume_export(), _csv_bytes(), get_export(), _json_rows(), purge_due_spaces(), datetime (+14 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)
### Community 85 - "identity/service.py" ### Community 85 - "identity/service.py"
Cohesion: 0.26 Cohesion: 0.30
Nodes (20): bad_request(), unauthorized(), accept_invitation(), authenticate(), create_invitation(), create_password_reset(), create_session(), get_active_session() (+12 more) Nodes (16): accept_invitation(), authenticate(), create_invitation(), create_password_reset(), create_session(), normalize_email(), User, UUID (+8 more)
### Community 86 - "Session" ### Community 86 - "test_import_holds.py"
Cohesion: 0.16 Cohesion: 0.13
Nodes (41): Session, add_record(), _draft(), hold_document(), make_document(), date, Path, TestClient (+33 more) Nodes (41): normalize_description(), date, record_fingerprint(), add_record(), _draft(), hold_document(), make_document(), date (+33 more)
### Community 87 - "get_or_create_period" ### Community 87 - "get_or_create_period"
Cohesion: 0.34 Cohesion: 0.21
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) 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" ### Community 88 - "20260923_0005_optional_hold_reason.py"
Cohesion: 0.83 Cohesion: 0.83
Nodes (3): downgrade(), _reason_constraint_name(), upgrade() 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" ### Community 90 - "conftest.py"
Cohesion: 0.27 Cohesion: 0.27
Nodes (6): client(), db(), db_factory(), fixture, sessionmaker, TestClient 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 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) 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.25 Cohesion: 0.26
Nodes (6): categories, categoriesMock, categorizeBatchMock, categorizeMock, importsListMock, operationsListMock Nodes (10): ConfirmImportPayload, ImportStatus, SaveRowDecisionPayload, useImport(), ACTIVE_STATUSES, IMPORT_POLL_MS, importListPollInterval(), importPollInterval() (+2 more)
### Community 94 - "account-adjustment.test.tsx" ### Community 95 - "dependencies.py"
Cohesion: 0.20 Cohesion: 0.17
Nodes (5): Account, adjustBalanceMock, listMock, createMock, listMock 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 ## 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) - **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; 468 node(s) total have ≤1 connection when file, concept and rationale nodes are included.) These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 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. - **16 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions ## Suggested Questions
_Questions this graph is uniquely positioned to answer:_ _Questions this graph is uniquely positioned to answer:_
- **Why does `Session` connect `Session` to `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`?** - **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.067) - this node is a cross-community bridge._ _High betweenness centrality (0.071) - 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`?** - **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.049) - this node is a cross-community bridge._ _High betweenness centrality (0.047) - this node is a cross-community bridge._
- **Why does `TestRocketbankDebitContract` connect `test_adapter_contracts.py` to `AppError`, `adapters/base.py`?** - **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._ _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?** - **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._ _`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?** - **Are the 42 inferred relationships involving `AppError` (e.g. with `request_export()` and `login()`) actually correct?**
_`AppError` has 39 INFERRED edges - model-reasoned connections that need verification._ _`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?** - **Are the 3 inferred relationships involving `Base` (e.g. with `downgrade()` and `upgrade()`) actually correct?**
_`Base` has 3 INFERRED edges - model-reasoned connections that need verification._ _`Base` has 3 INFERRED edges - model-reasoned connections that need verification._
- **What connects `$schema`, `file:///home/k1nq/repos/finance/.kilo/plugins/graphify.js`, `type` to the rest of the system?** - **What connects `$schema`, `file:///home/k1nq/repos/finance/.kilo/plugins/graphify.js`, `type` to the rest of the system?**
_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": "" "semantic_hash": ""
}, },
"backend/app/analytics/service.py": { "backend/app/analytics/service.py": {
"mtime": 1790061159.3943744, "mtime": 1790158886.5589485,
"seen": 1790096479.536424, "seen": 1790164233.4191206,
"ast_hash": "58ac24a62b4cf8a986527f79d456127d", "ast_hash": "3d87f3a80dc16a7781b5b802a0a66039",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/audit/__init__.py": { "backend/app/audit/__init__.py": {
@@ -66,15 +66,15 @@
"semantic_hash": "039081cab3d91704e8f8a7152a981304" "semantic_hash": "039081cab3d91704e8f8a7152a981304"
}, },
"backend/app/budgeting/router.py": { "backend/app/budgeting/router.py": {
"mtime": 1790140570.7235522, "mtime": 1790164219.7100794,
"seen": 1790140596.4645138, "seen": 1790164233.4191263,
"ast_hash": "5c8ea9e79dbe5450c2891c40931f0f4d", "ast_hash": "e4c01ff67e84d606da20520c634aada1",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/budgeting/service.py": { "backend/app/budgeting/service.py": {
"mtime": 1790139967.779716, "mtime": 1790164039.9056563,
"seen": 1790140596.4645145, "seen": 1790164233.4191272,
"ast_hash": "8b936c8c80b35123143fcc6bed627172", "ast_hash": "9856f61057a9ddc569b2d8ae5d78f688",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/categorization/__init__.py": { "backend/app/categorization/__init__.py": {
@@ -222,9 +222,9 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/imports/adapters/ozon_pdf.py": { "backend/app/imports/adapters/ozon_pdf.py": {
"mtime": 1789733868.836172, "mtime": 1790163956.5875351,
"seen": 1789735433.158129, "seen": 1790164233.4191487,
"ast_hash": "dfa6dc9ccf2c655e6c2eeaceea0cf06a", "ast_hash": "a0aa31d71ab7a45a479178cd630d8b05",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/imports/adapters/rocketbank_pdf.py": { "backend/app/imports/adapters/rocketbank_pdf.py": {
@@ -288,15 +288,15 @@
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e" "semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
}, },
"backend/app/ledger/models.py": { "backend/app/ledger/models.py": {
"mtime": 1790060833.8770478, "mtime": 1790158886.5589485,
"seen": 1790096479.5364609, "seen": 1790164233.4191582,
"ast_hash": "86891c404082d8d2c84214f31d8616ff", "ast_hash": "38e4db3531f1672e57e86ec36d7b1ed7",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/ledger/router.py": { "backend/app/ledger/router.py": {
"mtime": 1790155278.0175776, "mtime": 1790158886.5589485,
"seen": 1790155776.860304, "seen": 1790164233.419159,
"ast_hash": "24b2565478f257f38561fabff646130a", "ast_hash": "37c07d8487d317af02a3762099bce24b",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/ledger/service.py": { "backend/app/ledger/service.py": {
@@ -372,15 +372,15 @@
"semantic_hash": "bfec0778131788a91a0bc5c9ec2ad8ba" "semantic_hash": "bfec0778131788a91a0bc5c9ec2ad8ba"
}, },
"backend/tests/test_budget.py": { "backend/tests/test_budget.py": {
"mtime": 1790141279.1849978, "mtime": 1790164269.4717507,
"seen": 1790141340.361456, "seen": 1790164324.1326215,
"ast_hash": "81578f618d0758c7f9e2f00c921f2594", "ast_hash": "6d01ee3dd3fdc66f65ba85ba6a3909fc",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_imports.py": { "backend/tests/test_imports.py": {
"mtime": 1790140440.029933, "mtime": 1790164055.474866,
"seen": 1790140596.4645612, "seen": 1790164233.4191756,
"ast_hash": "9e568b07fa4d77eb55c36ef748fb0f9b", "ast_hash": "580d49a52a4cfc72ce23b21cc983f833",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_ledger_properties.py": { "backend/tests/test_ledger_properties.py": {
@@ -402,9 +402,9 @@
"semantic_hash": "ed54799a921d10394f826893acb46876" "semantic_hash": "ed54799a921d10394f826893acb46876"
}, },
"frontend/package.json": { "frontend/package.json": {
"mtime": 1790055350.366911, "mtime": 1790164273.1148,
"seen": 1790096479.5364823, "seen": 1790164324.1326368,
"ast_hash": "172e2880b6084c1fc423ea759b99280a", "ast_hash": "6e94a9e65d8bbac0083b73acea30af23",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/scripts/generate-api.mjs": { "frontend/scripts/generate-api.mjs": {
@@ -672,9 +672,9 @@
"semantic_hash": "53e014a77ff3070820a83458af08a5fd" "semantic_hash": "53e014a77ff3070820a83458af08a5fd"
}, },
"frontend/src/pages/OperationsPage.tsx": { "frontend/src/pages/OperationsPage.tsx": {
"mtime": 1790155897.8159056, "mtime": 1790157347.0632977,
"seen": 1790156681.728144, "seen": 1790157380.8711965,
"ast_hash": "5fd3db4e96df49497a42d9040b02ff58", "ast_hash": "79e817d97de60b1c3aa88a69312089c4",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/pages/ResetPasswordPage.tsx": { "frontend/src/pages/ResetPasswordPage.tsx": {
@@ -888,9 +888,9 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_adapter_contracts.py": { "backend/tests/test_adapter_contracts.py": {
"mtime": 1790061478.1675427, "mtime": 1790164118.1117096,
"seen": 1790096479.5364716, "seen": 1790164233.419169,
"ast_hash": "9d446a10c953a14c8d4962eff50f5b97", "ast_hash": "a9bc17e60e6795f552aba5dee534c9ba",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_import_holds.py": { "backend/tests/test_import_holds.py": {
@@ -900,9 +900,9 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_private_fixtures.py": { "backend/tests/test_private_fixtures.py": {
"mtime": 1789737822.4871244, "mtime": 1790164067.7670314,
"seen": 1789737916.7915628, "seen": 1790164233.419178,
"ast_hash": "f14cd118f1e8d6b5088536d50d2b965a", "ast_hash": "268632b4cd05c2538fac5f22b28480ba",
"semantic_hash": "" "semantic_hash": ""
}, },
".gitea/workflows/ci.yml": { ".gitea/workflows/ci.yml": {
@@ -948,15 +948,15 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/alembic/versions/20260922_0003_savings_system.py": { "backend/alembic/versions/20260922_0003_savings_system.py": {
"mtime": 1790078031.6120079, "mtime": 1790158886.5589485,
"seen": 1790096479.536419, "seen": 1790164233.4191144,
"ast_hash": "12acc1a6c82583b06da88974500498a7", "ast_hash": "94f0fec8f2edfdefd3d00ece470867c2",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/ledger/savings.py": { "backend/app/ledger/savings.py": {
"mtime": 1790097195.1310222, "mtime": 1790158886.5589485,
"seen": 1790129548.0529978, "seen": 1790164233.4191597,
"ast_hash": "5367de3a6184c6df07170721a6a0fcf8", "ast_hash": "8b17828223421e8062069382024a7ae3",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_budget_savings.py": { "backend/tests/test_budget_savings.py": {
@@ -972,15 +972,15 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_import_savings.py": { "backend/tests/test_import_savings.py": {
"mtime": 1790129378.2103765, "mtime": 1790164033.0365639,
"seen": 1790129548.0530114, "seen": 1790164233.419175,
"ast_hash": "7bf7f4500852520a2a975a452911af1c", "ast_hash": "aee4dbfbada3e53720c7c5dd64014c26",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_savings.py": { "backend/tests/test_savings.py": {
"mtime": 1790097195.1310337, "mtime": 1790164079.4361887,
"seen": 1790129548.053015, "seen": 1790164233.4191797,
"ast_hash": "c5eb8af5219e2f5cc153d09c0c4a37a1", "ast_hash": "db9a0c71ac67e3c104a4fcd9ecb83062",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/components/budget/BudgetBlockers.tsx": { "frontend/src/components/budget/BudgetBlockers.tsx": {
@@ -1122,9 +1122,9 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_review_queue_batch.py": { "backend/tests/test_review_queue_batch.py": {
"mtime": 1790155761.6000907, "mtime": 1790158886.5589485,
"seen": 1790155776.860325, "seen": 1790164233.419179,
"ast_hash": "ca92b0417d73b849012c90803a4a6885", "ast_hash": "0074192ea42734bcc988792b5de3624b",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/test/review-queue-save.test.tsx": { "frontend/src/test/review-queue-save.test.tsx": {
@@ -1146,9 +1146,27 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/test/ledger-view.test.tsx": { "frontend/src/test/ledger-view.test.tsx": {
"mtime": 1790156126.5019412, "mtime": 1790157401.4480782,
"seen": 1790156681.7281582, "seen": 1790157424.7858286,
"ast_hash": "dbd335027673613bbee4cb47aa4b6983", "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": "" "semantic_hash": ""
} }
} }
+184 -155
View File
@@ -1,49 +1,49 @@
# Graph Report - finance (2026-09-23) # Graph Report - finance (2026-09-23)
## Corpus Check ## Corpus Check
- 187 files · ~90,166 words - 191 files · ~92,817 words
- Verdict: corpus is large enough that graph structure adds value. - Verdict: corpus is large enough that graph structure adds value.
## Summary ## Summary
- 1667 nodes · 5182 edges · 95 communities (72 shown, 16 thin omitted) - 1708 nodes · 5406 edges · 102 communities (78 shown, 15 thin omitted)
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 91 edges (avg confidence: 0.93) - Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 96 edges (avg confidence: 0.93)
- Token cost: 0 input · 0 output - Token cost: 0 input · 0 output
## Graph Freshness ## 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 `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost). - Run `graphify update .` after code changes (no API cost).
## Community Hubs (Navigation) ## Community Hubs (Navigation)
- Category
- budgeting/router.py
- db/models.py
- AppError
- imports/router.py - imports/router.py
- budgeting/router.py
- Base
- main.py
- test_import_savings.py
- alfa_xlsx.py - alfa_xlsx.py
- system-account-picker.test.tsx - system-account-picker.test.tsx
- types.ts - types.ts
- router.tsx - LoginPage.tsx
- ledger/router.py - ledger/router.py
- @testing-library/jest-dom - @testing-library/jest-dom
- compilerOptions - compilerOptions
- ReviewQueuePage.tsx - AnalyticsPage.tsx
- @testing-library/user-event - mcc.py
- devDependencies - devDependencies
- ledger/service.py - ledger/service.py
- dependencies - dependencies
- ImportDetailPage.tsx - useOperations.ts
- OperationsPage.tsx - OperationsPage.tsx
- Base - analytics/service.py
- apiErrors.ts - apiErrors.ts
- adapters/base.py - test_adapter_contracts.py
- BudgetPage.tsx - BudgetPage.tsx
- client.ts - client.ts
- fields.tsx - imports/service.py
- Docker Compose Development Stack - Docker Compose Development Stack
- Financial Space - Financial Space
- scripts - scripts
- @types/react - router.tsx
- General Pool - General Pool
- TestBaseContract - TestBaseContract
- Per-File Envelope Encryption - Per-File Envelope Encryption
@@ -53,68 +53,73 @@
- Closed Beta Launch Gates - Closed Beta Launch Gates
- package.json - package.json
- Financial Tracker MVP - Financial Tracker MVP
- test_adapter_contracts.py - TestRocketbankDebitContract
- generate-api.mjs - generate-api.mjs
- schema.d.ts - schema.d.ts
- test_api.py - test_api.py
- i18n-parity.test.ts - i18n-parity.test.ts
- Expected Income - Expected Income
- helpers.tsx - api
- ledger/models.py - AccountsPage.tsx
- category-manager.test.tsx - category-manager.test.tsx
- graphify - graphify
- @types/react-dom - index.ts
- typescript-eslint - typescript-eslint
- Ближайший край: накопления, категории и блокировки бюджета - Ближайший край: накопления, категории и блокировки бюджета
- vitest - RocketbankDebitPdfAdapter
- Favicon SVG (ledger lines icon) - Favicon SVG (ledger lines icon)
- init-app-role.sh - init-app-role.sh
- Payee - Payee
- test_private_fixtures.py - test_private_fixtures.py
- finance-backend - finance-backend
- LocalDirectoryBlobStore - LocalDirectoryBlobStore
- OzonDebitPdfAdapter - TestOzonDebitContract
- Gitea CI and real statement imports - Gitea CI and real statement imports
- 20260918_0002_hold_lifecycle.py - 20260918_0002_hold_lifecycle.py
- eslint-plugin-react-hooks - eslint-plugin-react-hooks
- import-hold-resolution.test.tsx - import-hold-resolution.test.tsx
- budgeting/service.py - budgeting/service.py
- TestAlfaDebitContract - TestAlfaDebitContract
- vite - AppLayout.tsx
- mcc.py - 20260922_0003_savings_system.py
- TestAlfaSavingsRule - jsdom
- savings-convert-revert.test.tsx - savings-convert-revert.test.tsx
- get_settings - get_settings
- ColumnChart.tsx - ColumnChart.tsx
- dependencies.py - dependencies.py
- AGENTS.md - AGENTS.md
- test_savings.py - Session
- test_category_definitions.py - test_category_definitions.py
- budget-plan.test.tsx - budget-plan.test.tsx
- imports/service.py - TestAlfaSavingsRule
- exports/service.py - exports/service.py
- test_imports.py - @testing-library/react
- identity/service.py - identity/service.py
- Session - test_import_holds.py
- get_or_create_period - get_or_create_period
- 20260923_0005_optional_hold_reason.py - 20260923_0005_optional_hold_reason.py
- test_import_savings.py - typescript
- conftest.py - useAuth.ts
- test_review_queue_batch.py - test_review_queue_batch.py
- @vitejs/plugin-react
- useImports.ts
- describeApiError
- app_error_handler
- review-queue-save.test.tsx - review-queue-save.test.tsx
- account-adjustment.test.tsx - detect_adapter
- review-queue-month.test.tsx
## God Nodes (most connected - your core abstractions) ## God Nodes (most connected - your core abstractions)
1. `Session` - 211 edges 1. `Session` - 221 edges
2. `AppError` - 72 edges 2. `AppError` - 76 edges
3. `Base` - 48 edges 3. `Base` - 48 edges
4. `conflict()` - 43 edges 4. `record()` - 44 edges
5. `get_settings()` - 40 edges 5. `conflict()` - 43 edges
6. `PkMixin` - 40 edges 6. `get_settings()` - 40 edges
7. `record()` - 39 edges 7. `PkMixin` - 40 edges
8. `TimestampMixin` - 38 edges 8. `get_or_create_period()` - 39 edges
9. `StatementDocument` - 36 edges 9. `TimestampMixin` - 38 edges
10. `create_expense()` - 36 edges 10. `bad_request()` - 36 edges
## Surprising Connections (you probably didn't know these) ## Surprising Connections (you probably didn't know these)
- `Typed Same-Origin API Client` --conceptually_related_to--> `Same-Origin Reverse Proxy` [INFERRED] - `Typed Same-Origin API Client` --conceptually_related_to--> `Same-Origin Reverse Proxy` [INFERRED]
@@ -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] - **Financial Space Tenancy Boundary** — context_financial_space, context_membership, docs_adr_0001_financial_space_ownership_and_rls_defense_in_depth_authorization, docs_adr_0001_financial_space_ownership_and_rls_transaction_local_rls, docs_adr_0001_financial_space_ownership_and_rls_cross_space_integrity [EXTRACTED 1.00]
- **Safe Statement Import Flow** — context_statement_import, context_parse_run, context_source_record, docs_adr_0003_versioned_statement_imports_immutable_statement_document, docs_adr_0003_versioned_statement_imports_conservative_deduplication, docs_adr_0003_versioned_statement_imports_atomic_confirm_revert [EXTRACTED 1.00] - **Safe Statement Import Flow** — context_statement_import, context_parse_run, context_source_record, docs_adr_0003_versioned_statement_imports_immutable_statement_document, docs_adr_0003_versioned_statement_imports_conservative_deduplication, docs_adr_0003_versioned_statement_imports_atomic_confirm_revert [EXTRACTED 1.00]
## Communities (95 total, 16 thin omitted) ## Communities (102 total, 15 thin omitted)
### Community 0 - "Category" ### Community 0 - "imports/router.py"
Cohesion: 0.20 Cohesion: 0.15
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) Nodes (35): confirm(), ConfirmBody, decide_row(), DecisionBody, delete_source(), detail(), HoldResolveBody, imports() (+27 more)
### Community 1 - "budgeting/router.py" ### Community 1 - "budgeting/router.py"
Cohesion: 0.11 Cohesion: 0.15
Nodes (52): balances(), cash_flow(), category_spend(), plan_fact(), Context, date, Db, get (+44 more) Nodes (51): AssignmentItem, assignments(), AssignmentsBody, categories(), category_definition_json(), category_definitions(), CategoryBody, CategoryDefinitionPatchBody (+43 more)
### Community 2 - "db/models.py" ### Community 2 - "Base"
Cohesion: 0.17 Cohesion: 0.09
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 (49): downgrade(), upgrade(), AuditEvent, Append-only domain/security event. Never stores raw financial payloads., ExpectedIncome, ExpectedIncomeMatch, FundingMovement, PeriodClosure (+41 more)
### Community 3 - "AppError" ### Community 3 - "main.py"
Cohesion: 0.19 Cohesion: 0.20
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) Nodes (15): get_db(), get_engine(), get_session_factory(), sessionmaker, health(), healthz(), get, readiness() (+7 more)
### Community 4 - "imports/router.py" ### Community 4 - "test_import_savings.py"
Cohesion: 0.05 Cohesion: 0.13
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) 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" ### Community 5 - "alfa_xlsx.py"
Cohesion: 0.12 Cohesion: 0.15
Nodes (32): AlfaCreditXlsxAdapter, AlfaDebitXlsxAdapter, _AlfaHeaderMeta, AlfaXlsxAdapterBase, _cell_str(), _debit_find_header(), _debit_header_metadata(), _debit_header_value() (+24 more) 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" ### Community 6 - "system-account-picker.test.tsx"
Cohesion: 0.11 Cohesion: 0.12
Nodes (15): AdjustBalanceResult, BudgetCategory, Operation, Page, budgetCategoriesMock, listAccountsMock, listOperationsMock, categoriesMock (+7 more) Nodes (13): Account, AdjustBalanceResult, BudgetCategory, Operation, adjustBalanceMock, listMock, budgetCategoriesMock, listAccountsMock (+5 more)
### Community 7 - "types.ts" ### 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 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" ### Community 9 - "ledger/router.py"
Cohesion: 0.15 Cohesion: 0.15
@@ -182,53 +187,57 @@ Nodes (38): account_json(), AccountBody, accounts(), adjust_balance(), AdjustBal
Cohesion: 0.07 Cohesion: 0.07
Nodes (28): compilerOptions, baseUrl, esModuleInterop, forceConsistentCasingInFileNames, isolatedModules, jsx, lib, module (+20 more) Nodes (28): compilerOptions, baseUrl, esModuleInterop, forceConsistentCasingInFileNames, isolatedModules, jsx, lib, module (+20 more)
### Community 12 - "ReviewQueuePage.tsx" ### Community 12 - "AnalyticsPage.tsx"
Cohesion: 0.09 Cohesion: 0.13
Nodes (43): NotFoundPage, PlanFactBars(), PlanFactBarsProps, PlanFactRow, EmptyState(), EmptyStateProps, MonthSwitcher(), MonthSwitcherProps (+35 more) 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" ### Community 14 - "devDependencies"
Cohesion: 0.10 Cohesion: 0.09
Nodes (21): eslint, @eslint/js, eslint-plugin-jsx-a11y, devDependencies, eslint, @eslint/js, eslint-plugin-jsx-a11y, globals (+13 more) 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" ### Community 15 - "ledger/service.py"
Cohesion: 0.13 Cohesion: 0.14
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) 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" ### 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 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" ### Community 18 - "OperationsPage.tsx"
Cohesion: 0.11 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" ### Community 19 - "analytics/service.py"
Cohesion: 0.15 Cohesion: 0.20
Nodes (22): downgrade(), upgrade(), BankCategoryMapping, CategorizationRule, CategoryMcc, IncomeSource, MccCode, Payee (+14 more) Nodes (18): balances(), cash_flow(), category_spend(), plan_fact(), Context, date, Db, get (+10 more)
### Community 20 - "apiErrors.ts" ### Community 20 - "apiErrors.ts"
Cohesion: 0.09 Cohesion: 0.33
Nodes (24): isApiError(), BudgetBlocker, App(), queryClient, AppRoutes(), blockerCta(), blockerText(), BudgetBlockers() (+16 more) Nodes (6): isApiError(), App(), queryClient, AppRoutes(), apiErrorCode(), container
### Community 21 - "adapters/base.py" ### Community 21 - "test_adapter_contracts.py"
Cohesion: 0.12 Cohesion: 0.13
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) 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" ### Community 22 - "BudgetPage.tsx"
Cohesion: 0.14 Cohesion: 0.17
Nodes (27): CreatableCategoryKind, CategoryManagerModal(), CREATE_KINDS, CreateCategoryForm(), MccCodesModal(), parseMccCodes(), RenameModal(), Badge() (+19 more) Nodes (28): CreatableCategoryKind, CategoryManagerModal(), CREATE_KINDS, CreateCategoryForm(), MccCodesModal(), parseMccCodes(), RenameModal(), useBudgetCategories() (+20 more)
### Community 23 - "client.ts" ### Community 23 - "client.ts"
Cohesion: 0.26 Cohesion: 0.26
Nodes (10): apiClient, ApiError, buildUrl(), ErrorEnvelope, parseErrorEnvelope(), QueryValue, readCsrfToken(), request() (+2 more) Nodes (10): apiClient, ApiError, buildUrl(), ErrorEnvelope, parseErrorEnvelope(), QueryValue, readCsrfToken(), request() (+2 more)
### Community 24 - "fields.tsx" ### Community 24 - "imports/service.py"
Cohesion: 0.21 Cohesion: 0.15
Nodes (11): FieldChrome, InputProps, MoneyField(), MoneyFieldProps, SelectField(), SelectFieldProps, SelectProps, TextareaField() (+3 more) 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" ### Community 25 - "Docker Compose Development Stack"
Cohesion: 0.29 Cohesion: 0.29
@@ -242,6 +251,10 @@ Nodes (12): Credit Reserve, Financial Account, Financial Space, Membership, User
Cohesion: 0.22 Cohesion: 0.22
Nodes (9): scripts, api:generate, build, dev, lint, preview, test, test:watch (+1 more) 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" ### Community 29 - "General Pool"
Cohesion: 0.29 Cohesion: 0.29
Nodes (8): Budget Period, Funding, General Pool, Monthly Category, Plan Assignment, Savings Envelope, Immutable Closure Snapshots, Plan and Funding Separation Nodes (8): Budget Period, Funding, General Pool, Monthly Category, Plan Assignment, Savings Envelope, Immutable Closure Snapshots, Plan and Funding Separation
@@ -278,9 +291,9 @@ Nodes (4): name, private, type, version
Cohesion: 0.18 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) 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" ### Community 38 - "TestRocketbankDebitContract"
Cohesion: 0.13 Cohesion: 0.24
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 Nodes (3): same_page_rocket_lines(), synthetic_rocket_lines(), TestRocketbankDebitContract
### Community 39 - "generate-api.mjs" ### Community 39 - "generate-api.mjs"
Cohesion: 0.40 Cohesion: 0.40
@@ -294,13 +307,13 @@ Nodes (5): components, $defs, operations, paths, webhooks
Cohesion: 0.67 Cohesion: 0.67
Nodes (3): TestClient, test_invite_login_and_authenticated_space(), test_openapi_contains_critical_paths() Nodes (3): TestClient, test_invite_login_and_authenticated_space(), test_openapi_contains_critical_paths()
### Community 44 - "helpers.tsx" ### Community 44 - "api"
Cohesion: 0.18 Cohesion: 0.14
Nodes (11): User, DESKTOP_NAV, meMock, MOBILE_NAV, user, createTestQueryClient(), renderWithProviders(), loginMock (+3 more) Nodes (14): api, User, createMock, listMock, DESKTOP_NAV, meMock, MOBILE_NAV, user (+6 more)
### Community 45 - "ledger/models.py" ### Community 45 - "AccountsPage.tsx"
Cohesion: 0.26 Cohesion: 0.10
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) Nodes (37): Badge(), BadgeTone, Button(), ButtonProps, Variant, ConfirmDialog(), ConfirmDialogProps, EmptyState() (+29 more)
### Community 46 - "category-manager.test.tsx" ### Community 46 - "category-manager.test.tsx"
Cohesion: 0.14 Cohesion: 0.14
@@ -310,21 +323,29 @@ Nodes (12): CategoryDefinition, categoriesMock, createCategoryMock, definitions,
Cohesion: 0.17 Cohesion: 0.17
Nodes (11): command, enabled, timeout, type, mcp, graphify, plugin, $schema (+3 more) 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 - "Ближайший край: накопления, категории и блокировки бюджета" ### Community 50 - "Ближайший край: накопления, категории и блокировки бюджета"
Cohesion: 0.10 Cohesion: 0.10
Nodes (20): 1. Доменная основа накоплений, 2. Альфа debit: «Копилка для сдачи», 3. Управление категориями, 4. Понятные и устранимые блокировки, Backend-контракт, Frontend, Ближайший край: накопления, категории и блокировки бюджета, Бюджетная семантика (+12 more) Nodes (20): 1. Доменная основа накоплений, 2. Альфа debit: «Копилка для сдачи», 3. Управление категориями, 4. Понятные и устранимые блокировки, Backend-контракт, Frontend, Ближайший край: накопления, категории и блокировки бюджета, Бюджетная семантика (+12 more)
### Community 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" ### Community 58 - "test_private_fixtures.py"
Cohesion: 0.10 Cohesion: 0.12
Nodes (24): Protocol, StatementAdapter, detect_adapter(), Any, Reconcile parsed rows against declared bank controls with stable diagnostics.…, _reconciliation_summary(), _check(), _fail() (+16 more) 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" ### Community 62 - "LocalDirectoryBlobStore"
Cohesion: 0.39 Cohesion: 0.39
Nodes (3): LocalDirectoryBlobStore, Path, S3-neutral local store; production swaps in an S3-compatible client. Nodes (3): LocalDirectoryBlobStore, Path, S3-neutral local store; production swaps in an S3-compatible client.
### Community 63 - "OzonDebitPdfAdapter" ### Community 63 - "TestOzonDebitContract"
Cohesion: 0.18 Cohesion: 0.15
Nodes (6): _ozon_normalize(), OzonDebitPdfAdapter, Ozon debit statement contract, evidenced against the real document. A 'Справка…, ozon_extracted(), TestOzonDebitContract, Pattern 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" ### Community 64 - "Gitea CI and real statement imports"
Cohesion: 0.29 Cohesion: 0.29
@@ -339,16 +360,20 @@ Cohesion: 0.15
Nodes (11): ImportDetail, ImportRow, ImportSummary, confirmMock, detailFixture(), getImportMock, postedRow(), getImportMock (+3 more) Nodes (11): ImportDetail, ImportRow, ImportSummary, confirmMock, detailFixture(), getImportMock, postedRow(), getImportMock (+3 more)
### Community 68 - "budgeting/service.py" ### Community 68 - "budgeting/service.py"
Cohesion: 0.16 Cohesion: 0.13
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) 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" ### Community 69 - "TestAlfaDebitContract"
Cohesion: 0.18 Cohesion: 0.18
Nodes (3): alfa_workbook_bytes(), synthetic_alfa_rows(), TestAlfaDebitContract Nodes (3): alfa_workbook_bytes(), synthetic_alfa_rows(), TestAlfaDebitContract
### Community 71 - "mcc.py" ### Community 70 - "AppLayout.tsx"
Cohesion: 0.14 Cohesion: 0.19
Nodes (23): _create_tenant_policy(), _default_keys(), downgrade(), _has_table(), _space_ids(), upgrade(), _default_categories(), default_category_system_keys() (+15 more) 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" ### Community 73 - "savings-convert-revert.test.tsx"
Cohesion: 0.20 Cohesion: 0.20
@@ -363,88 +388,92 @@ Cohesion: 0.24
Nodes (7): CashFlowColumn, ColumnChart(), ColumnChartProps, BarRow, HorizontalBars(), HorizontalBarsProps, rows Nodes (7): CashFlowColumn, ColumnChart(), ColumnChartProps, BarRow, HorizontalBars(), HorizontalBarsProps, rows
### Community 76 - "dependencies.py" ### Community 76 - "dependencies.py"
Cohesion: 0.09 Cohesion: 0.10
Nodes (39): current_context(), current_session(), alias, Context, CurrentSession, Db, Header, User (+31 more) 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 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" ### Community 80 - "test_category_definitions.py"
Cohesion: 0.50 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() Nodes (8): bootstrap(), TestClient, HTTP contract for category definitions, system protections and CSRF., test_adjust_and_savings_conversion_http_contracts(), test_categories_expose_and_update_multiple_mcc_codes(), test_category_lifecycle_create_rename_archive_restore(), test_openapi_contains_new_contracts(), test_space_has_protected_savings_pair()
### Community 81 - "budget-plan.test.tsx" ### Community 81 - "budget-plan.test.tsx"
Cohesion: 0.25 Cohesion: 0.15
Nodes (7): BudgetSummary, categories, categoriesMock, month, summary, summaryMock, updatePlanMock Nodes (11): BudgetSummary, ExpectedIncome, categories, categoriesMock, month, summary, summaryMock, updatePlanMock (+3 more)
### 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 83 - "exports/service.py" ### Community 83 - "exports/service.py"
Cohesion: 0.13 Cohesion: 0.07
Nodes (19): DataExport, build_export(), consume_export(), _csv_bytes(), get_export(), _json_rows(), purge_due_spaces(), datetime (+11 more) 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 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)
### Community 85 - "identity/service.py" ### Community 85 - "identity/service.py"
Cohesion: 0.26 Cohesion: 0.19
Nodes (20): bad_request(), unauthorized(), accept_invitation(), authenticate(), create_invitation(), create_password_reset(), create_session(), get_active_session() (+12 more) Nodes (24): current_session(), alias, User, unauthorized(), accept_invitation(), authenticate(), create_invitation(), create_password_reset() (+16 more)
### Community 86 - "Session" ### Community 86 - "test_import_holds.py"
Cohesion: 0.16 Cohesion: 0.13
Nodes (41): Session, add_record(), _draft(), hold_document(), make_document(), date, Path, TestClient (+33 more) Nodes (42): normalize_description(), date, record_fingerprint(), add_record(), _draft(), hold_document(), make_document(), date (+34 more)
### Community 87 - "get_or_create_period" ### Community 87 - "get_or_create_period"
Cohesion: 0.34 Cohesion: 0.18
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) 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" ### Community 88 - "20260923_0005_optional_hold_reason.py"
Cohesion: 0.83 Cohesion: 0.83
Nodes (3): downgrade(), _reason_constraint_name(), upgrade() Nodes (3): downgrade(), _reason_constraint_name(), upgrade()
### Community 89 - "test_import_savings.py" ### Community 90 - "useAuth.ts"
Cohesion: 0.32 Cohesion: 0.36
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) Nodes (8): ResetPasswordPage, useAcceptInvite(), useConfirmPasswordReset(), useLogin(), useRequestPasswordReset(), useSessionMutation(), ResetPasswordPage(), ResetValues
### Community 90 - "conftest.py"
Cohesion: 0.27
Nodes (6): client(), db(), db_factory(), fixture, sessionmaker, TestClient
### Community 91 - "test_review_queue_batch.py" ### Community 91 - "test_review_queue_batch.py"
Cohesion: 0.60 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) 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 Cohesion: 0.25
Nodes (6): categories, categoriesMock, categorizeBatchMock, categorizeMock, importsListMock, operationsListMock Nodes (6): categories, categoriesMock, categorizeBatchMock, categorizeMock, importsListMock, operationsListMock
### Community 94 - "account-adjustment.test.tsx" ### Community 100 - "detect_adapter"
Cohesion: 0.20 Cohesion: 0.29
Nodes (5): Account, adjustBalanceMock, listMock, createMock, listMock 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 ## 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) - **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; 468 node(s) total have ≤1 connection when file, concept and rationale nodes are included.) These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 477 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. - **15 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions ## Suggested Questions
_Questions this graph is uniquely positioned to answer:_ _Questions this graph is uniquely positioned to answer:_
- **Why does `Session` connect `Session` to `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`?** - **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.067) - this node is a cross-community bridge._ _High betweenness centrality (0.071) - 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`?** - **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.049) - this node is a cross-community bridge._ _High betweenness centrality (0.047) - this node is a cross-community bridge._
- **Why does `TestRocketbankDebitContract` connect `test_adapter_contracts.py` to `AppError`, `adapters/base.py`?** - **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._ _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?** - **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._ _`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?** - **Are the 42 inferred relationships involving `AppError` (e.g. with `request_export()` and `login()`) actually correct?**
_`AppError` has 39 INFERRED edges - model-reasoned connections that need verification._ _`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?** - **Are the 3 inferred relationships involving `Base` (e.g. with `downgrade()` and `upgrade()`) actually correct?**
_`Base` has 3 INFERRED edges - model-reasoned connections that need verification._ _`Base` has 3 INFERRED edges - model-reasoned connections that need verification._
- **What connects `$schema`, `file:///home/k1nq/repos/finance/.kilo/plugins/graphify.js`, `type` to the rest of the system?** - **What connects `$schema`, `file:///home/k1nq/repos/finance/.kilo/plugins/graphify.js`, `type` to the rest of the system?**
_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": "" "semantic_hash": ""
}, },
"backend/app/analytics/service.py": { "backend/app/analytics/service.py": {
"mtime": 1790061159.3943744, "mtime": 1790158886.5589485,
"seen": 1790096479.536424, "seen": 1790164233.4191206,
"ast_hash": "58ac24a62b4cf8a986527f79d456127d", "ast_hash": "3d87f3a80dc16a7781b5b802a0a66039",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/audit/__init__.py": { "backend/app/audit/__init__.py": {
@@ -66,15 +66,15 @@
"semantic_hash": "039081cab3d91704e8f8a7152a981304" "semantic_hash": "039081cab3d91704e8f8a7152a981304"
}, },
"backend/app/budgeting/router.py": { "backend/app/budgeting/router.py": {
"mtime": 1790140570.7235522, "mtime": 1790164219.7100794,
"seen": 1790140596.4645138, "seen": 1790164233.4191263,
"ast_hash": "5c8ea9e79dbe5450c2891c40931f0f4d", "ast_hash": "e4c01ff67e84d606da20520c634aada1",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/budgeting/service.py": { "backend/app/budgeting/service.py": {
"mtime": 1790139967.779716, "mtime": 1790164039.9056563,
"seen": 1790140596.4645145, "seen": 1790164233.4191272,
"ast_hash": "8b936c8c80b35123143fcc6bed627172", "ast_hash": "9856f61057a9ddc569b2d8ae5d78f688",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/categorization/__init__.py": { "backend/app/categorization/__init__.py": {
@@ -222,9 +222,9 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/imports/adapters/ozon_pdf.py": { "backend/app/imports/adapters/ozon_pdf.py": {
"mtime": 1789733868.836172, "mtime": 1790163956.5875351,
"seen": 1789735433.158129, "seen": 1790164233.4191487,
"ast_hash": "dfa6dc9ccf2c655e6c2eeaceea0cf06a", "ast_hash": "a0aa31d71ab7a45a479178cd630d8b05",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/imports/adapters/rocketbank_pdf.py": { "backend/app/imports/adapters/rocketbank_pdf.py": {
@@ -288,15 +288,15 @@
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e" "semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
}, },
"backend/app/ledger/models.py": { "backend/app/ledger/models.py": {
"mtime": 1790060833.8770478, "mtime": 1790158886.5589485,
"seen": 1790096479.5364609, "seen": 1790164233.4191582,
"ast_hash": "86891c404082d8d2c84214f31d8616ff", "ast_hash": "38e4db3531f1672e57e86ec36d7b1ed7",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/ledger/router.py": { "backend/app/ledger/router.py": {
"mtime": 1790155278.0175776, "mtime": 1790158886.5589485,
"seen": 1790155776.860304, "seen": 1790164233.419159,
"ast_hash": "24b2565478f257f38561fabff646130a", "ast_hash": "37c07d8487d317af02a3762099bce24b",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/ledger/service.py": { "backend/app/ledger/service.py": {
@@ -372,15 +372,15 @@
"semantic_hash": "bfec0778131788a91a0bc5c9ec2ad8ba" "semantic_hash": "bfec0778131788a91a0bc5c9ec2ad8ba"
}, },
"backend/tests/test_budget.py": { "backend/tests/test_budget.py": {
"mtime": 1790141279.1849978, "mtime": 1790164269.4717507,
"seen": 1790141340.361456, "seen": 1790164324.1326215,
"ast_hash": "81578f618d0758c7f9e2f00c921f2594", "ast_hash": "6d01ee3dd3fdc66f65ba85ba6a3909fc",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_imports.py": { "backend/tests/test_imports.py": {
"mtime": 1790140440.029933, "mtime": 1790164055.474866,
"seen": 1790140596.4645612, "seen": 1790164233.4191756,
"ast_hash": "9e568b07fa4d77eb55c36ef748fb0f9b", "ast_hash": "580d49a52a4cfc72ce23b21cc983f833",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_ledger_properties.py": { "backend/tests/test_ledger_properties.py": {
@@ -402,9 +402,9 @@
"semantic_hash": "ed54799a921d10394f826893acb46876" "semantic_hash": "ed54799a921d10394f826893acb46876"
}, },
"frontend/package.json": { "frontend/package.json": {
"mtime": 1790055350.366911, "mtime": 1790164273.1148,
"seen": 1790096479.5364823, "seen": 1790164324.1326368,
"ast_hash": "172e2880b6084c1fc423ea759b99280a", "ast_hash": "6e94a9e65d8bbac0083b73acea30af23",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/scripts/generate-api.mjs": { "frontend/scripts/generate-api.mjs": {
@@ -426,9 +426,9 @@
"semantic_hash": "dbea075a006b7703340f7f93e69d53ac" "semantic_hash": "dbea075a006b7703340f7f93e69d53ac"
}, },
"frontend/src/api/endpoints.ts": { "frontend/src/api/endpoints.ts": {
"mtime": 1790155318.8911343, "mtime": 1790164934.5157416,
"seen": 1790155776.8603325, "seen": 1790165332.699026,
"ast_hash": "972cada63fe04b77326f9fd51c6a0fff", "ast_hash": "4b26317b0d9c940df9b7b173e6de15c4",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/api/generated/schema.d.ts": { "frontend/src/api/generated/schema.d.ts": {
@@ -444,9 +444,9 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/api/types.ts": { "frontend/src/api/types.ts": {
"mtime": 1790155310.2330165, "mtime": 1790164934.5147417,
"seen": 1790155776.8603358, "seen": 1790165332.6990294,
"ast_hash": "168d7c87c05b4bf5205cde1bc45ff75b", "ast_hash": "bf75f61d6fa226a6a073770cbacf3784",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/app/router.tsx": { "frontend/src/app/router.tsx": {
@@ -474,9 +474,9 @@
"semantic_hash": "f581486baa48447ec4c573acef3190ef" "semantic_hash": "f581486baa48447ec4c573acef3190ef"
}, },
"frontend/src/components/layout/AppLayout.tsx": { "frontend/src/components/layout/AppLayout.tsx": {
"mtime": 1790155877.7546387, "mtime": 1790164788.5197659,
"seen": 1790156681.7281113, "seen": 1790165332.6990376,
"ast_hash": "b2485ee6009655cd96a2acda54c4e873", "ast_hash": "0d03099bc00a95bd3889434b26541fe8",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/components/layout/LangSwitcher.tsx": { "frontend/src/components/layout/LangSwitcher.tsx": {
@@ -516,9 +516,9 @@
"semantic_hash": "6827eb04b914e8c675e842fea74b5ba5" "semantic_hash": "6827eb04b914e8c675e842fea74b5ba5"
}, },
"frontend/src/components/ui/Modal.tsx": { "frontend/src/components/ui/Modal.tsx": {
"mtime": 1790055350.367207, "mtime": 1790165073.2735536,
"seen": 1790096479.5365033, "seen": 1790165332.6990447,
"ast_hash": "4250ebda526b86679ba5eb965feccf59", "ast_hash": "bf0bf25b1397d8368099127a570b17a7",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/components/ui/MonthSwitcher.tsx": { "frontend/src/components/ui/MonthSwitcher.tsx": {
@@ -576,9 +576,9 @@
"semantic_hash": "40f9f47b108ec0b484f9564bd1d319cb" "semantic_hash": "40f9f47b108ec0b484f9564bd1d319cb"
}, },
"frontend/src/hooks/useBudget.ts": { "frontend/src/hooks/useBudget.ts": {
"mtime": 1790140175.05337, "mtime": 1790164934.5157416,
"seen": 1790140596.464592, "seen": 1790165332.6990545,
"ast_hash": "ffe50957304981998e7b92838ae15bdb", "ast_hash": "0e193a3e55e814a92e5aa38319c3eadb",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/hooks/useImports.ts": { "frontend/src/hooks/useImports.ts": {
@@ -606,21 +606,21 @@
"semantic_hash": "243512f6e095deeaa29393b7c831777f" "semantic_hash": "243512f6e095deeaa29393b7c831777f"
}, },
"frontend/src/i18n/locales/en.json": { "frontend/src/i18n/locales/en.json": {
"mtime": 1790155431.9488008, "mtime": 1790164982.1323862,
"seen": 1790155776.860359, "seen": 1790165332.6990597,
"ast_hash": "a92d8d64ae7b7e47bf83e625ea901611", "ast_hash": "d18260df743714adc9e30465612f01db",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/i18n/locales/ru.json": { "frontend/src/i18n/locales/ru.json": {
"mtime": 1790155435.0697088, "mtime": 1790164982.1333861,
"seen": 1790155776.8603597, "seen": 1790165332.6990604,
"ast_hash": "31dc876ab451fbd85e91efde3c33f904", "ast_hash": "9428a9c9e6497f077c274e82150ad4b3",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/main.tsx": { "frontend/src/main.tsx": {
"mtime": 1790055350.367207, "mtime": 1790165287.3710241,
"seen": 1790096479.5365248, "seen": 1790165332.6990693,
"ast_hash": "b88ee6ed2ae6eab1f3db7097b6912329", "ast_hash": "cf297f4697f483026ef7a7800d0a8d8f",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/pages/AcceptInvitePage.tsx": { "frontend/src/pages/AcceptInvitePage.tsx": {
@@ -642,9 +642,9 @@
"semantic_hash": "4a83070566fde66ff6423b1064cde4e8" "semantic_hash": "4a83070566fde66ff6423b1064cde4e8"
}, },
"frontend/src/pages/BudgetPage.tsx": { "frontend/src/pages/BudgetPage.tsx": {
"mtime": 1790097127.972161, "mtime": 1790164892.6851754,
"seen": 1790129548.053057, "seen": 1790165332.6990728,
"ast_hash": "7435e885343ed139a64dfbe152b44624", "ast_hash": "8484ee3d99574a8e794702f895bb8b85",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/pages/ImportDetailPage.tsx": { "frontend/src/pages/ImportDetailPage.tsx": {
@@ -888,9 +888,9 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_adapter_contracts.py": { "backend/tests/test_adapter_contracts.py": {
"mtime": 1790061478.1675427, "mtime": 1790164118.1117096,
"seen": 1790096479.5364716, "seen": 1790164233.419169,
"ast_hash": "9d446a10c953a14c8d4962eff50f5b97", "ast_hash": "a9bc17e60e6795f552aba5dee534c9ba",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_import_holds.py": { "backend/tests/test_import_holds.py": {
@@ -900,9 +900,9 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_private_fixtures.py": { "backend/tests/test_private_fixtures.py": {
"mtime": 1789737822.4871244, "mtime": 1790164067.7670314,
"seen": 1789737916.7915628, "seen": 1790164233.419178,
"ast_hash": "f14cd118f1e8d6b5088536d50d2b965a", "ast_hash": "268632b4cd05c2538fac5f22b28480ba",
"semantic_hash": "" "semantic_hash": ""
}, },
".gitea/workflows/ci.yml": { ".gitea/workflows/ci.yml": {
@@ -942,21 +942,21 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/pages/OverviewPage.tsx": { "frontend/src/pages/OverviewPage.tsx": {
"mtime": 1790077489.1771908, "mtime": 1790164982.131386,
"seen": 1790096479.5365322, "seen": 1790165332.6990778,
"ast_hash": "6a426b63545f169490e67d52ca231180", "ast_hash": "8acb02afddacb8cb78a2e21ace07ab4c",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/alembic/versions/20260922_0003_savings_system.py": { "backend/alembic/versions/20260922_0003_savings_system.py": {
"mtime": 1790078031.6120079, "mtime": 1790158886.5589485,
"seen": 1790096479.536419, "seen": 1790164233.4191144,
"ast_hash": "12acc1a6c82583b06da88974500498a7", "ast_hash": "94f0fec8f2edfdefd3d00ece470867c2",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/app/ledger/savings.py": { "backend/app/ledger/savings.py": {
"mtime": 1790097195.1310222, "mtime": 1790158886.5589485,
"seen": 1790129548.0529978, "seen": 1790164233.4191597,
"ast_hash": "5367de3a6184c6df07170721a6a0fcf8", "ast_hash": "8b17828223421e8062069382024a7ae3",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_budget_savings.py": { "backend/tests/test_budget_savings.py": {
@@ -972,15 +972,15 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_import_savings.py": { "backend/tests/test_import_savings.py": {
"mtime": 1790129378.2103765, "mtime": 1790164033.0365639,
"seen": 1790129548.0530114, "seen": 1790164233.419175,
"ast_hash": "7bf7f4500852520a2a975a452911af1c", "ast_hash": "aee4dbfbada3e53720c7c5dd64014c26",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_savings.py": { "backend/tests/test_savings.py": {
"mtime": 1790097195.1310337, "mtime": 1790164079.4361887,
"seen": 1790129548.053015, "seen": 1790164233.4191797,
"ast_hash": "c5eb8af5219e2f5cc153d09c0c4a37a1", "ast_hash": "db9a0c71ac67e3c104a4fcd9ecb83062",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/components/budget/BudgetBlockers.tsx": { "frontend/src/components/budget/BudgetBlockers.tsx": {
@@ -990,9 +990,9 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/components/budget/CategoryManagerModal.tsx": { "frontend/src/components/budget/CategoryManagerModal.tsx": {
"mtime": 1790141684.261269, "mtime": 1790164788.520766,
"seen": 1790142012.6081603, "seen": 1790165332.6990325,
"ast_hash": "3320a39025aec0d67df0a5f8a4bf6d4b", "ast_hash": "25e3f09e8aa689ddec8b4324b23bea99",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/lib/accountLabel.ts": { "frontend/src/lib/accountLabel.ts": {
@@ -1056,9 +1056,9 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/test/category-manager.test.tsx": { "frontend/src/test/category-manager.test.tsx": {
"mtime": 1790141753.6681683, "mtime": 1790165029.0430212,
"seen": 1790142012.6082017, "seen": 1790165332.6990855,
"ast_hash": "9d2f35f1471a69a6a73d1a55cf81cb2a", "ast_hash": "61dc003a42028373615bc6937f593247",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/test/import-hold-resolution.test.tsx": { "frontend/src/test/import-hold-resolution.test.tsx": {
@@ -1122,9 +1122,9 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/tests/test_review_queue_batch.py": { "backend/tests/test_review_queue_batch.py": {
"mtime": 1790155761.6000907, "mtime": 1790158886.5589485,
"seen": 1790155776.860325, "seen": 1790164233.419179,
"ast_hash": "ca92b0417d73b849012c90803a4a6885", "ast_hash": "0074192ea42734bcc988792b5de3624b",
"semantic_hash": "" "semantic_hash": ""
}, },
"frontend/src/test/review-queue-save.test.tsx": { "frontend/src/test/review-queue-save.test.tsx": {
@@ -1150,5 +1150,29 @@
"seen": 1790157424.7858286, "seen": 1790157424.7858286,
"ast_hash": "325d3bc51b743c4fdde3c00733ce9c99", "ast_hash": "325d3bc51b743c4fdde3c00733ce9c99",
"semantic_hash": "" "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