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