Update file metadata in manifest.json and change MinIO image source in docker-compose.yml

This commit is contained in:
k1nq
2026-09-23 09:23:13 +05:00
parent 8c2b89c80d
commit db4dd7572e
71 changed files with 31011 additions and 8308 deletions
+3
View File
@@ -17,6 +17,9 @@ eggs/
.eggs/
lib/
lib64/
# The frontend's runtime utilities live in a source directory named `lib`.
!frontend/src/lib/
!frontend/src/lib/**
parts/
sdist/
var/
@@ -0,0 +1,153 @@
# Ближайший край: накопления, категории и блокировки бюджета
## Цель и порядок
Сначала ввести корректную доменную модель системных накоплений, затем использовать её в импорте Альфа-Банка и ручной разметке операций. После этого закрыть пользовательские разрывы в управлении категориями и разборе причин, мешающих закрыть месяц.
Порядок реализации обязателен:
1. Системный счёт и конверт «Накопления», корректировки и преобразование расходов.
2. Автоматическая классификация «Копилки для сдачи» в Альфа debit.
3. Полное управление пользовательскими категориями на странице бюджета.
4. Человекочитаемые и сразу устранимые блокировки закрытия.
## Что обнаружено
- Альфа debit сейчас извлекает только направление, банковскую категорию и описание. Перевод создаётся лишь при совпадении двух импортированных операций на разных пользовательских счетах; технический счёт услуги «Копилка для сдачи» этому условию не соответствует.
- Настоящий `transfer` обязан иметь два счёта и две противоположные проводки. Категория у переводов сейчас не используется бюджетом.
- Остатки счетов производные и по принятому ADR не должны перезаписываться напрямую.
- Backend уже имеет `POST /budget/categories` и сервисные функции переименования/архивации, но frontend их не вызывает, HTTP-контракт жизненного цикла неполон, архив скрыт.
- Backend отдаёт коды `IMPORT_UNRESOLVED_HOLDS` и `BUDGET_UNCATEGORIZED_EXPENSES`, а локали содержат другие ключи в нижнем регистре, поэтому UI показывает enum. Endpoint разрешения HOLD уже есть, но frontend-действий для него нет.
## Зафиксированные продуктовые решения
- В каждом финансовом пространстве существует ровно один видимый системный счёт типа `savings` и один связанный системный `savings_envelope` с ключом `savings`.
- Название локализуется как «Накопления» / “Savings”; системные счёт и категория не переименовываются, не архивируются и не удаляются.
- Перевод с обычного счёта на системный счёт получает тип `transfer` и системную категорию «Накопления». Перевод обратно увеличивает общий пул.
- Пользователь может преобразовать только подтверждённый расход в перевод на «Накопления» и отменить это преобразование. Доходы, возвраты и произвольные переводы не преобразуются этой командой.
- Ручная смена остатка создаёт отдельную аудируемую `adjustment`-операцию на разницу. Она меняет активы и остаток конверта, но не доходы, расходы, cash flow или общий бюджетный пул.
- Автоматически распознаётся только услуга Альфа-Банка «Копилка для сдачи». Снятие наличных, P2P-крипта и другие способы накопления размечаются пользователем вручную.
- Категориями управляют внутри страницы «Бюджет»: создание, переименование, архивирование и восстановление. Системные категории показаны, но их действия заблокированы.
- Причина блокировки содержит понятный текст, количество и действие, ведущее к месту исправления.
## Обязательный дизайн-процесс
- До изменения frontend-кода загрузить и применить `pen-dev`: проработать в существующем `design.pen` состояния управления категориями, корректировки накоплений, разметки операции и устранения бюджетных блокировок. `.pen` редактировать только Pencil-инструментами.
- Затем применить `design-an-interface` (доступное имя скилла, соответствующее запросу `design-interfaces`) и сравнить минимум два заметно разных варианта структуры и interaction flow. Выбрать один вариант по числу шагов, ясности финансовых последствий, доступности и согласованности с текущим приложением, а не смешивать варианты.
- Вёрстку выбранного варианта выполнять со скиллом `frontend-design`, сохраняя установленный визуальный язык проекта, существующие компоненты, типографику и плотность финансовых таблиц. Не вводить отдельный дизайн-язык или новый набор базовых UI-компонентов без необходимости.
- После первой работающей вёрстки применить `impeccable` для критики и полировки UX-copy, визуальной иерархии, responsive-поведения, keyboard/focus flow, error/empty/loading states и доступности.
- Для каждого затронутого flow проверить макет и реализацию в состояниях desktop и mobile, с данными, пустым списком, pending mutation, ошибкой, системной защищённой сущностью и архивной сущностью. Frontend-срез не считается законченным до browser screenshot review и устранения замечаний из `impeccable`.
## 1. Доменная основа накоплений
### Схема и миграция
- В `backend/app/ledger/models.py` добавить тип счёта `savings`, nullable `Account.system_key` и уникальность `(space_id, system_key)` для системного счёта.
- Добавить тип операции `adjustment`. Для отображения знака получать дельту из единственной проводки корректировки, не делать `amount_kopecks` знаковым.
- Добавить к операции признак обратимого преобразования из расхода и ссылку на предыдущую категорию. Это позволит отличить размеченный расход от обычного вручную созданного перевода и точно восстановить категорию.
- Добавить уникальность системной пары категории по `(space_id, system_key, linked_account_id)`; существующие резервы кредиток остаются допустимы.
- Создать ревизию Alembic после `0002`: безопасно заменить check constraints типов, добавить поля/индексы и создать для каждого существующего пространства системный счёт с нулевым открывающим остатком и связанную категорию. Новые пространства получают ту же пару в `spaces.service.create_space`.
- Не переиспользовать существующие пользовательские счета/категории с текстом «Накопления»: идентичность задаётся только `system_key`, совпавшие пользовательские имена сохраняются.
- Обновить `docs/adr/0002-journal-and-budget-reserves.md`: корректировка является журнальной операцией с одной проводкой; системный конверт связан со счётом; прямое изменение stored balance по-прежнему запрещено.
### Сервисы и API
- Вынести идемпотентный `ensure_savings_resources(db, space_id)` и использовать его при создании пространства и в доменных командах накоплений.
- Добавить `POST /accounts/{account_id}/adjust-balance` с `targetBalanceKopecks >= 0`, датой и обязательной причиной. Под блокировкой строки счёта вычислять дельту, запрещать нулевое изменение и закрытый месяц, создавать `adjustment` и одну signed posting, писать old/new/delta в audit.
- Добавить явные команды `POST /operations/{id}/savings-transfer` и `POST /operations/{id}/savings-transfer/revert`.
- Преобразование разрешать только для `confirmed expense` не на системном счёте и в открытом периоде: сохранить прежнюю категорию, сменить тип на `transfer`, назначить counter account и системную категорию, добавить положительную проводку на накопления, сохранив исходную отрицательную проводку, description, payee, request key и import provenance.
- Обратная команда разрешена только для помеченного преобразования: удалить проводку накоплений, вернуть `expense`, убрать counter account и восстановить предыдущую категорию. Обе команды аудируются и остаются tenant-scoped.
- Обычные expense/income/import endpoints не должны использовать системный счёт как внешний счёт. Ручной `create_transfer` может переводить в накопления и обратно; при участии системного счёта он автоматически назначает системную категорию.
- В API счёта вернуть `systemKey`/`nameKey`; пользовательский create-account по-прежнему принимает только `cash | debit | credit`.
### Бюджетная семантика
- В `compute_period_state` считать переводы в накопления обеспечением системного конверта и вычитать их из общего пула; переводы из накоплений считать обратным движением и возвращать в пул.
- Для системного конверта: `planned = 0`, `funded` показывает чистое движение переводов выбранного месяца, `spent = 0`, `available` равен остатку связанного счёта на конец выбранного месяца. Корректировки меняют только `available`.
- Не включать `adjustment` в income/expense totals, category spend, plan/fact или cash-flow. В balances/net worth системный счёт включается как актив.
- Закрытый период защищает преобразование, возврат преобразования и корректировку тем же правилом reopen-before-change.
### Frontend
- Расширить типы, форматирование и локали для `savings` и `adjustment`; не добавлять `adjustment` в форму создания обычной операции.
- В `AccountsPage` показать системный счёт обычной строкой с системным badge, без archive, и действием «Скорректировать». Drawer/modal содержит текущий и новый остаток, дату, причину и явно сообщает, что это не доход/расход.
- В `OperationsPage` у подтверждённого расхода добавить действие «В накопления» с подтверждением; у преобразованного перевода — «Вернуть в расходы». В строке отображать `исходный счёт → Накопления` и категорию «Накопления».
- Исключить системный счёт из выбора счёта выписки и форм expense/income/refund; оставить его доступным в ручной форме transfer и фильтрах.
## 2. Альфа debit: «Копилка для сдачи»
- В `backend/app/imports/adapters/base.py` добавить к канонической `SourceRow` необязательную подсказку решения `savings_transfer`; сохранить её в source record как начальное review decision.
- В `alfa_xlsx.py` распознавать нормализованную фразу `Перечисление средств в рамках услуги "Копилка для сдачи" со счета <20 цифр> на счет <20 цифр>`, проверять, что счёт-источник совпадает с номером счёта в заголовке выписки, и применять правило только к исходящей posted-строке. Не хардкодить пользовательские номера `408...`/`306...`; они служат контрактным примером, а не конфигурацией одного пользователя.
- Поднять `ALFA_DEBIT_XLSX_VERSION`, так как канонический результат парсинга меняется.
- Добавить решение `savings_transfer` в import DTO/UI как «Перевод в накопления». Оно не использует `DedupCandidate` и не смешивается с существующим решением `transfer`, которое объединяет две банковские стороны.
- При confirm для `savings_transfer` создавать перевод со счёта привязанной выписки на системный счёт накоплений, назначать системную категорию и обычный source link. До подтверждения пользователь может сменить решение на `create` или `discard`.
- Сохранить атомарные confirm/revert и provenance: отмена импорта удаляет созданный перевод; повторный parse даёт то же решение; существующая дедупликация по external id продолжает выигрывать у создания дубля.
- Контрактные тесты покрывают точное совпадение, пробелы/регистр/кавычки, несовпавший source account, входящую строку, HOLD и похожее описание без полного шаблона. Использовать только sanitized synthetic fixture.
## 3. Управление категориями
### Backend-контракт
- Оставить `GET /budget/categories?month=` для месячных вычисленных значений и добавить отдельный каталог определений `GET /budget/category-definitions?includeArchived=true`.
- Каталог возвращает `id`, literal `name` либо чистый `nameKey` без префикса `category.`, `kind`, `systemKey`, `linkedAccountId`, `archived`.
- `POST /budget/categories` возвращает полный definition DTO, trim-ит имя и создаёт только `monthly | savings_envelope`.
- Добавить `PATCH /budget/category-definitions/{id}` с optional `name` и `archived`. Системные категории отклоняют оба изменения; `archived=false` восстанавливает пользовательскую категорию.
- Архивирование запрещает новые назначения/обеспечение/категоризацию, но не удаляет историю. Расчёт бюджета обязан продолжать учитывать архивную категорию, если в выбранном периоде есть plan/funding/operations, чтобы расходы не исчезали из totals.
- Унифицировать сериализацию `name/nameKey` в budgeting, ledger и analytics; frontend должен получать `savings`, а не `category.savings`.
### Дизайн и вёрстка
- В секции категорий `BudgetPage` сделать заголовок с действием «Управлять категориями». Не добавлять новый пункт глобальной навигации.
- Использовать существующий drawer/modal visual language. Верхняя часть: компактная форма «Название + тип + Создать». Ниже: вкладки «Активные / Архив», плотный список с badge типа и inline-действиями rename/archive/restore.
- Системные «Накопления» и резервы кредиток отмечать badge «Системная»; вместо действий показывать короткое объяснение защиты.
- Пустое состояние должно вести к созданию первой категории, а не предлагать только копирование прошлого месяца.
- На mobile drawer занимает viewport, действия не зависят от hover, все inputs имеют labels, confirm archive получает фокус и возвращает его инициатору.
- Добавить API methods, TanStack mutations, точечную invalidation `budget/operations/analytics`, success/error toasts и RU/EN parity.
## 4. Понятные и устранимые блокировки
### Контракт и представление
- Сделать `BudgetBlocker` discriminated union для известных кодов и данных: `count` для импортов/HOLD/расходов, `categories` для отрицательных конвертов. Машинный код остаётся в API и диагностике, но не является пользовательским текстом.
- Вынести общий frontend-компонент/formatter для `BudgetPage` и `OverviewPage`; отказаться от динамического lookup, который при несовпадении показывает raw enum.
- Показать отдельные строки причин с count и CTA:
- `IMPORTS_UNRESOLVED`: «Не завершены импорты выписок: N» → очередь разбора;
- `IMPORT_UNRESOLVED_HOLDS`: «Неподтверждённые операции без решения: N» → импорт(ы) с HOLD;
- `BUDGET_UNCATEGORIZED_EXPENSES`: «Расходы без категории: N» → очередь разбора выбранного месяца;
- `BUDGET_NEGATIVE_ENVELOPE`: перечислить категории → секция категорий.
- Передавать выбранный месяц в ссылке `/review?month=YYYY-MM`; ReviewQueue использует его для date filters и категорий, а не всегда `currentMonth()`.
### Разрешение HOLD на frontend
- Расширить import summary агрегированным `unresolvedHoldCount`, с optional month filter для ReviewQueue, чтобы confirmed-импорты с HOLD не исчезали из списка работ.
- Расширить `ImportRow` полями `bankStatus` и `holdResolution`. Для HOLD не показывать обычный select решения.
- В confirmed import для нерешённого HOLD показать два действия существующего endpoint: «Операция списалась» (`confirm_as_posted`) и «Операция отменена» (`discard_as_cancelled`). Оба открывают modal с обязательной причиной; первый использует mapped account документа.
- После разрешения инвалидировать import detail/list, operations, accounts, budget и review queue. Закрытый месяц возвращает понятную ошибку о необходимости reopen.
## Отказы и граничные случаи
- Любая команда проверяет пространство, активность счетов, статус операции и открытость периода до изменения проводок.
- Нельзя получить отрицательный target balance накоплений; нулевая корректировка отклоняется как no-op.
- Повторное создание системных ресурсов идемпотентно; конфликт/дубликат системного ключа приводит к явной ошибке, а не выбору случайной записи.
- Неизвестный blocker получает нейтральный локализованный fallback и логируемый code, но raw enum не показывается вместо сообщения.
- Авто-правило «Копилки» fails closed: при неполном совпадении остаётся обычный расход, который пользователь может вручную отправить в накопления.
- Архивированные категории недоступны новым операциям, но продолжают отображаться в истории и участвовать в уже созданных фактах.
## Проверка
1. Backend domain/property tests: две проводки savings-transfer суммируются в ноль; adjustment имеет одну signed posting; convert/revert восстанавливает тип, категорию, баланс и provenance; closed period и cross-space операции отклоняются.
2. Migration tests/manual check: upgrade существующей БД создаёт ровно одну пару на пространство; fresh install не дублирует её; пользовательские одноимённые записи не захватываются.
3. Budget tests: перевод в накопления уменьшает pool и увеличивает system envelope; обратный перевод делает обратное; adjustment не меняет pool/cash-flow; archived history не исчезает; blockers содержат корректные counts.
4. Import tests: Alfa parser contract и parser version, confirm/revert, idempotency/dedup, HOLD и near-miss cases.
5. API tests: CSRF для новых mutations, OpenAPI critical paths, system-resource protection, category archive/restore и HOLD resolution.
6. Frontend Vitest: account adjustment modal, expense convert/revert, category manager CRUD/archive, blocker text/count/links для RU/EN, month-aware ReviewQueue и HOLD modal.
7. Обновить `frontend/openapi.local.json` и `frontend/src/api/generated/schema.d.ts`, затем выполнить `make backend-check`, `make frontend-check` и полный `make check`.
8. По обязательному дизайн-процессу сверить реализацию с выбранными слоями в `design.pen`, затем со скиллом `impeccable` проверить в браузере desktop/mobile: Budget category drawer, Accounts adjustment, Operations actions, Budget/Overview blocker panel, ReviewQueue и confirmed ImportDetail; проверить keyboard flow, responsive states и отсутствие console errors.
9. После исходных изменений выполнить `graphify update .`.
## Вне этого края
- Несколько системных счетов накоплений, цели со сроком/суммой, иерархия или split категорий.
- Автоматические правила для банкоматов, P2P, криптовалют и произвольных описаний; для них остаётся ручная разметка расхода.
- Преобразование income/refund в перевод и универсальный редактор типа любой операции.
- Пересчёт или автоматическая мутация уже закрытой истории без явного reopen.
@@ -0,0 +1,194 @@
"""Savings system resources, adjustments and reversible conversions."""
import uuid
import sqlalchemy as sa
from alembic import op
revision = "0003"
down_revision = "0002"
branch_labels = None
depends_on = None
OLD_ACCOUNT_KINDS = "kind IN ('cash', 'debit', 'credit')"
NEW_ACCOUNT_KINDS = "kind IN ('cash', 'debit', 'credit', 'savings')"
OLD_OPERATION_TYPES = "type IN ('expense', 'income', 'transfer', 'refund')"
NEW_OPERATION_TYPES = "type IN ('expense', 'income', 'transfer', 'refund', 'adjustment')"
def _has_column(bind, table: str, column: str) -> bool:
inspector = sa.inspect(bind)
return column in {item["name"] for item in inspector.get_columns(table)}
def _check_constraints(bind, table: str) -> list[dict]:
inspector = sa.inspect(bind)
return inspector.get_check_constraints(table)
def _has_unique_constraint(bind, table: str, name: str) -> bool:
inspector = sa.inspect(bind)
return any(
(item.get("name") or "") == name for item in inspector.get_unique_constraints(table)
)
def _normalized_sqltext(expression: str) -> str:
return "".join(expression.lower().split()).strip("()")
def _replace_check_constraint(bind, table: str, bare_name: str, expression: str) -> None:
"""Swap a check constraint for a new value list, dialect-safely.
Fresh databases already carry the new constraint from revision 0001's
metadata materialization, so an exact (normalized) expression match is a
no-op. Otherwise the constraint is dropped and recreated by bare name and
the metadata naming convention expands it (batch mode re-applies the
``ck_%(table_name)s_%(constraint_name)s`` template).
"""
wanted = _normalized_sqltext(expression)
existing = next(
(
item
for item in _check_constraints(bind, table)
if (item.get("name") or "").endswith(bare_name)
),
None,
)
if existing is not None and _normalized_sqltext(existing.get("sqltext") or "") == wanted:
return
with op.batch_alter_table(table) as batch_op:
if existing is not None:
batch_op.drop_constraint(bare_name, type_="check")
batch_op.create_check_constraint(bare_name, expression)
def _space_ids(bind) -> list[str]:
return [str(row[0]) for row in bind.execute(sa.text("SELECT id FROM financial_spaces"))]
def _has_row(bind, query: str) -> bool:
return bind.execute(sa.text(query)).first() is not None
def upgrade() -> None:
bind = op.get_bind()
# Revision 0001 materializes the schema from live model metadata, so a
# database created fresh already carries these objects; guard every step.
if not _has_column(bind, "accounts", "system_key"):
op.add_column("accounts", sa.Column("system_key", sa.String(length=64), nullable=True))
if not _has_unique_constraint(bind, "accounts", "uq_accounts_space_system_key"):
with op.batch_alter_table("accounts") as batch_op:
batch_op.create_unique_constraint(
"uq_accounts_space_system_key", ["space_id", "system_key"]
)
_replace_check_constraint(bind, "accounts", "kind_valid", NEW_ACCOUNT_KINDS)
if not _has_column(bind, "operations", "converted_from_expense"):
op.add_column(
"operations",
sa.Column(
"converted_from_expense",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
if not _has_column(bind, "operations", "previous_category_id"):
op.add_column(
"operations", sa.Column("previous_category_id", sa.Uuid(), nullable=True)
)
_replace_check_constraint(bind, "operations", "type_valid", NEW_OPERATION_TYPES)
if not _has_unique_constraint(bind, "categories", "uq_categories_system_pair"):
with op.batch_alter_table("categories") as batch_op:
batch_op.create_unique_constraint(
"uq_categories_system_pair",
["space_id", "system_key", "linked_account_id"],
)
# One system savings account + envelope per existing space. Fresh installs
# have no spaces at migration time and get the pair at space creation.
from sqlalchemy.orm import Session
from app.db.session import set_space_context
from app.ledger.savings import ensure_savings_resources
session = Session(bind=bind)
try:
for space_id in _space_ids(bind):
set_space_context(session, space_id)
ensure_savings_resources(session, space_id=uuid.UUID(hex=space_id))
session.flush()
finally:
session.close()
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
# Refuse before any mutation when 0002 cannot represent the current data.
# This prevents SQLite batch DDL from leaving a partially downgraded schema.
if inspector.has_table("accounts"):
incompatible_queries = {
"adjustment operations": "SELECT 1 FROM operations WHERE type = 'adjustment' LIMIT 1",
"converted expenses": (
"SELECT 1 FROM operations WHERE converted_from_expense = true LIMIT 1"
),
"savings account operations": (
"SELECT 1 FROM operations o JOIN accounts a "
"ON a.id = o.account_id OR a.id = o.counter_account_id "
"WHERE a.system_key = 'savings' LIMIT 1"
),
"savings account postings": (
"SELECT 1 FROM postings p JOIN accounts a ON a.id = p.account_id "
"WHERE a.system_key = 'savings' LIMIT 1"
),
"savings category operations": (
"SELECT 1 FROM operations o JOIN categories c ON c.id = o.category_id "
"WHERE c.system_key = 'savings' LIMIT 1"
),
"savings category funding": (
"SELECT 1 FROM funding_movements f JOIN categories c ON c.id = f.category_id "
"WHERE c.system_key = 'savings' LIMIT 1"
),
"savings category plans": (
"SELECT 1 FROM plan_assignments p JOIN categories c ON c.id = p.category_id "
"WHERE c.system_key = 'savings' LIMIT 1"
),
"non-system savings accounts": (
"SELECT 1 FROM accounts WHERE kind = 'savings' "
"AND (system_key IS NULL OR system_key <> 'savings') LIMIT 1"
),
}
blocked_by = [
label for label, query in incompatible_queries.items() if _has_row(bind, query)
]
if blocked_by:
raise RuntimeError(
"Cannot downgrade savings migration while incompatible data exists: "
+ ", ".join(blocked_by)
)
# The preflight proved these system resources have no dependent history.
bind.execute(
sa.text("DELETE FROM categories WHERE system_key = 'savings'")
)
bind.execute(
sa.text("DELETE FROM accounts WHERE system_key = 'savings'")
)
if _has_unique_constraint(bind, "categories", "uq_categories_system_pair"):
with op.batch_alter_table("categories") as batch_op:
batch_op.drop_constraint("uq_categories_system_pair", type_="unique")
_replace_check_constraint(bind, "operations", "type_valid", OLD_OPERATION_TYPES)
if _has_column(bind, "operations", "previous_category_id"):
op.drop_column("operations", "previous_category_id")
if _has_column(bind, "operations", "converted_from_expense"):
op.drop_column("operations", "converted_from_expense")
_replace_check_constraint(bind, "accounts", "kind_valid", OLD_ACCOUNT_KINDS)
if _has_unique_constraint(bind, "accounts", "uq_accounts_space_system_key"):
with op.batch_alter_table("accounts") as batch_op:
batch_op.drop_constraint("uq_accounts_space_system_key", type_="unique")
if _has_column(bind, "accounts", "system_key"):
op.drop_column("accounts", "system_key")
+4 -4
View File
@@ -22,8 +22,8 @@ def category_spend(
return [
{
"categoryId": row["category_id"],
"name": row["category"] if not row["category"].startswith("category.") else None,
"nameKey": row["category"] if row["category"].startswith("category.") else None,
"name": row["name"],
"nameKey": row["name_key"],
"amountKopecks": row["amount_kopecks"],
}
for row in rows
@@ -75,8 +75,8 @@ def plan_fact(month: str, db: Db, context: Context) -> dict:
"items": [
{
"categoryId": row["category_id"],
"name": row["name"] if not str(row["name"]).startswith("category.") else None,
"nameKey": row["name"] if str(row["name"]).startswith("category.") else None,
"name": row.get("name"),
"nameKey": row.get("name_key"),
"plannedKopecks": row["planned"],
"actualKopecks": row["spent"],
}
+12 -7
View File
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
from app.budgeting.models import PeriodClosure
from app.budgeting.service import compute_period_state, get_or_create_period
from app.categorization.service import resolve_category_name
from app.categorization.service import resolve_category_name, resolve_category_name_key
from app.ledger.models import Account, Category, Operation
from app.ledger.service import account_balance
@@ -40,16 +40,21 @@ def category_spend(
)
totals[expense.category_id] += expense.amount_kopecks - int(refunded or 0)
categories = {
item.id: resolve_category_name(item)
item.id: item
for item in db.query(Category).filter_by(space_id=space_id).all()
}
return [
{
"category_id": category_id,
"category": (
categories.get(category_id, "category.unnamed")
if category_id is not None
else "category.uncategorized"
"name": (
resolve_category_name(categories[category_id])
if category_id is not None and category_id in categories
else None
),
"name_key": (
resolve_category_name_key(categories[category_id])
if category_id is not None and category_id in categories
else "uncategorized"
),
"amount_kopecks": amount,
}
@@ -66,7 +71,7 @@ def cash_flow(db: Session, *, space_id: uuid.UUID, date_from: date, date_to: dat
totals: dict[tuple[date, str], int] = defaultdict(int)
for operation in operations:
effective = operation.posted_on or operation.occurred_on
if not date_from <= effective <= date_to or operation.type == "transfer":
if not date_from <= effective <= date_to or operation.type in {"transfer", "adjustment"}:
continue
signed = (
operation.amount_kopecks
+67 -23
View File
@@ -1,11 +1,12 @@
import uuid
from datetime import date
from typing import Literal
from typing import Annotated, Literal
from fastapi import APIRouter
from fastapi import APIRouter, Query
from pydantic import BaseModel, Field
from app.budgeting import service
from app.categorization import service as categorization
from app.dependencies import Context, Csrf, Db
router = APIRouter(prefix="/budget", tags=["budget"])
@@ -117,35 +118,78 @@ def categories(month: str, db: Db, context: Context) -> list[dict]:
year, month_number = parse_month(month)
period = period_for(db, context, year, month_number)
state = service.compute_period_state(db, space_id=context.space.id, period=period)
rows = state["monthly_categories"] + [
{
"category_id": row["category_id"],
"name": row["name"],
"planned": 0,
"funded": row["funded_total"],
"spent": row["funded_total"] - row["available"],
"available": row["available"],
}
for row in state["envelopes"]
]
rows = state["monthly_categories"] + state["envelopes"]
category_by_id = {
str(item.id): item for item in service.list_categories(db, space_id=context.space.id)
str(item.id): item
for item in service.list_categories(db, space_id=context.space.id, include_archived=True)
}
return [
result = []
for row in rows:
category = category_by_id[row["category_id"]]
result.append(
{
"id": row["category_id"],
"name": row["name"] if not str(row["name"]).startswith("category.") else None,
"nameKey": row["name"] if str(row["name"]).startswith("category.") else None,
"kind": category_by_id[row["category_id"]].kind,
"archived": False,
"creditAccountId": category_by_id[row["category_id"]].linked_account_id,
"name": row["name"],
"nameKey": row["name_key"],
"kind": category.kind,
"system": row["system"],
"archived": category.archived,
"creditAccountId": category.linked_account_id,
"plannedKopecks": row["planned"],
"fundedKopecks": row["funded"],
"spentKopecks": row["spent"],
"availableKopecks": row["available"],
}
for row in rows
]
)
return result
def category_definition_json(category) -> dict:
return {
"id": category.id,
"name": categorization.resolve_category_name(category),
"nameKey": categorization.resolve_category_name_key(category),
"kind": category.kind,
"systemKey": category.system_key,
"linkedAccountId": category.linked_account_id,
"archived": category.archived,
}
@router.get("/category-definitions")
def category_definitions(
db: Db,
context: Context,
include_archived: Annotated[bool, Query(alias="includeArchived")] = False,
) -> list[dict]:
items = service.list_categories(
db, space_id=context.space.id, include_archived=include_archived
)
return [category_definition_json(item) for item in items]
class CategoryDefinitionPatchBody(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=200)
archived: bool | None = None
@router.patch("/category-definitions/{category_id}")
def patch_category_definition(
category_id: uuid.UUID,
body: CategoryDefinitionPatchBody,
db: Db,
context: Context,
_csrf: Csrf,
) -> dict:
category = service.update_category_definition(
db,
space_id=context.space.id,
user=context.user,
category_id=category_id,
name=body.name,
archived=body.archived,
)
return category_definition_json(category)
@router.post("/categories", status_code=201)
@@ -157,7 +201,7 @@ def create_category(body: CategoryBody, db: Db, context: Context, _csrf: Csrf) -
kind=body.kind,
name=body.name,
)
return {"id": category.id, "kind": category.kind, "name": body.name}
return category_definition_json(category)
@router.patch("/categories/{category_id}")
+106 -16
View File
@@ -19,7 +19,7 @@ from app.budgeting.models import (
from app.categorization.service import resolve_category_name
from app.errors import bad_request, conflict, not_found
from app.identity.models import User
from app.ledger.models import Account, Category, Operation
from app.ledger.models import Account, Category, Operation, Posting
from app.ledger.service import ensure_period_open_for_date as _ledger_guard # noqa: F401
@@ -96,8 +96,6 @@ def _closure_snapshot(db: Session, *, space_id: uuid.UUID, year: int, month: int
def _balance_until(db: Session, account: Account, end: date) -> int:
from app.ledger.models import Posting
total = (
db.query(func.coalesce(func.sum(Posting.amount_kopecks), 0))
.join(Operation, Operation.id == Posting.operation_id)
@@ -125,6 +123,10 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri
- a refund on a credit card reduces debt and releases any surplus reserve;
- a same-period refund restores the parent purchase's category; a refund on
an uncategorized expense restores the pool;
- transfers into the system savings envelope are carved out of the pool as
its backing; transfers back out return to the pool;
- adjustments reconcile a stored balance through one signed posting and are
excluded from income/expense totals, category spend and cash flow;
- monthly categories fold their residual into the pool at close; savings
envelopes and credit reserves persist via all-time/ snapshot carries.
"""
@@ -132,7 +134,10 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri
accounts = db.query(Account).filter_by(space_id=space_id).all()
account_by_id = {a.id: a for a in accounts}
categories = db.query(Category).filter_by(space_id=space_id, archived=False).all()
savings_account = next((a for a in accounts if a.system_key == "savings"), None)
# Archived categories stay in the computation: any plan/funding/operations
# history in the selected period must keep affecting totals.
categories = db.query(Category).filter_by(space_id=space_id).all()
ops = (
db.query(Operation)
@@ -155,6 +160,9 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri
payments_by_account: dict[Any, int] = defaultdict(int)
refunds_to_credit_by_account: dict[Any, int] = defaultdict(int)
refund_back_uncategorized_period = 0
# Net transfers into the system savings envelope for this month (+ into,
# - back out). Adjustments are excluded from every flow aggregate.
savings_net_period = 0
for op in ops:
if op.type == "income":
@@ -177,6 +185,11 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri
)
if counter is not None and counter.kind == "credit":
payments_by_account[counter.id] += op.amount_kopecks
source = account_by_id.get(op.account_id)
if counter is not None and counter.system_key == "savings":
savings_net_period += op.amount_kopecks
if source is not None and source.system_key == "savings":
savings_net_period -= op.amount_kopecks
elif op.type == "refund":
parent_account = account_by_id.get(op.account_id)
parent = db.get(Operation, op.parent_operation_id) if op.parent_operation_id else None
@@ -224,7 +237,7 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri
# All-time envelope consumption up to period end.
for cat in categories:
if cat.kind != "savings_envelope":
if cat.kind != "savings_envelope" or cat.system_key == "savings":
continue
consumed = (
db.query(func.coalesce(func.sum(Operation.amount_kopecks), 0))
@@ -323,6 +336,7 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri
- (payments_total - releases_payment_total)
+ surplus_release_total
- funding_total
- savings_net_period
)
monthly_rows: list[dict[str, Any]] = []
@@ -336,22 +350,65 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri
{
"category_id": str(cat.id),
"name": resolve_category_name(cat),
"name_key": cat.system_key,
"archived": cat.archived,
"planned": 0,
"funded": funded_period[cat.id],
"spent": monthly_consumption[cat.id],
"available": available,
}
)
# All-time net transfer movement into the system savings account (+ in, - out).
savings_net_all_time = 0
if savings_account is not None:
savings_net_all_time = int(
db.query(func.coalesce(func.sum(Posting.amount_kopecks), 0))
.join(Operation, Operation.id == Posting.operation_id)
.filter(
Posting.account_id == savings_account.id,
Operation.status == "confirmed",
Operation.type == "transfer",
)
.scalar()
or 0
)
envelope_rows: list[dict[str, Any]] = []
for cat in categories:
if cat.kind != "savings_envelope" or cat.system_key == "credit_reserve":
continue
if cat.system_key == "savings":
# System savings envelope: funded shows the net transfer movement of
# the selected month, spent is always zero and available tracks the
# linked account balance at month end (adjustments included).
available = _balance_until(db, savings_account, end) if savings_account else 0
envelope_rows.append(
{
"category_id": str(cat.id),
"name": resolve_category_name(cat),
"name_key": cat.system_key,
"archived": cat.archived,
"system": True,
"funded_total": savings_net_all_time,
"planned": 0,
"funded": savings_net_period,
"spent": 0,
"available": available,
}
)
continue
available = funded_all_time[cat.id] - envelope_consumption_total[cat.id]
envelope_rows.append(
{
"category_id": str(cat.id),
"name": resolve_category_name(cat),
"name_key": cat.system_key,
"archived": cat.archived,
"system": cat.system_key is not None,
"funded_total": funded_all_time[cat.id],
"planned": 0,
"funded": funded_period[cat.id],
"spent": funded_all_time[cat.id] - available,
"available": available,
}
)
@@ -376,7 +433,11 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri
matched_by_expected[m.expected_income_id] += m.amount_kopecks
uncategorized_count = sum(1 for op in ops if op.type == "expense" and op.category_id is None)
negative_envelopes = [str(row["name"]) for row in envelope_rows if int(row["available"]) < 0]
negative_envelopes = [
str(row["name"] or row["name_key"] or row["category_id"])
for row in envelope_rows
if int(row["available"]) < 0
]
warnings = []
if planned_total > carry_pool + expected_total:
@@ -508,7 +569,10 @@ def create_category(
) -> Category:
if kind not in ("monthly", "savings_envelope"):
raise bad_request("BUDGET_CATEGORY_KIND_INVALID", "Unknown category kind")
category = Category(space_id=space_id, kind=kind, custom_name=name)
trimmed = name.strip()
if not trimmed:
raise bad_request("BUDGET_CATEGORY_NAME_REQUIRED", "Category name is required")
category = Category(space_id=space_id, kind=kind, custom_name=trimmed[:200])
db.add(category)
db.flush()
audit.record(
@@ -554,7 +618,7 @@ def archive_category(
db: Session, *, space_id: uuid.UUID, user: User, category_id: uuid.UUID
) -> Category:
category = get_category(db, space_id=space_id, category_id=category_id)
if category.system_key == "credit_reserve":
if category.system_key is not None:
raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be archived")
category.archived = True
audit.record(
@@ -567,20 +631,40 @@ def archive_category(
return category
def rename_category(
db: Session, *, space_id: uuid.UUID, user: User, category_id: uuid.UUID, name: str
def update_category_definition(
db: Session,
*,
space_id: uuid.UUID,
user: User,
category_id: uuid.UUID,
name: str | None = None,
archived: bool | None = None,
) -> Category:
"""Rename and/or archive/restore one user category definition.
System categories reject both changes; ``archived=False`` restores an
archived user category. History (plans, funding, operations) is preserved.
"""
category = get_category(db, space_id=space_id, category_id=category_id)
if category.system_key is not None:
category.name_override = name
else:
category.custom_name = name
raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be renamed or archived")
changes: dict[str, bool | str] = {}
if name is not None:
trimmed = name.strip()
if not trimmed:
raise bad_request("BUDGET_CATEGORY_NAME_REQUIRED", "Category name is required")
category.custom_name = trimmed[:200]
changes["renamed"] = True
if archived is not None:
category.archived = archived
changes["archived"] = archived
if changes:
audit.record(
db,
event_type="category.renamed",
event_type="category.updated",
space_id=space_id,
user_id=user.id,
detail={"category_id": str(category.id)},
detail={"category_id": str(category.id), **changes},
)
return category
@@ -597,6 +681,8 @@ def set_assignments(
result = []
for item in items:
category = get_category(db, space_id=space_id, category_id=item["category_id"])
if category.system_key is not None:
raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be planned")
if category.archived:
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived")
planned = item["planned_kopecks"]
@@ -713,7 +799,11 @@ def fund(
raise bad_request("BUDGET_FUNDING_KIND_INVALID", "Unknown funding kind")
if amount_kopecks <= 0:
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
get_category(db, space_id=space_id, category_id=category_id)
category = get_category(db, space_id=space_id, category_id=category_id)
if category.system_key is not None:
raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be funded")
if category.archived:
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived")
movement = FundingMovement(
space_id=space_id,
period_id=period.id,
+12 -5
View File
@@ -5,15 +5,22 @@ from app.errors import conflict, not_found
from app.ledger.models import Category
def resolve_category_name(category: Category) -> str:
"""Standard categories resolve to a translation key; overrides/customs are literal."""
def resolve_category_name(category: Category) -> str | None:
"""Literal display name, or None when the name localizes via the system key.
System categories serialize as a pure ``nameKey`` (for example ``savings``),
never as a ``category.*`` prefixed string.
"""
if category.custom_name is not None:
return category.custom_name
if category.name_override is not None:
return category.name_override
if category.system_key is not None:
return f"category.{category.system_key}"
return "category.unnamed"
return None
def resolve_category_name_key(category: Category) -> str | None:
"""Pure localization key for system categories; None for user categories."""
return category.system_key
def get_income_source(db: Session, *, space_id, income_source_id) -> IncomeSource:
+44 -1
View File
@@ -29,6 +29,40 @@ DESCRIPTION_HEADERS = ("описание", "назначение", "коммен
CATEGORY_HEADERS = ("категория",)
ACCOUNT_HEADERS = ("номер карты", "номер счёта", "карта")
# Alfa «Копилка для сдачи» round-up service. The rule fails closed: only an
# exact normalized phrase with a source account equal to the statement account
# and an outgoing posted row produces the savings_transfer decision hint. The
# account numbers in the pattern are part of the bank's phrasing contract and
# are matched against the statement header, never hardcoded per user.
SAVINGS_SERVICE_RE = re.compile(
r"перечисление средств в рамках услуги \"копилка для сдачи\" "
r"со счета (\d{20}) на счет (\d{20})"
)
def _savings_rule_text(value: str) -> str:
text = value.replace("\u00a0", " ").replace("ё", "е").replace("Ё", "Е").lower()
text = re.sub(r"[«»„“”\"']", '"', text)
return " ".join(text.split())
def detect_savings_transfer(
description: str | None,
*,
direction: str,
bank_status: str,
statement_account: str,
) -> bool:
if direction != DIRECTION_OUT or bank_status != BANK_STATUS_POSTED or not description:
return False
match = SAVINGS_SERVICE_RE.search(_savings_rule_text(description))
if match is None:
return False
statement_digits = re.sub(r"\D", "", statement_account or "")
if not statement_digits:
return False
return match.group(1) == statement_digits
class AlfaXlsxAdapterBase:
"""Alfa statement contract: XLSX with a header row and bank marker 'Альфа'."""
@@ -277,7 +311,9 @@ class AlfaDebitXlsxAdapter:
header_idx, columns = _debit_find_header(self, rows)
meta = _debit_header_metadata(self, rows, header_idx)
period_start, period_end = _debit_statement_period(self, rows, header_idx)
parsed = _debit_operation_rows(self, rows, header_idx, columns)
parsed = _debit_operation_rows(
self, rows, header_idx, columns, statement_account=meta["account"]
)
if not parsed:
raise UnsupportedStatementLayout("no operation rows found in Alfa statement")
return StatementDraft(
@@ -413,6 +449,7 @@ def _debit_operation_rows(
rows: list[tuple],
header_idx: int,
columns: dict[str, int],
statement_account: str = "",
) -> list[SourceRow]:
parsed: list[SourceRow] = []
separated_from_operations = False
@@ -475,6 +512,12 @@ def _debit_operation_rows(
row_ref=f"sheet0:row{offset}",
warnings=tuple(warnings),
bank_status=bank_status,
savings_transfer=detect_savings_transfer(
description,
direction=direction,
bank_status=bank_status,
statement_account=statement_account,
),
)
)
separated_from_operations = False
+2 -1
View File
@@ -16,7 +16,7 @@ BANK_STATUSES = (BANK_STATUS_POSTED, BANK_STATUS_HOLD)
PARSER_VERSION = "1"
# Per-contract versions: bumped for the rewritten evidenced contracts so old
# parse runs can never be confused with the new canonical output.
ALFA_DEBIT_XLSX_VERSION = "2"
ALFA_DEBIT_XLSX_VERSION = "3"
OZON_DEBIT_PDF_VERSION = "2"
ROCKETBANK_DEBIT_PDF_VERSION = "2"
@@ -35,6 +35,7 @@ class SourceRow:
row_ref: str
warnings: tuple[str, ...] = ()
bank_status: str = BANK_STATUS_POSTED
savings_transfer: bool = False
def __post_init__(self) -> None:
if self.amount_kopecks < 0:
+36 -6
View File
@@ -1,7 +1,7 @@
import uuid
from typing import Annotated, Any
from fastapi import APIRouter, File, Form, UploadFile
from fastapi import APIRouter, File, Form, Query, UploadFile
from pydantic import BaseModel, ConfigDict, Field
from app.dependencies import Context, Csrf, Db
@@ -41,7 +41,7 @@ class HoldResolveBody(BaseModel):
account_id: uuid.UUID | None = Field(default=None, alias="accountId")
def summary(document: Any) -> dict:
def summary(document: Any, *, unresolved_hold_count: int = 0) -> dict:
return {
"id": document.id,
"fileName": document.original_filename,
@@ -52,14 +52,33 @@ def summary(document: Any) -> dict:
"periodFrom": None,
"periodTo": None,
"needsAccountMapping": document.needs_account_mapping,
"unresolvedHoldCount": unresolved_hold_count,
"errorCode": document.error_code,
"createdAt": document.created_at,
}
@router.get("")
def imports(db: Db, context: Context) -> list[dict]:
return [summary(item) for item in service.list_documents(db, space_id=context.space.id)]
def imports(
db: Db,
context: Context,
month: Annotated[str | None, Query()] = None,
) -> list[dict]:
month_filter = service.parse_month_filter(month)
documents = service.list_documents(db, space_id=context.space.id)
hold_counts = service.unresolved_hold_counts(
db,
space_id=context.space.id,
document_ids=[item.id for item in documents],
month=month_filter,
)
result = []
for item in documents:
hold_count = hold_counts.get(item.id, 0)
if month_filter is not None and item.status == "confirmed" and hold_count == 0:
continue
result.append(summary(item, unresolved_hold_count=hold_count))
return result
@router.post("", status_code=202)
@@ -106,7 +125,12 @@ async def upload(
def detail(document_id: uuid.UUID, db: Db, context: Context) -> dict:
raw = service.document_detail(db, space_id=context.space.id, document_id=document_id)
document = service._document(db, space_id=context.space.id, document_id=document_id)
base = summary(document)
base = summary(
document,
unresolved_hold_count=len(
service.unresolved_holds(db, space_id=context.space.id, document=document)
),
)
reconciliation = raw.get("reconciliation") or {}
rows = []
pending_candidate_row_ids: set[uuid.UUID] = set()
@@ -204,6 +228,7 @@ def detail(document_id: uuid.UUID, db: Db, context: Context) -> dict:
"discard": sum(row["decision"] == "discard" for row in rows),
"duplicate": sum(row["decision"] == "duplicate" for row in rows),
"transfer": sum(row["decision"] == "transfer" for row in rows),
"savingsTransfer": sum(row["decision"] == "savings_transfer" for row in rows),
"pending": sum(row["decision"] == "pending" for row in rows),
},
}
@@ -217,7 +242,12 @@ def review(document_id: uuid.UUID, body: ReviewBody, db: Db, context: Context, _
document_id=document_id,
**body.model_dump(),
)
return summary(document)
return summary(
document,
unresolved_hold_count=len(
service.unresolved_holds(db, space_id=context.space.id, document=document)
),
)
@router.patch("/{document_id}/rows/{row_id}")
+116 -1
View File
@@ -5,6 +5,7 @@ from datetime import date, timedelta
from pathlib import Path
from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.audit import service as audit
@@ -164,6 +165,7 @@ def _source_from_row(
or row.direction not in {"in", "out"}
),
record_fp=record_fingerprint(row.occurred_on, abs(row.amount_kopecks), row.description),
review_decision="savings_transfer" if row.savings_transfer else "create",
)
@@ -522,6 +524,72 @@ def list_documents(db: Session, *, space_id: uuid.UUID) -> list[StatementDocumen
)
def unresolved_holds(
db: Session, *, space_id: uuid.UUID, document: StatementDocument
) -> list[SourceRecord]:
"""Unresolved HOLD rows of the document's active parse run."""
if not document.active_parse_run_id:
return []
return (
db.query(SourceRecord)
.outerjoin(HoldResolution, HoldResolution.hold_source_record_id == SourceRecord.id)
.filter(
SourceRecord.space_id == space_id,
SourceRecord.parse_run_id == document.active_parse_run_id,
SourceRecord.bank_status == "hold",
HoldResolution.id.is_(None),
)
.all()
)
def unresolved_hold_counts(
db: Session,
*,
space_id: uuid.UUID,
document_ids: list[uuid.UUID],
month: tuple[int, int] | None = None,
) -> dict[uuid.UUID, int]:
"""Count unresolved HOLD rows for active parse runs in one query."""
if not document_ids:
return {}
query = (
db.query(StatementDocument.id, func.count(SourceRecord.id))
.join(
SourceRecord,
(SourceRecord.document_id == StatementDocument.id)
& (SourceRecord.parse_run_id == StatementDocument.active_parse_run_id),
)
.outerjoin(HoldResolution, HoldResolution.hold_source_record_id == SourceRecord.id)
.filter(
StatementDocument.space_id == space_id,
StatementDocument.id.in_(document_ids),
SourceRecord.bank_status == "hold",
HoldResolution.id.is_(None),
)
)
if month is not None:
year, month_number = month
start = date(year, month_number, 1)
end = date(year + 1, 1, 1) if month_number == 12 else date(year, month_number + 1, 1)
query = query.filter(SourceRecord.occurred_on >= start, SourceRecord.occurred_on < end)
return {document_id: count for document_id, count in query.group_by(StatementDocument.id)}
def parse_month_filter(value: str | None) -> tuple[int, int] | None:
if value is None:
return None
try:
year_text, month_text = value.split("-", 1)
year, month = int(year_text), int(month_text)
except (ValueError, AttributeError) as exc:
raise bad_request("IMPORT_MONTH_INVALID", "Month filter must use YYYY-MM") from exc
if not 1 <= month <= 12 or year < 2000:
raise bad_request("IMPORT_MONTH_INVALID", "Month filter must use YYYY-MM")
return year, month
def document_detail(db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID) -> dict:
document = _document(db, space_id=space_id, document_id=document_id)
records = []
@@ -622,8 +690,17 @@ def decide_source_record(
)
if source is None:
raise not_found("IMPORT_SOURCE_RECORD_NOT_FOUND", "Import row not found")
if decision not in {"create", "discard", "duplicate", "transfer"}:
if decision not in {"create", "discard", "duplicate", "transfer", "savings_transfer"}:
raise conflict("IMPORT_DECISION_INVALID", "Unsupported row decision")
if source.bank_status == "hold" and decision != "discard":
raise conflict("IMPORT_DECISION_INVALID", "HOLD rows are resolved via hold resolution")
if decision == "savings_transfer" and (
source.bank_status != "posted" or source.direction != "out"
):
raise conflict(
"IMPORT_DECISION_INVALID",
"Only posted outflows can be transferred to savings",
)
candidates = db.query(DedupCandidate).filter_by(source_record_id=source.id).all()
matching = [item for item in candidates if item.candidate_kind == decision]
if decision == "duplicate" and duplicate_operation_id is None and len(matching) == 1:
@@ -684,6 +761,11 @@ def set_review_confirmation(
account = db.query(Account).filter_by(id=account_id, space_id=space_id).one_or_none()
if account is None:
raise not_found("LEDGER_ACCOUNT_NOT_FOUND", "Account not found")
if account.system_key is not None:
raise conflict(
"IMPORT_SYSTEM_ACCOUNT_INVALID",
"System accounts cannot be used as statement accounts",
)
if confirmed_without_full_reconciliation and not reason:
raise conflict("IMPORT_RECONCILIATION_REASON_REQUIRED", "A confirmation reason is required")
document.account_id = account.id
@@ -988,6 +1070,39 @@ def confirm_document(
]
)
continue
if source.review_decision == "savings_transfer":
from app.ledger.savings import ensure_savings_resources
savings_account, _savings_category = ensure_savings_resources(
db, space_id=space_id
)
if document.account_id == savings_account.id:
raise conflict(
"IMPORT_SAVINGS_SOURCE_INVALID",
"The mapped statement account cannot be the savings account itself",
)
assert source.occurred_on is not None
operation = create_transfer(
db,
space_id=space_id,
user=user,
source_account_id=document.account_id,
target_account_id=savings_account.id,
amount_kopecks=source.amount_kopecks,
occurred_on=source.occurred_on,
posted_on=source.posted_on,
description=source.description,
request_key=f"import-savings:{source.id}",
)
db.add(
SourceRecordLink(
space_id=space_id,
source_record_id=source.id,
operation_id=operation.id,
kind="created",
)
)
continue
if source.review_decision == "duplicate" and source.duplicate_operation_id:
duplicate_operation = (
db.query(Operation)
+16 -4
View File
@@ -17,9 +17,9 @@ from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, PkMixin, TimestampMixin
ACCOUNT_KINDS = ("cash", "debit", "credit")
ACCOUNT_KINDS = ("cash", "debit", "credit", "savings")
ACCOUNT_STATUSES = ("active", "archived")
OPERATION_TYPES = ("expense", "income", "transfer", "refund")
OPERATION_TYPES = ("expense", "income", "transfer", "refund", "adjustment")
OPERATION_STATUSES = ("confirmed", "voided")
@@ -28,10 +28,13 @@ class Account(Base, PkMixin, TimestampMixin):
__tablename__ = "accounts"
__table_args__ = (
CheckConstraint("kind IN ('cash', 'debit', 'credit')", name="kind_valid"),
CheckConstraint(
"kind IN ('cash', 'debit', 'credit', 'savings')", name="kind_valid"
),
CheckConstraint("status IN ('active', 'archived')", name="status_valid"),
CheckConstraint("currency = 'RUB'", name="currency_rub_only"),
UniqueConstraint("space_id", "id", name="uq_accounts_space_id"),
UniqueConstraint("space_id", "system_key", name="uq_accounts_space_system_key"),
)
space_id: Mapped[uuid.UUID] = mapped_column(
@@ -45,6 +48,7 @@ class Account(Base, PkMixin, TimestampMixin):
bank_code: Mapped[str | None] = mapped_column(String(32), nullable=True)
masked_identifier: Mapped[str | None] = mapped_column(String(32), nullable=True)
fingerprint: Mapped[str | None] = mapped_column(String(128), nullable=True)
system_key: Mapped[str | None] = mapped_column(String(64), nullable=True)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
@@ -56,7 +60,10 @@ class Operation(Base, PkMixin, TimestampMixin):
__tablename__ = "operations"
__table_args__ = (
CheckConstraint("type IN ('expense', 'income', 'transfer', 'refund')", name="type_valid"),
CheckConstraint(
"type IN ('expense', 'income', 'transfer', 'refund', 'adjustment')",
name="type_valid",
),
CheckConstraint("status IN ('confirmed', 'voided')", name="status_valid"),
CheckConstraint("amount_kopecks > 0", name="amount_positive"),
CheckConstraint(
@@ -114,6 +121,8 @@ class Operation(Base, PkMixin, TimestampMixin):
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
void_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
request_key: Mapped[str | None] = mapped_column(String(120), nullable=True)
converted_from_expense: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
previous_category_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
created_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
@@ -168,6 +177,9 @@ class Category(Base, PkMixin, TimestampMixin):
name="fk_categories_linked_account_tenant",
),
UniqueConstraint("space_id", "id", name="uq_categories_space_id"),
UniqueConstraint(
"space_id", "system_key", "linked_account_id", name="uq_categories_system_pair"
),
)
space_id: Mapped[uuid.UUID] = mapped_column(
+54
View File
@@ -52,6 +52,14 @@ class OperationPatchBody(BaseModel):
category_id: uuid.UUID | None = Field(default=None, alias="categoryId")
class AdjustBalanceBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
target_balance_kopecks: int = Field(ge=0, alias="targetBalanceKopecks")
occurred_on: date = Field(alias="occurredOn")
reason: str = Field(min_length=1, max_length=500)
def account_json(db: Db, account: Any) -> dict:
return {
"id": account.id,
@@ -62,6 +70,8 @@ def account_json(db: Db, account: Any) -> dict:
"openedOn": account.opened_on,
"bankCode": account.bank_code,
"maskedNumber": account.masked_identifier,
"systemKey": account.system_key,
"nameKey": f"account.{account.system_key}" if account.system_key else None,
"status": account.status,
"balanceKopecks": service.account_balance(db, account),
"version": account.version,
@@ -91,6 +101,7 @@ def operation_json(db: Db, operation: Any) -> dict:
"postedOn": operation.posted_on,
"description": operation.description,
"voidReason": operation.void_reason,
"convertedFromExpense": operation.converted_from_expense,
"version": operation.version,
"createdAt": operation.created_at,
}
@@ -276,3 +287,46 @@ def patch_operation(
category_id=body.category_id,
)
return operation_json(db, operation)
@router.post("/accounts/{account_id}/adjust-balance")
def adjust_balance(
account_id: uuid.UUID, body: AdjustBalanceBody, db: Db, context: Context, _csrf: Csrf
) -> dict:
from app.ledger import savings
operation, old_balance, new_balance = savings.adjust_balance(
db,
space_id=context.space.id,
user=context.user,
account_id=account_id,
target_balance_kopecks=body.target_balance_kopecks,
occurred_on=body.occurred_on,
reason=body.reason,
)
payload = operation_json(db, operation)
payload["oldBalanceKopecks"] = old_balance
payload["newBalanceKopecks"] = new_balance
return payload
@router.post("/operations/{operation_id}/savings-transfer")
def savings_transfer(operation_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict:
from app.ledger import savings
operation = savings.convert_expense_to_savings(
db, space_id=context.space.id, user=context.user, operation_id=operation_id
)
return operation_json(db, operation)
@router.post("/operations/{operation_id}/savings-transfer/revert")
def savings_transfer_revert(
operation_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf
) -> dict:
from app.ledger import savings
operation = savings.revert_savings_conversion(
db, space_id=context.space.id, user=context.user, operation_id=operation_id
)
return operation_json(db, operation)
+334
View File
@@ -0,0 +1,334 @@
"""System savings account/envelope, balance adjustments and reversible conversions.
The savings "convert" identity is defined exclusively by ``system_key``:
user-created accounts or categories that merely share the name «Накопления»
are never captured. Every command is tenant-scoped, period-guarded and
audited; balances stay derived (postings only, never stored mutations).
"""
import uuid
from datetime import date
from sqlalchemy.orm import Session
from app.audit import service as audit
from app.errors import bad_request, conflict, not_found
from app.identity.models import User
from app.ledger.models import Account, Category, Operation, Posting
SAVINGS_SYSTEM_KEY = "savings"
SAVINGS_ACCOUNT_NAME = "Накопления"
def get_savings_account(db: Session, *, space_id: uuid.UUID) -> Account | None:
return (
db.query(Account)
.filter_by(space_id=space_id, system_key=SAVINGS_SYSTEM_KEY)
.one_or_none()
)
def _savings_category(db: Session, *, space_id: uuid.UUID) -> Category | None:
return (
db.query(Category)
.filter_by(space_id=space_id, system_key=SAVINGS_SYSTEM_KEY)
.one_or_none()
)
def ensure_savings_resources(db: Session, *, space_id: uuid.UUID) -> tuple[Account, Category]:
"""Idempotently return the single system savings account and its envelope.
Any duplicate or inconsistent system pair is an explicit conflict, never a
random pick: identity is keyed by ``system_key`` only.
"""
accounts = db.query(Account).filter_by(space_id=space_id, system_key=SAVINGS_SYSTEM_KEY).all()
if len(accounts) > 1:
raise conflict(
"LEDGER_SAVINGS_CONFLICT",
"Multiple system savings accounts exist in this space",
)
account: Account | None = accounts[0] if accounts else None
created = False
if account is None:
account = Account(
space_id=space_id,
name=SAVINGS_ACCOUNT_NAME,
kind="savings",
system_key=SAVINGS_SYSTEM_KEY,
opened_on=date.today(),
opening_amount_kopecks=0,
)
db.add(account)
db.flush()
created = True
categories = (
db.query(Category).filter_by(space_id=space_id, system_key=SAVINGS_SYSTEM_KEY).all()
)
if len(categories) > 1:
raise conflict(
"LEDGER_SAVINGS_CONFLICT",
"Multiple system savings categories exist in this space",
)
category: Category | None = categories[0] if categories else None
if category is not None and category.linked_account_id != account.id:
raise conflict(
"LEDGER_SAVINGS_CONFLICT",
"System savings category is linked to a different account",
)
if category is None:
category = Category(
space_id=space_id,
kind="savings_envelope",
system_key=SAVINGS_SYSTEM_KEY,
linked_account_id=account.id,
)
db.add(category)
db.flush()
created = True
if created:
audit.record(
db,
event_type="savings.resources_ensured",
space_id=space_id,
detail={
"account_id": str(account.id),
"category_id": str(category.id),
},
)
return account, category
def transfer_category_for_accounts(
db: Session, *, space_id: uuid.UUID, source: Account, target: Account
) -> Category | None:
"""System category for a manual transfer touching the savings account."""
if SAVINGS_SYSTEM_KEY not in {source.system_key, target.system_key}:
return None
_, category = ensure_savings_resources(db, space_id=space_id)
return category
def adjust_balance(
db: Session,
*,
space_id: uuid.UUID,
user: User,
account_id: uuid.UUID,
target_balance_kopecks: int,
occurred_on: date,
reason: str,
) -> tuple[Operation, int, int]:
"""Reconcile a derived balance with reality via one audited adjustment.
The operation carries one signed posting for the delta; ``amount_kopecks``
stays positive and the sign lives in the posting. Adjustments never touch
income/expense totals, category spend, cash flow or the budget pool.
"""
if not reason.strip():
raise bad_request("LEDGER_ADJUST_REASON_REQUIRED", "An adjustment reason is required")
account = (
db.query(Account)
.filter_by(id=account_id, space_id=space_id)
.with_for_update()
.one_or_none()
)
if account is None:
raise not_found("LEDGER_ACCOUNT_NOT_FOUND", "Account not found")
if account.status != "active":
raise conflict("LEDGER_ACCOUNT_ARCHIVED", "Account is archived")
if account.kind == "credit":
raise conflict(
"LEDGER_ADJUST_CREDIT_UNSUPPORTED",
"Credit account balances cannot be adjusted with a non-negative target",
)
if target_balance_kopecks < 0:
raise bad_request(
"LEDGER_ADJUST_TARGET_NEGATIVE", "Target balance cannot be negative"
)
from app.ledger.service import account_balance, ensure_period_open_for_date
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
old_balance = account_balance(db, account)
delta = target_balance_kopecks - old_balance
if delta == 0:
raise conflict(
"LEDGER_ADJUST_NOOP",
"Balance already equals the target; nothing to adjust",
details={"balance_kopecks": old_balance},
)
operation = Operation(
space_id=space_id,
type="adjustment",
amount_kopecks=abs(delta),
account_id=account.id,
occurred_on=occurred_on,
description=reason.strip(),
created_by_user_id=user.id,
)
db.add(operation)
db.flush()
db.add(
Posting(
space_id=space_id,
operation_id=operation.id,
account_id=account.id,
amount_kopecks=delta,
)
)
audit.record(
db,
event_type="account.balance_adjusted",
space_id=space_id,
user_id=user.id,
detail={
"account_id": str(account.id),
"operation_id": str(operation.id),
"old_balance_kopecks": old_balance,
"new_balance_kopecks": target_balance_kopecks,
"delta_kopecks": delta,
"reason": reason.strip(),
},
)
return operation, old_balance, target_balance_kopecks
def convert_expense_to_savings(
db: Session, *, space_id: uuid.UUID, user: User, operation_id: uuid.UUID
) -> Operation:
"""Turn a confirmed expense into a transfer into the system savings account.
Keeps the original negative posting plus description, payee, request key
and import provenance; adds one positive posting on the savings account and
remembers the previous category for exact reversion.
"""
operation = (
db.query(Operation)
.filter_by(id=operation_id, space_id=space_id)
.with_for_update()
.one_or_none()
)
if operation is None:
raise not_found("LEDGER_OPERATION_NOT_FOUND", "Operation not found")
if operation.status != "confirmed":
raise conflict(
"LEDGER_SAVINGS_CONVERT_INVALID",
"Only confirmed operations can be converted",
)
if operation.type != "expense":
raise conflict(
"LEDGER_SAVINGS_CONVERT_INVALID",
"Only expenses can be converted into savings transfers",
)
if operation.converted_from_expense:
raise conflict(
"LEDGER_SAVINGS_CONVERT_INVALID",
"Operation is already a savings transfer",
)
source_account = (
db.query(Account).filter_by(id=operation.account_id, space_id=space_id).one_or_none()
)
if source_account is None:
raise not_found("LEDGER_ACCOUNT_NOT_FOUND", "Account not found")
if source_account.system_key is not None:
raise conflict(
"LEDGER_SAVINGS_CONVERT_INVALID",
"Operations on system accounts cannot be converted",
)
confirmed_refund = (
db.query(Operation.id)
.filter_by(
space_id=space_id,
parent_operation_id=operation.id,
type="refund",
status="confirmed",
)
.first()
)
if confirmed_refund is not None:
raise conflict(
"LEDGER_SAVINGS_CONVERT_HAS_REFUNDS",
"Expenses with confirmed refunds cannot be converted into savings transfers",
)
from app.ledger.service import ensure_period_open_for_date
ensure_period_open_for_date(db, space_id=space_id, day=operation.occurred_on)
savings_account, savings_category = ensure_savings_resources(db, space_id=space_id)
previous_category_id = operation.category_id
operation.previous_category_id = previous_category_id
operation.converted_from_expense = True
operation.type = "transfer"
operation.counter_account_id = savings_account.id
operation.category_id = savings_category.id
db.add(
Posting(
space_id=space_id,
operation_id=operation.id,
account_id=savings_account.id,
amount_kopecks=operation.amount_kopecks,
)
)
audit.record(
db,
event_type="operation.savings_transfer",
space_id=space_id,
user_id=user.id,
detail={
"operation_id": str(operation.id),
"savings_account_id": str(savings_account.id),
"amount_kopecks": operation.amount_kopecks,
"previous_category_id": (
str(previous_category_id) if previous_category_id else None
),
},
)
return operation
def revert_savings_conversion(
db: Session, *, space_id: uuid.UUID, user: User, operation_id: uuid.UUID
) -> Operation:
"""Undo a marked conversion: restore the expense type, category and postings."""
operation = (
db.query(Operation)
.filter_by(id=operation_id, space_id=space_id)
.with_for_update()
.one_or_none()
)
if operation is None:
raise not_found("LEDGER_OPERATION_NOT_FOUND", "Operation not found")
if operation.status != "confirmed":
raise conflict(
"LEDGER_SAVINGS_REVERT_INVALID",
"Only confirmed operations can be reverted",
)
if operation.type != "transfer" or not operation.converted_from_expense:
raise conflict(
"LEDGER_SAVINGS_REVERT_INVALID",
"Only converted expenses can be reverted from savings",
)
from app.ledger.service import ensure_period_open_for_date
ensure_period_open_for_date(db, space_id=space_id, day=operation.occurred_on)
counter_account_id = operation.counter_account_id
if counter_account_id is not None:
savings_posting = (
db.query(Posting)
.filter_by(operation_id=operation.id, account_id=counter_account_id)
.one_or_none()
)
if savings_posting is not None:
db.delete(savings_posting)
operation.type = "expense"
operation.counter_account_id = None
operation.category_id = operation.previous_category_id
operation.converted_from_expense = False
operation.previous_category_id = None
audit.record(
db,
event_type="operation.savings_transfer_reverted",
space_id=space_id,
user_id=user.id,
detail={"operation_id": str(operation.id)},
)
return operation
+24
View File
@@ -48,6 +48,15 @@ def _require_active_account(db: Session, *, space_id: uuid.UUID, account_id: uui
return account
def _reject_system_account(account: Account) -> None:
"""System accounts (savings) are journal-internal and never statement-facing."""
if account.system_key is not None:
raise bad_request(
"LEDGER_SYSTEM_ACCOUNT_NOT_EXTERNAL",
"System accounts cannot host external income or expense operations",
)
def _validate_category(db: Session, *, space_id: uuid.UUID, category_id: uuid.UUID | None) -> None:
if category_id is None:
return
@@ -58,6 +67,11 @@ def _validate_category(db: Session, *, space_id: uuid.UUID, category_id: uuid.UU
raise not_found("BUDGET_CATEGORY_NOT_FOUND", "Category not found")
if category.archived:
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived")
if category.system_key is not None:
raise conflict(
"BUDGET_CATEGORY_SYSTEM",
"System categories cannot be assigned to regular operations",
)
def account_balance(db: Session, account: Account) -> int:
@@ -155,6 +169,7 @@ def create_expense(
if existing is not None:
return existing
account = _require_active_account(db, space_id=space_id, account_id=account_id)
_reject_system_account(account)
_validate_category(db, space_id=space_id, category_id=category_id)
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
operation = Operation(
@@ -210,6 +225,7 @@ def create_income(
if existing is not None:
return existing
account = _require_active_account(db, space_id=space_id, account_id=account_id)
_reject_system_account(account)
if income_source_id is not None:
categorization.get_income_source(db, space_id=space_id, income_source_id=income_source_id)
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
@@ -268,12 +284,16 @@ def create_transfer(
source = _require_active_account(db, space_id=space_id, account_id=source_account_id)
target = _require_active_account(db, space_id=space_id, account_id=target_account_id)
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
from app.ledger.savings import transfer_category_for_accounts
category = transfer_category_for_accounts(db, space_id=space_id, source=source, target=target)
operation = Operation(
space_id=space_id,
type="transfer",
amount_kopecks=amount_kopecks,
account_id=source.id,
counter_account_id=target.id,
category_id=category.id if category is not None else None,
occurred_on=occurred_on,
posted_on=posted_on,
description=description,
@@ -463,6 +483,8 @@ def archive_account(
db: Session, *, space_id: uuid.UUID, user: User, account_id: uuid.UUID
) -> Account:
account = get_account(db, space_id=space_id, account_id=account_id)
if account.system_key is not None:
raise conflict("LEDGER_ACCOUNT_SYSTEM", "System accounts cannot be archived")
if account.status == "archived":
return account
if account_balance(db, account) != 0:
@@ -483,6 +505,8 @@ def archive_account(
def delete_account(db: Session, *, space_id: uuid.UUID, user: User, account_id: uuid.UUID) -> None:
account = get_account(db, space_id=space_id, account_id=account_id)
if account.system_key is not None:
raise conflict("LEDGER_ACCOUNT_SYSTEM", "System accounts cannot be deleted")
used = (
db.query(Operation.id)
.filter((Operation.account_id == account.id) | (Operation.counter_account_id == account.id))
+3
View File
@@ -15,6 +15,9 @@ def create_space(db: Session, *, user: User, name: str) -> FinancialSpace:
db.flush()
set_space_context(db, space.id)
db.add(Membership(space_id=space.id, user_id=user.id, role="owner"))
from app.ledger.savings import ensure_savings_resources
ensure_savings_resources(db, space_id=space.id)
audit.record(db, event_type="space.created", space_id=space.id, user_id=user.id)
return space
+122 -2
View File
@@ -14,7 +14,11 @@ from openpyxl import Workbook
from app.errors import AppError
from app.imports.adapters import ADAPTERS, detect_adapter, magic_kind
from app.imports.adapters import alfa_xlsx as alfa_module
from app.imports.adapters.alfa_xlsx import AlfaCreditXlsxAdapter, AlfaDebitXlsxAdapter
from app.imports.adapters.alfa_xlsx import (
AlfaCreditXlsxAdapter,
AlfaDebitXlsxAdapter,
detect_savings_transfer,
)
from app.imports.adapters.base import (
ALFA_DEBIT_XLSX_VERSION,
BANK_STATUS_HOLD,
@@ -89,7 +93,7 @@ class TestBaseContract:
def test_per_contract_parser_versions(self) -> None:
assert PARSER_VERSION == "1"
assert ALFA_DEBIT_XLSX_VERSION == "2"
assert ALFA_DEBIT_XLSX_VERSION == "3"
assert OZON_DEBIT_PDF_VERSION == "2"
assert ROCKETBANK_DEBIT_PDF_VERSION == "2"
assert AlfaDebitXlsxAdapter.parser_version == ALFA_DEBIT_XLSX_VERSION
@@ -472,6 +476,122 @@ class TestAlfaDebitContract:
assert "quota" in exc.value.message
class TestAlfaSavingsRule:
"""«Копилка для сдачи» contract: exact normalized phrase, statement-side
source account, outgoing posted rows only. Every input is synthetic."""
STATEMENT_ACCOUNT = "00000000000000000001"
SERVICE_ACCOUNT = "00000000000000000306"
PHRASE = (
'Перечисление средств в рамках услуги "Копилка для сдачи" '
"со счета {source} на счет {target}"
)
def rule(
self,
description: str | None,
*,
direction: str = "out",
bank_status: str = "posted",
statement_account: str = STATEMENT_ACCOUNT,
) -> bool:
return detect_savings_transfer(
description,
direction=direction,
bank_status=bank_status,
statement_account=statement_account,
)
def test_exact_phrase_matches(self) -> None:
assert self.rule(
self.PHRASE.format(source=self.STATEMENT_ACCOUNT, target=self.SERVICE_ACCOUNT)
)
def test_spaces_case_and_quotes_are_normalized(self) -> None:
noisy = (
" Перечисление\u00a0средств в рамках услуги «КОПИЛКА ДЛЯ СДАЧИ» "
f"со счёта {self.STATEMENT_ACCOUNT} на счет {self.SERVICE_ACCOUNT} "
)
assert self.rule(noisy)
def test_mismatched_source_account_fails_closed(self) -> None:
assert not self.rule(
self.PHRASE.format(source="00000000000000000099", target=self.SERVICE_ACCOUNT)
)
def test_incoming_row_never_matches(self) -> None:
assert not self.rule(
self.PHRASE.format(source=self.STATEMENT_ACCOUNT, target=self.SERVICE_ACCOUNT),
direction="in",
)
def test_hold_row_never_matches(self) -> None:
assert not self.rule(
self.PHRASE.format(source=self.STATEMENT_ACCOUNT, target=self.SERVICE_ACCOUNT),
bank_status="hold",
)
def test_similar_description_without_full_pattern_fails_closed(self) -> None:
assert not self.rule("Копилка для сдачи: пополнение")
assert not self.rule(
'Перечисление в рамках услуги "Копилка для сдачи" со счета 123 на счет 306'
)
def test_masked_statement_account_fails_closed(self) -> None:
assert not self.rule(
self.PHRASE.format(source=self.STATEMENT_ACCOUNT, target=self.SERVICE_ACCOUNT),
statement_account="0000********0001",
)
def savings_statement_rows(self) -> list[list[object]]:
rows = [list(row) for row in synthetic_alfa_rows()]
rows[9] = [
None, None, None, None, None, None, None, None, None, None,
"Расходы", None, "416,90 RUR",
]
savings_row = [
"03.09.2026",
"03.09.2026",
None,
"000000000000009",
"Переводы",
None,
None,
None,
None,
None,
None,
self.PHRASE.format(source=self.STATEMENT_ACCOUNT, target=self.SERVICE_ACCOUNT),
"-123,45",
"Выполнен",
]
rows.insert(17, savings_row)
return rows
def test_parse_marks_savings_rows_and_bumps_version(self) -> None:
adapter = AlfaDebitXlsxAdapter()
raw = alfa_workbook_bytes(self.savings_statement_rows())
draft = adapter.parse(raw)
assert draft.parser_version == ALFA_DEBIT_XLSX_VERSION
by_code = {row.external_id: row for row in draft.rows}
assert len(draft.rows) == 6
marked = by_code["000000000000009"]
assert marked.savings_transfer is True
assert marked.direction == "out"
assert marked.bank_status == "posted"
assert marked.amount_kopecks == 12345
assert all(
row.savings_transfer is False
for code, row in by_code.items()
if code != "000000000000009"
)
def test_parse_leaves_plain_expenses_unmarked(self) -> None:
adapter = AlfaDebitXlsxAdapter()
draft = adapter.parse(alfa_workbook_bytes(synthetic_alfa_rows()))
assert all(not row.savings_transfer for row in draft.rows)
def ozon_extracted() -> tuple[list[str], list[list[list[object]]]]:
pages_text = [
"\n".join(
+337
View File
@@ -0,0 +1,337 @@
"""Budget semantics for savings transfers, adjustments and archived history."""
from datetime import date
from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
from app.budgeting.service import (
compute_period_state,
create_category,
fund,
get_or_create_period,
set_assignments,
)
from app.errors import AppError
from app.identity.models import User
from app.ledger.savings import adjust_balance, ensure_savings_resources
from app.ledger.service import create_account, create_expense, create_income, create_transfer
from app.spaces.service import create_space
def seed(db: Session):
user = User(email=f"{uuid4().hex}@example.com", password_hash="unused")
db.add(user)
db.flush()
space = create_space(db, user=user, name="BudgetSavings")
account = create_account(
db,
space_id=space.id,
user=user,
name="Debit",
kind="debit",
opened_on=date(2026, 5, 1),
opening_amount_kopecks=1_000_000,
)
return user, space, account
def savings_row(state: dict) -> dict:
return next(row for row in state["envelopes"] if row["name_key"] == "savings")
def test_transfer_to_savings_moves_pool_into_envelope(db: Session) -> None:
user, space, account = seed(db)
savings_account, _ = ensure_savings_resources(db, space_id=space.id)
before = compute_period_state(
db,
space_id=space.id,
period=get_or_create_period(db, space_id=space.id, year=2026, month=5),
)
assert before["pool"] == 0
assert savings_row(before)["available"] == 0
create_transfer(
db,
space_id=space.id,
user=user,
source_account_id=account.id,
target_account_id=savings_account.id,
amount_kopecks=250_000,
occurred_on=date(2026, 5, 10),
)
after = compute_period_state(
db,
space_id=space.id,
period=get_or_create_period(db, space_id=space.id, year=2026, month=5),
)
row = savings_row(after)
assert after["pool"] == -250_000
assert row["funded"] == 250_000
assert row["spent"] == 0
assert row["available"] == 250_000
assert after["income_total"] == 0
def test_transfer_back_from_savings_returns_pool(db: Session) -> None:
user, space, account = seed(db)
savings_account, _ = ensure_savings_resources(db, space_id=space.id)
create_transfer(
db,
space_id=space.id,
user=user,
source_account_id=account.id,
target_account_id=savings_account.id,
amount_kopecks=250_000,
occurred_on=date(2026, 5, 10),
)
create_transfer(
db,
space_id=space.id,
user=user,
source_account_id=savings_account.id,
target_account_id=account.id,
amount_kopecks=100_000,
occurred_on=date(2026, 5, 12),
)
state = compute_period_state(
db,
space_id=space.id,
period=get_or_create_period(db, space_id=space.id, year=2026, month=5),
)
row = savings_row(state)
assert state["pool"] == -150_000
assert row["funded"] == 150_000
assert row["available"] == 150_000
def test_adjustment_changes_only_envelope_available_not_pool(db: Session) -> None:
user, space, account = seed(db)
savings_account, _ = ensure_savings_resources(db, space_id=space.id)
create_transfer(
db,
space_id=space.id,
user=user,
source_account_id=account.id,
target_account_id=savings_account.id,
amount_kopecks=100_000,
occurred_on=date(2026, 5, 3),
)
adjust_balance(
db,
space_id=space.id,
user=user,
account_id=savings_account.id,
target_balance_kopecks=80_000,
occurred_on=date(2026, 5, 20),
reason="Процентная корректировка",
)
state = compute_period_state(
db,
space_id=space.id,
period=get_or_create_period(db, space_id=space.id, year=2026, month=5),
)
row = savings_row(state)
assert row["available"] == 80_000
assert row["funded"] == 100_000
assert state["income_total"] == 0
assert state["pool"] == -100_000
def test_adjustment_on_regular_account_keeps_pool_and_totals(db: Session) -> None:
user, space, account = seed(db)
create_income(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=300_000,
occurred_on=date(2026, 5, 2),
)
adjust_balance(
db,
space_id=space.id,
user=user,
account_id=account.id,
target_balance_kopecks=1_000_000,
occurred_on=date(2026, 5, 21),
reason="Списание банка не отразилось",
)
state = compute_period_state(
db,
space_id=space.id,
period=get_or_create_period(db, space_id=space.id, year=2026, month=5),
)
assert state["income_total"] == 300_000
# The adjustment reconciles the account but never enters the budget pool.
assert state["pool"] == 300_000
assert state["uncategorized_expenses"] == 0
assert state["monthly_categories"] == []
def test_savings_envelope_available_uses_month_end_balance(db: Session) -> None:
user, space, account = seed(db)
savings_account, _ = ensure_savings_resources(db, space_id=space.id)
create_transfer(
db,
space_id=space.id,
user=user,
source_account_id=account.id,
target_account_id=savings_account.id,
amount_kopecks=50_000,
occurred_on=date(2026, 5, 28),
)
# A transfer in a later month must not affect the May envelope view.
create_transfer(
db,
space_id=space.id,
user=user,
source_account_id=account.id,
target_account_id=savings_account.id,
amount_kopecks=70_000,
occurred_on=date(2026, 6, 2),
)
may = compute_period_state(
db,
space_id=space.id,
period=get_or_create_period(db, space_id=space.id, year=2026, month=5),
)
june = compute_period_state(
db,
space_id=space.id,
period=get_or_create_period(db, space_id=space.id, year=2026, month=6),
)
assert savings_row(may)["available"] == 50_000
assert savings_row(may)["funded"] == 50_000
assert savings_row(june)["available"] == 120_000
assert savings_row(june)["funded"] == 70_000
def test_archived_category_history_stays_in_totals(db: Session) -> None:
user, space, account = seed(db)
category = create_category(db, space_id=space.id, user=user, kind="monthly", name="Transport")
set_assignments(
db,
space_id=space.id,
user=user,
period=get_or_create_period(db, space_id=space.id, year=2026, month=5),
items=[{"category_id": category.id, "planned_kopecks": 20_000}],
)
create_expense(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=5_000,
occurred_on=date(2026, 5, 6),
category_id=category.id,
)
category.archived = True
db.flush()
state = compute_period_state(
db,
space_id=space.id,
period=get_or_create_period(db, space_id=space.id, year=2026, month=5),
)
rows = [row for row in state["monthly_categories"] if row["category_id"] == str(category.id)]
assert len(rows) == 1
row = rows[0]
assert row["archived"] is True
assert row["spent"] == 5_000
assert row["planned"] == 20_000
# An archived category refuses new assignments...
try:
set_assignments(
db,
space_id=space.id,
user=user,
period=get_or_create_period(db, space_id=space.id, year=2026, month=5),
items=[{"category_id": category.id, "planned_kopecks": 1}],
)
raise AssertionError("archived assignment must fail")
except AppError as exc:
assert exc.code == "BUDGET_CATEGORY_ARCHIVED"
# ...but its history still counts in the funding/spent totals.
summary_funded = sum(row["funded"] for row in state["monthly_categories"])
assert summary_funded == 0
def test_system_category_rejects_planning_funding_and_regular_expense(db: Session) -> None:
user, space, account = seed(db)
_, savings_category = ensure_savings_resources(db, space_id=space.id)
period = get_or_create_period(db, space_id=space.id, year=2026, month=5)
with pytest.raises(AppError) as exc:
set_assignments(
db,
space_id=space.id,
user=user,
period=period,
items=[{"category_id": savings_category.id, "planned_kopecks": 10_000}],
)
assert exc.value.code == "BUDGET_CATEGORY_SYSTEM"
with pytest.raises(AppError) as exc:
fund(
db,
space_id=space.id,
user=user,
period=period,
category_id=savings_category.id,
amount_kopecks=10_000,
kind="fund",
)
assert exc.value.code == "BUDGET_CATEGORY_SYSTEM"
with pytest.raises(AppError) as exc:
create_expense(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=10_000,
occurred_on=date(2026, 5, 10),
category_id=savings_category.id,
)
assert exc.value.code == "BUDGET_CATEGORY_SYSTEM"
def test_archived_category_rejects_new_funding(db: Session) -> None:
user, space, _ = seed(db)
category = create_category(db, space_id=space.id, user=user, kind="monthly", name="Old")
category.archived = True
period = get_or_create_period(db, space_id=space.id, year=2026, month=5)
with pytest.raises(AppError) as exc:
fund(
db,
space_id=space.id,
user=user,
period=period,
category_id=category.id,
amount_kopecks=1_000,
kind="fund",
)
assert exc.value.code == "BUDGET_CATEGORY_ARCHIVED"
def test_blockers_carry_discriminated_data(db: Session) -> None:
user, space, account = seed(db)
create_expense(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=5_000,
occurred_on=date(2026, 5, 6),
)
state = compute_period_state(
db,
space_id=space.id,
period=get_or_create_period(db, space_id=space.id, year=2026, month=5),
)
by_code = {blocker["code"]: blocker for blocker in state["close_blockers"]}
assert by_code["BUDGET_UNCATEGORIZED_EXPENSES"]["count"] == 1
assert "IMPORTS_UNRESOLVED" not in by_code
assert "IMPORT_UNRESOLVED_HOLDS" not in by_code
+216
View File
@@ -0,0 +1,216 @@
"""HTTP contract for category definitions, system protections and CSRF."""
from fastapi.testclient import TestClient
def bootstrap(client: TestClient) -> str:
invite = client.post(
"/api/v1/admin/invitations",
headers={"X-Admin-Token": "test-admin-token"},
json={"email": "categories@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": "Category Tester",
"locale": "en",
},
)
assert accepted.status_code == 201, accepted.text
return client.cookies["csrf_token"]
def test_space_has_protected_savings_pair(client: TestClient) -> None:
csrf = bootstrap(client)
accounts = client.get("/api/v1/accounts").json()
savings = [item for item in accounts if item["systemKey"] == "savings"]
assert len(savings) == 1
assert savings[0]["type"] == "savings"
assert savings[0]["nameKey"] == "account.savings"
assert savings[0]["balanceKopecks"] == 0
definitions = client.get(
"/api/v1/budget/category-definitions", params={"includeArchived": "true"}
).json()
savings_definitions = [item for item in definitions if item["systemKey"] == "savings"]
assert len(savings_definitions) == 1
assert savings_definitions[0]["nameKey"] == "savings"
assert savings_definitions[0]["name"] is None
assert savings_definitions[0]["archived"] is False
assert savings_definitions[0]["kind"] == "savings_envelope"
assert savings_definitions[0]["linkedAccountId"] == savings[0]["id"]
# CSRF is enforced on the new mutation endpoints.
denied = client.patch(
f"/api/v1/budget/category-definitions/{savings_definitions[0]['id']}",
json={"name": "My stash"},
)
assert denied.status_code == 403
# System categories reject rename and archive.
for payload in ({"name": "My stash"}, {"archived": True}):
rejected = client.patch(
f"/api/v1/budget/category-definitions/{savings_definitions[0]['id']}",
json=payload,
headers={"X-CSRF-Token": csrf},
)
assert rejected.status_code == 409, rejected.text
# System accounts reject archive and delete.
account_id = savings[0]["id"]
archive_response = client.post(
f"/api/v1/accounts/{account_id}/archive", headers={"X-CSRF-Token": csrf}
)
assert archive_response.status_code == 409
delete_response = client.delete(
f"/api/v1/accounts/{account_id}", headers={"X-CSRF-Token": csrf}
)
assert delete_response.status_code == 409
def test_category_lifecycle_create_rename_archive_restore(client: TestClient) -> None:
csrf = bootstrap(client)
created = client.post(
"/api/v1/budget/categories",
headers={"X-CSRF-Token": csrf},
json={"name": " Groceries ", "kind": "monthly"},
)
assert created.status_code == 201, created.text
body = created.json()
assert body["name"] == "Groceries"
assert body["kind"] == "monthly"
assert body["systemKey"] is None
assert body["archived"] is False
category_id = body["id"]
renamed = client.patch(
f"/api/v1/budget/category-definitions/{category_id}",
headers={"X-CSRF-Token": csrf},
json={"name": "Food"},
)
assert renamed.status_code == 200, renamed.text
assert renamed.json()["name"] == "Food"
archived = client.patch(
f"/api/v1/budget/category-definitions/{category_id}",
headers={"X-CSRF-Token": csrf},
json={"archived": True},
)
assert archived.status_code == 200
assert archived.json()["archived"] is True
active_only = client.get("/api/v1/budget/category-definitions").json()
assert all(item["id"] != category_id for item in active_only)
with_archived = client.get(
"/api/v1/budget/category-definitions", params={"includeArchived": "true"}
).json()
assert any(item["id"] == category_id and item["archived"] for item in with_archived)
restored = client.patch(
f"/api/v1/budget/category-definitions/{category_id}",
headers={"X-CSRF-Token": csrf},
json={"archived": False},
)
assert restored.status_code == 200
assert restored.json()["archived"] is False
# Empty names are rejected after trim.
empty = client.post(
"/api/v1/budget/categories",
headers={"X-CSRF-Token": csrf},
json={"name": " ", "kind": "monthly"},
)
assert empty.status_code == 400
blank_rename = client.patch(
f"/api/v1/budget/category-definitions/{category_id}",
headers={"X-CSRF-Token": csrf},
json={"name": " "},
)
assert blank_rename.status_code == 400
def test_openapi_contains_new_contracts(client: TestClient) -> None:
bootstrap(client)
paths = client.get("/api/v1/openapi.json").json()["paths"]
assert "/api/v1/accounts/{account_id}/adjust-balance" in paths
assert "/api/v1/operations/{operation_id}/savings-transfer" in paths
assert "/api/v1/operations/{operation_id}/savings-transfer/revert" in paths
assert "/api/v1/budget/category-definitions" in paths
assert "/api/v1/budget/category-definitions/{category_id}" in paths
assert "/api/v1/imports/{document_id}/rows/{row_id}/resolve" in paths
def test_adjust_and_savings_conversion_http_contracts(client: TestClient) -> None:
csrf = bootstrap(client)
account = client.post(
"/api/v1/accounts",
headers={"X-CSRF-Token": csrf},
json={
"name": "Debit",
"type": "debit",
"openedOn": "2026-09-01",
"openingBalanceKopecks": 100_000,
},
)
assert account.status_code == 201, account.text
account_id = account.json()["id"]
adjustment_body = {
"targetBalanceKopecks": 90_000,
"occurredOn": "2026-09-10",
"reason": "Statement reconciliation",
}
denied = client.post(
f"/api/v1/accounts/{account_id}/adjust-balance", json=adjustment_body
)
assert denied.status_code == 403
adjusted = client.post(
f"/api/v1/accounts/{account_id}/adjust-balance",
headers={"X-CSRF-Token": csrf},
json=adjustment_body,
)
assert adjusted.status_code == 200, adjusted.text
assert adjusted.json()["oldBalanceKopecks"] == 100_000
assert adjusted.json()["newBalanceKopecks"] == 90_000
assert adjusted.json()["kind"] == "adjustment"
category = client.post(
"/api/v1/budget/categories",
headers={"X-CSRF-Token": csrf},
json={"name": "Food", "kind": "monthly"},
)
assert category.status_code == 201, category.text
expense = client.post(
"/api/v1/operations",
headers={"X-CSRF-Token": csrf},
json={
"kind": "expense",
"amountKopecks": 5_000,
"accountId": account_id,
"categoryId": category.json()["id"],
"occurredOn": "2026-09-11",
},
)
assert expense.status_code == 201, expense.text
operation_id = expense.json()["id"]
denied = client.post(f"/api/v1/operations/{operation_id}/savings-transfer")
assert denied.status_code == 403
converted = client.post(
f"/api/v1/operations/{operation_id}/savings-transfer",
headers={"X-CSRF-Token": csrf},
)
assert converted.status_code == 200, converted.text
assert converted.json()["kind"] == "transfer"
assert converted.json()["convertedFromExpense"] is True
reverted = client.post(
f"/api/v1/operations/{operation_id}/savings-transfer/revert",
headers={"X-CSRF-Token": csrf},
)
assert reverted.status_code == 200, reverted.text
assert reverted.json()["kind"] == "expense"
assert reverted.json()["convertedFromExpense"] is False
+386
View File
@@ -0,0 +1,386 @@
"""Import contract for the «Копилка для сдачи» savings_transfer decision."""
import uuid
from datetime import date
from types import SimpleNamespace
import pytest
from sqlalchemy.orm import Session
from app.errors import AppError
from app.identity.models import User
from app.imports import router as imports_router
from app.imports import service
from app.imports.adapters.alfa_xlsx import detect_savings_transfer
from app.imports.models import (
DedupCandidate,
ParseRun,
SourceRecord,
SourceRecordLink,
StatementDocument,
)
from app.ledger.models import Operation
from app.ledger.savings import get_savings_account
from app.ledger.service import create_account
from app.spaces.service import create_space
SAVINGS_PHRASE = (
'Перечисление средств в рамках услуги "Копилка для сдачи" '
"со счета 00000000000000000001 на счет 00000000000000000306"
)
def seed(db: Session):
user = User(email=f"{uuid.uuid4().hex}@example.com", password_hash="unused")
db.add(user)
db.flush()
space = create_space(db, user=user, name="ImportSavings")
account = create_account(
db,
space_id=space.id,
user=user,
name="Alfa Debit",
kind="debit",
opened_on=date(2026, 1, 1),
)
return user, space, account
def make_document(db: Session, space, account, *, user: User) -> tuple[StatementDocument, ParseRun]:
document = StatementDocument(
space_id=space.id,
account_id=account.id,
original_filename=f"{uuid.uuid4().hex}.xlsx",
size_bytes=100,
content_sha256=uuid.uuid4().hex + uuid.uuid4().hex,
object_key=uuid.uuid4().hex,
status="ready",
reconciliation={"full": True, "matched": True},
uploaded_by_user_id=user.id,
)
db.add(document)
db.flush()
run = ParseRun(
space_id=space.id,
document_id=document.id,
adapter_id="alfa_debit_xlsx",
status="succeeded",
)
db.add(run)
db.flush()
document.active_parse_run_id = run.id
return document, run
def add_record(
db: Session,
document: StatementDocument,
run: ParseRun,
*,
bank_status: str = "posted",
direction: str = "out",
external_id: str | None = None,
description: str | None = None,
occurred_on: date = date(2026, 5, 10),
) -> SourceRecord:
source = SourceRecord(
space_id=document.space_id,
document_id=document.id,
parse_run_id=run.id,
external_id=external_id,
occurred_on=occurred_on,
amount_kopecks=12_345,
direction=direction,
bank_status=bank_status,
description=description,
row_ref=f"sheet0:row{uuid.uuid4().int % 1000}",
record_fp=uuid.uuid4().hex,
review_decision="savings_transfer"
if detect_savings_transfer(
description,
direction=direction,
bank_status=bank_status,
statement_account="00000000000000000001",
)
else "create",
)
db.add(source)
db.flush()
return source
def test_parsed_savings_hint_becomes_initial_review_decision(db: Session) -> None:
user, space, account = seed(db)
document, run = make_document(db, space, account, user=user)
source = add_record(db, document, run, description=SAVINGS_PHRASE)
assert source.review_decision == "savings_transfer"
def test_confirm_creates_savings_transfer_with_system_category(db: Session) -> None:
user, space, account = seed(db)
document, run = make_document(db, space, account, user=user)
source = add_record(
db,
document,
run,
external_id="ALFA-1",
description=SAVINGS_PHRASE,
occurred_on=date(2026, 5, 10),
)
assert source.review_decision == "savings_transfer"
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
db.flush()
savings_account = get_savings_account(db, space_id=space.id)
operations = db.query(Operation).filter_by(space_id=space.id).all()
assert len(operations) == 1
transfer = operations[0]
assert transfer.type == "transfer"
assert transfer.account_id == account.id
assert transfer.counter_account_id == savings_account.id
from app.ledger.models import Category
category = db.get(Category, transfer.category_id)
assert category is not None
assert category.system_key == "savings"
assert transfer.request_key == f"import-savings:{source.id}"
assert transfer.description == SAVINGS_PHRASE
link = db.query(SourceRecordLink).filter_by(source_record_id=source.id).one()
assert link.operation_id == transfer.id
assert link.kind == "created"
assert document.status == "confirmed"
def test_revert_removes_created_savings_transfer(db: Session) -> None:
user, space, account = seed(db)
document, run = make_document(db, space, account, user=user)
add_record(db, document, run, description=SAVINGS_PHRASE)
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
assert db.query(Operation).filter_by(space_id=space.id).count() == 1
service.revert_document(db, space_id=space.id, document_id=document.id, user=user)
db.flush()
assert db.query(Operation).filter_by(space_id=space.id).count() == 0
assert db.query(SourceRecordLink).filter_by(space_id=space.id).count() == 0
assert document.status == "ready"
source = (
db.query(SourceRecord).filter_by(parse_run_id=document.active_parse_run_id).one()
)
# Repeated parse gives the same decision: the hint survives revert.
assert source.review_decision == "savings_transfer"
def test_user_can_switch_savings_decision_to_create(db: Session) -> None:
user, space, account = seed(db)
document, run = make_document(db, space, account, user=user)
source = add_record(db, document, run, description=SAVINGS_PHRASE)
service.decide_source_record(
db,
space_id=space.id,
document_id=document.id,
source_record_id=source.id,
decision="create",
duplicate_operation_id=None,
user=user,
)
assert source.review_decision == "create"
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
operations = db.query(Operation).filter_by(space_id=space.id).all()
assert len(operations) == 1
assert operations[0].type == "expense"
def test_user_can_switch_savings_decision_to_discard(db: Session) -> None:
user, space, account = seed(db)
document, run = make_document(db, space, account, user=user)
source = add_record(db, document, run, description=SAVINGS_PHRASE)
service.decide_source_record(
db,
space_id=space.id,
document_id=document.id,
source_record_id=source.id,
decision="discard",
duplicate_operation_id=None,
user=user,
)
assert source.review_decision == "discard"
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
assert db.query(Operation).filter_by(space_id=space.id).count() == 0
def test_savings_decision_rejects_incoming_rows(db: Session) -> None:
user, space, account = seed(db)
document, run = make_document(db, space, account, user=user)
source = add_record(
db,
document,
run,
direction="in",
description="Incoming transfer",
)
with pytest.raises(AppError) as exc:
service.decide_source_record(
db,
space_id=space.id,
document_id=document.id,
source_record_id=source.id,
decision="savings_transfer",
duplicate_operation_id=None,
user=user,
)
assert exc.value.code == "IMPORT_DECISION_INVALID"
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)
with pytest.raises(AppError) as exc:
service.set_review_confirmation(
db,
space_id=space.id,
document_id=document.id,
account_id=savings_account.id,
confirmed_without_full_reconciliation=True,
reason="invalid mapping",
)
assert exc.value.code == "IMPORT_SYSTEM_ACCOUNT_INVALID"
def test_existing_dedup_wins_over_savings_creation(db: Session) -> None:
user, space, account = seed(db)
first, first_run = make_document(db, space, account, user=user)
external_id = "ALFA-DUP-1"
first_source = SourceRecord(
space_id=space.id,
document_id=first.id,
parse_run_id=first_run.id,
external_id=external_id,
occurred_on=date(2026, 5, 3),
amount_kopecks=12_345,
direction="out",
description="Groceries",
row_ref="sheet0:row3",
record_fp="fp-existing",
)
db.add(first_source)
db.flush()
service.confirm_document(db, space_id=space.id, document_id=first.id, user=user)
created = db.query(Operation).filter_by(space_id=space.id).one()
second, second_run = make_document(db, space, account, user=user)
duplicate = add_record(
db,
second,
second_run,
external_id=external_id,
description=SAVINGS_PHRASE,
occurred_on=date(2026, 5, 10),
)
assert service._build_candidates(db, document=second, run=second_run)
candidates = db.query(DedupCandidate).filter_by(source_record_id=duplicate.id).all()
assert len(candidates) == 1
assert candidates[0].candidate_kind == "duplicate"
assert candidates[0].existing_operation_id == created.id
service.decide_source_record(
db,
space_id=space.id,
document_id=second.id,
source_record_id=duplicate.id,
decision="duplicate",
duplicate_operation_id=created.id,
user=user,
)
service.confirm_document(db, space_id=space.id, document_id=second.id, user=user)
db.flush()
operations = db.query(Operation).filter_by(space_id=space.id).all()
assert len(operations) == 1
assert operations[0].id == created.id
def test_savings_transfer_on_hold_row_never_matches(db: Session) -> None:
assert (
detect_savings_transfer(
SAVINGS_PHRASE,
direction="out",
bank_status="hold",
statement_account="00000000000000000001",
)
is False
)
def test_unresolved_hold_counts_and_month_filter(db: Session) -> None:
user, space, account = seed(db)
document, run = make_document(db, space, account, user=user)
add_record(
db,
document,
run,
bank_status="hold",
description="Coffee shop",
occurred_on=date(2026, 5, 10),
)
add_record(
db,
document,
run,
bank_status="hold",
description="Train ticket",
occurred_on=date(2026, 6, 1),
)
resolved = add_record(
db,
document,
run,
bank_status="hold",
description="Resolved one",
occurred_on=date(2026, 5, 12),
)
db.add(
service.HoldResolution(
space_id=space.id,
hold_source_record_id=resolved.id,
resolution="confirmed_as_posted",
reason="verified",
)
)
db.flush()
unresolved = service.unresolved_holds(db, space_id=space.id, document=document)
assert len(unresolved) == 2
assert service.unresolved_hold_counts(
db, space_id=space.id, document_ids=[document.id]
) == {document.id: 2}
may = service.parse_month_filter("2026-05")
assert may == (2026, 5)
may_holds = [h for h in unresolved if h.occurred_on and h.occurred_on.month == 5]
assert len(may_holds) == 1
assert service.unresolved_hold_counts(
db, space_id=space.id, document_ids=[document.id], month=may
) == {document.id: 1}
context = 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
document.status = "confirmed"
assert imports_router.imports(db, context, month="2026-07") == []
may_items = imports_router.imports(db, context, month="2026-05")
assert may_items[0]["id"] == document.id
assert may_items[0]["unresolvedHoldCount"] == 1
assert service.parse_month_filter(None) is None
with pytest.raises(AppError) as exc:
service.parse_month_filter("2026-13")
assert exc.value.code == "IMPORT_MONTH_INVALID"
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)
document, run = make_document(db, space, savings_account, user=user)
document.account_id = savings_account.id
add_record(db, document, run, description=SAVINGS_PHRASE)
with pytest.raises(AppError) as exc:
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
assert exc.value.code == "IMPORT_SAVINGS_SOURCE_INVALID"
+495
View File
@@ -0,0 +1,495 @@
"""Savings system resources, adjustments and convert/revert contracts."""
from datetime import date
from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
from app.audit.models import AuditEvent
from app.budgeting.service import close_period, get_or_create_period
from app.errors import AppError
from app.identity.models import User
from app.ledger.models import Account, Category, Operation, Posting
from app.ledger.savings import (
adjust_balance,
convert_expense_to_savings,
ensure_savings_resources,
get_savings_account,
revert_savings_conversion,
)
from app.ledger.service import (
account_balance,
create_account,
create_expense,
create_income,
create_refund,
create_transfer,
)
from app.spaces.service import create_space
def seed(db: Session):
user = User(email=f"{uuid4().hex}@example.com", password_hash="unused")
db.add(user)
db.flush()
space = create_space(db, user=user, name="Savings")
account = create_account(
db,
space_id=space.id,
user=user,
name="Debit",
kind="debit",
opened_on=date(2026, 3, 1),
opening_amount_kopecks=500_000,
)
return user, space, account
def test_space_creation_builds_exactly_one_system_pair(db: Session) -> None:
user, space, _ = seed(db)
account, category = ensure_savings_resources(db, space_id=space.id)
assert account.kind == "savings"
assert account.system_key == "savings"
assert category.kind == "savings_envelope"
assert category.system_key == "savings"
assert category.linked_account_id == account.id
assert get_savings_account(db, space_id=space.id) is account
assert db.query(Account).filter_by(space_id=space.id, kind="savings").count() == 1
# Idempotent: repeated ensure returns the same pair and adds no rows.
again_account, again_category = ensure_savings_resources(db, space_id=space.id)
assert (again_account.id, again_category.id) == (account.id, category.id)
assert db.query(Account).filter_by(space_id=space.id).count() == 2
assert user is not None
def test_same_named_user_account_is_not_captured(db: Session) -> None:
user, space, _ = seed(db)
impostor = create_account(
db,
space_id=space.id,
user=user,
name="Накопления",
kind="cash",
opened_on=date(2026, 3, 1),
)
system_account, category = ensure_savings_resources(db, space_id=space.id)
assert impostor.id != system_account.id
assert impostor.system_key is None
assert category.linked_account_id == system_account.id
def test_adjust_balance_creates_single_signed_posting(db: Session) -> None:
user, space, account = seed(db)
operation, old_balance, new_balance = adjust_balance(
db,
space_id=space.id,
user=user,
account_id=account.id,
target_balance_kopecks=475_000,
occurred_on=date(2026, 3, 15),
reason="Купюры пересчитаны вручную",
)
assert (old_balance, new_balance) == (500_000, 475_000)
assert operation.type == "adjustment"
assert operation.amount_kopecks == 25_000
postings = db.query(Posting).filter_by(operation_id=operation.id).all()
assert len(postings) == 1
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["old_balance_kopecks"] == 500_000
assert event.detail["new_balance_kopecks"] == 475_000
assert event.detail["delta_kopecks"] == -25_000
def test_adjust_balance_positive_delta_and_upward_adjustment(db: Session) -> None:
user, space, account = seed(db)
operation, _, _ = adjust_balance(
db,
space_id=space.id,
user=user,
account_id=account.id,
target_balance_kopecks=510_000,
occurred_on=date(2026, 3, 15),
reason="Найдена купюра",
)
assert operation.amount_kopecks == 10_000
postings = db.query(Posting).filter_by(operation_id=operation.id).all()
assert postings[0].amount_kopecks == 10_000
assert account_balance(db, account) == 510_000
def test_adjust_balance_rejects_credit_accounts(db: Session) -> None:
user, space, _ = seed(db)
credit = create_account(
db,
space_id=space.id,
user=user,
name="Credit",
kind="credit",
opened_on=date(2026, 3, 1),
opening_amount_kopecks=-500_000,
)
with pytest.raises(AppError) as exc:
adjust_balance(
db,
space_id=space.id,
user=user,
account_id=credit.id,
target_balance_kopecks=0,
occurred_on=date(2026, 3, 16),
reason="Invalid sign",
)
assert exc.value.code == "LEDGER_ADJUST_CREDIT_UNSUPPORTED"
def test_adjust_balance_rejects_noop_negative_target_and_missing_reason(
db: Session,
) -> None:
user, space, account = seed(db)
with pytest.raises(AppError) as exc:
adjust_balance(
db,
space_id=space.id,
user=user,
account_id=account.id,
target_balance_kopecks=500_000,
occurred_on=date(2026, 3, 15),
reason="same",
)
assert exc.value.code == "LEDGER_ADJUST_NOOP"
with pytest.raises(AppError) as exc:
adjust_balance(
db,
space_id=space.id,
user=user,
account_id=account.id,
target_balance_kopecks=-1,
occurred_on=date(2026, 3, 15),
reason="below zero",
)
assert exc.value.code == "LEDGER_ADJUST_TARGET_NEGATIVE"
with pytest.raises(AppError) as exc:
adjust_balance(
db,
space_id=space.id,
user=user,
account_id=account.id,
target_balance_kopecks=1,
occurred_on=date(2026, 3, 15),
reason=" ",
)
assert exc.value.code == "LEDGER_ADJUST_REASON_REQUIRED"
def test_adjust_balance_protects_closed_period(db: Session) -> None:
user, space, account = seed(db)
period = get_or_create_period(db, space_id=space.id, year=2026, month=3)
close_period(db, space_id=space.id, user=user, period=period)
with pytest.raises(AppError) as exc:
adjust_balance(
db,
space_id=space.id,
user=user,
account_id=account.id,
target_balance_kopecks=1,
occurred_on=date(2026, 3, 20),
reason="late fix",
)
assert exc.value.code == "BUDGET_PERIOD_CLOSED"
def test_convert_expense_to_savings_and_revert_roundtrip(db: Session) -> None:
user, space, account = seed(db)
savings_account, savings_category = ensure_savings_resources(db, space_id=space.id)
category = Category(space_id=space.id, kind="monthly", custom_name="Food")
db.add(category)
db.flush()
expense = create_expense(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=12_345,
occurred_on=date(2026, 3, 5),
category_id=category.id,
description="Groceries",
request_key="op-1",
)
converted = convert_expense_to_savings(
db, space_id=space.id, user=user, operation_id=expense.id
)
assert converted.type == "transfer"
assert converted.counter_account_id == savings_account.id
assert converted.category_id == savings_category.id
assert converted.converted_from_expense is True
assert converted.previous_category_id == category.id
assert converted.description == "Groceries"
assert converted.request_key == "op-1"
postings = db.query(Posting).filter_by(operation_id=converted.id).all()
assert len(postings) == 2
assert sum(item.amount_kopecks for item in postings) == 0
assert account_balance(db, account) == 500_000 - 12_345
assert account_balance(db, savings_account) == 12_345
reverted = revert_savings_conversion(
db, space_id=space.id, user=user, operation_id=expense.id
)
assert reverted.type == "expense"
assert reverted.counter_account_id is None
assert reverted.category_id == category.id
assert reverted.converted_from_expense is False
assert reverted.previous_category_id is None
postings_after = db.query(Posting).filter_by(operation_id=reverted.id).all()
assert len(postings_after) == 1
assert postings_after[0].amount_kopecks == -12_345
assert account_balance(db, account) == 500_000 - 12_345
assert account_balance(db, savings_account) == 0
def test_convert_rejects_income_refund_transfer_and_double_conversion(
db: Session,
) -> None:
user, space, account = seed(db)
income = create_income(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=1_000,
occurred_on=date(2026, 3, 2),
)
with pytest.raises(AppError) as exc:
convert_expense_to_savings(
db, space_id=space.id, user=user, operation_id=income.id
)
assert exc.value.code == "LEDGER_SAVINGS_CONVERT_INVALID"
refundable_expense = create_expense(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=2_000,
occurred_on=date(2026, 3, 3),
)
refund = create_refund(
db,
space_id=space.id,
user=user,
parent_operation_id=refundable_expense.id,
amount_kopecks=500,
occurred_on=date(2026, 3, 4),
)
with pytest.raises(AppError) as exc:
convert_expense_to_savings(
db, space_id=space.id, user=user, operation_id=refund.id
)
assert exc.value.code == "LEDGER_SAVINGS_CONVERT_INVALID"
expense = create_expense(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=2_000,
occurred_on=date(2026, 3, 5),
)
convert_expense_to_savings(db, space_id=space.id, user=user, operation_id=expense.id)
with pytest.raises(AppError) as exc:
convert_expense_to_savings(
db, space_id=space.id, user=user, operation_id=expense.id
)
assert exc.value.code == "LEDGER_SAVINGS_CONVERT_INVALID"
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,
amount_kopecks=1_000,
occurred_on=date(2026, 3, 6),
)
with pytest.raises(AppError) as exc:
convert_expense_to_savings(
db, space_id=space.id, user=user, operation_id=transfer.id
)
assert exc.value.code == "LEDGER_SAVINGS_CONVERT_INVALID"
def test_convert_rejects_expense_with_confirmed_refund(db: Session) -> None:
user, space, account = seed(db)
expense = create_expense(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=2_000,
occurred_on=date(2026, 3, 3),
)
create_refund(
db,
space_id=space.id,
user=user,
parent_operation_id=expense.id,
amount_kopecks=500,
occurred_on=date(2026, 3, 4),
)
with pytest.raises(AppError) as exc:
convert_expense_to_savings(
db, space_id=space.id, user=user, operation_id=expense.id
)
assert exc.value.code == "LEDGER_SAVINGS_CONVERT_HAS_REFUNDS"
def test_convert_rejects_closed_period_and_cross_space_operation(db: Session) -> None:
user, space, account = seed(db)
other_space = create_space(db, user=user, name="Other")
category = Category(space_id=space.id, kind="monthly", custom_name="Food")
db.add(category)
db.flush()
expense = create_expense(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=3_000,
occurred_on=date(2026, 3, 7),
category_id=category.id,
)
period = get_or_create_period(db, space_id=space.id, year=2026, month=3)
close_period(db, space_id=space.id, user=user, period=period)
with pytest.raises(AppError) as exc:
convert_expense_to_savings(
db, space_id=space.id, user=user, operation_id=expense.id
)
assert exc.value.code == "BUDGET_PERIOD_CLOSED"
with pytest.raises(AppError) as exc:
convert_expense_to_savings(
db, space_id=other_space.id, user=user, operation_id=expense.id
)
assert exc.value.code == "LEDGER_OPERATION_NOT_FOUND"
def test_revert_rejects_plain_transfers_and_unknown_operations(db: Session) -> None:
user, space, account = seed(db)
savings_account, _ = ensure_savings_resources(db, space_id=space.id)
plain = create_transfer(
db,
space_id=space.id,
user=user,
source_account_id=account.id,
target_account_id=savings_account.id,
amount_kopecks=500,
occurred_on=date(2026, 3, 8),
)
with pytest.raises(AppError) as exc:
revert_savings_conversion(db, space_id=space.id, user=user, operation_id=plain.id)
assert exc.value.code == "LEDGER_SAVINGS_REVERT_INVALID"
with pytest.raises(AppError) as exc:
revert_savings_conversion(db, space_id=space.id, user=user, operation_id=uuid4())
assert exc.value.code == "LEDGER_OPERATION_NOT_FOUND"
def test_system_account_rejects_external_operations(db: Session) -> None:
user, space, _ = seed(db)
savings_account, savings_category = ensure_savings_resources(db, space_id=space.id)
with pytest.raises(AppError) as exc:
create_expense(
db,
space_id=space.id,
user=user,
account_id=savings_account.id,
amount_kopecks=100,
occurred_on=date(2026, 3, 9),
)
assert exc.value.code == "LEDGER_SYSTEM_ACCOUNT_NOT_EXTERNAL"
with pytest.raises(AppError) as exc:
create_income(
db,
space_id=space.id,
user=user,
account_id=savings_account.id,
amount_kopecks=100,
occurred_on=date(2026, 3, 9),
)
assert exc.value.code == "LEDGER_SYSTEM_ACCOUNT_NOT_EXTERNAL"
assert savings_category is not None
def test_manual_transfer_with_savings_gets_system_category(db: Session) -> None:
user, space, account = seed(db)
cash = create_account(
db,
space_id=space.id,
user=user,
name="Cash",
kind="cash",
opened_on=date(2026, 3, 1),
)
savings_account, savings_category = ensure_savings_resources(db, space_id=space.id)
into = create_transfer(
db,
space_id=space.id,
user=user,
source_account_id=account.id,
target_account_id=savings_account.id,
amount_kopecks=700,
occurred_on=date(2026, 3, 10),
)
assert into.category_id == savings_category.id
out_of = create_transfer(
db,
space_id=space.id,
user=user,
source_account_id=savings_account.id,
target_account_id=cash.id,
amount_kopecks=200,
occurred_on=date(2026, 3, 11),
)
assert out_of.category_id == savings_category.id
regular = create_transfer(
db,
space_id=space.id,
user=user,
source_account_id=account.id,
target_account_id=cash.id,
amount_kopecks=300,
occurred_on=date(2026, 3, 12),
)
assert regular.category_id is None
def test_system_account_cannot_be_archived_or_deleted(db: Session) -> None:
user, space, _ = seed(db)
savings_account, _ = ensure_savings_resources(db, space_id=space.id)
from app.ledger.service import archive_account, delete_account
with pytest.raises(AppError) as exc:
archive_account(db, space_id=space.id, user=user, account_id=savings_account.id)
assert exc.value.code == "LEDGER_ACCOUNT_SYSTEM"
with pytest.raises(AppError) as exc:
delete_account(db, space_id=space.id, user=user, account_id=savings_account.id)
assert exc.value.code == "LEDGER_ACCOUNT_SYSTEM"
def test_operations_on_other_spaces_are_isolated(db: Session) -> None:
user, space, account = seed(db)
other_space = create_space(db, user=user, name="Elsewhere")
expense = create_expense(
db,
space_id=space.id,
user=user,
account_id=account.id,
amount_kopecks=1_500,
occurred_on=date(2026, 3, 14),
)
convert_expense_to_savings(db, space_id=space.id, user=user, operation_id=expense.id)
assert (
db.query(Operation).filter_by(space_id=other_space.id).count() == 0
)
+68
View File
@@ -0,0 +1,68 @@
from datetime import date
from pathlib import Path
from alembic.config import Config
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from alembic import command
from app.config import get_settings
from app.identity.models import User
from app.ledger.models import Account, Category
from app.spaces.models import FinancialSpace, Membership
def test_upgrade_backfills_one_pair_without_claiming_same_named_rows(
tmp_path: Path, monkeypatch,
) -> None:
database_url = f"sqlite:///{tmp_path / 'migration.db'}"
monkeypatch.setenv("DATABASE_URL", database_url)
get_settings.cache_clear()
config = Config(str(Path(__file__).parents[1] / "alembic.ini"))
config.set_main_option("script_location", str(Path(__file__).parents[1] / "alembic"))
try:
command.upgrade(config, "0002")
engine = create_engine(database_url)
with Session(engine) as db:
user = User(email="migration@example.com", password_hash="unused")
space = FinancialSpace(name="Legacy")
db.add_all([user, space])
db.flush()
db.add(Membership(space_id=space.id, user_id=user.id, role="owner"))
db.add(
Account(
space_id=space.id,
name="Savings",
kind="debit",
opened_on=date(2026, 1, 1),
)
)
db.add(
Category(
space_id=space.id,
name_override="Savings",
kind="monthly",
)
)
db.commit()
space_id = space.id
command.upgrade(config, "head")
command.upgrade(config, "head")
with Session(engine) as db:
accounts = db.query(Account).filter_by(space_id=space_id).all()
categories = db.query(Category).filter_by(space_id=space_id).all()
system_account = [item for item in accounts if item.system_key == "savings"]
system_category = [item for item in categories if item.system_key == "savings"]
assert len(system_account) == 1
assert len(system_category) == 1
assert system_category[0].linked_account_id == system_account[0].id
assert any(item.name == "Savings" and item.system_key is None for item in accounts)
assert any(
item.name_override == "Savings" and item.system_key is None
for item in categories
)
finally:
get_settings.cache_clear()
+4 -1
View File
@@ -10,10 +10,13 @@ The product needs trustworthy account balances including credit-card debt, zero-
## Decision
- The ledger is a journal of `operations` (`expense | income | transfer | refund`; `confirmed | voided`) and `postings` (one posting for external income/expense/refund; two equal and opposite postings for an internal transfer). Amounts are integer kopecks; no floats.
- The ledger is a journal of `operations` (`expense | income | transfer | refund | adjustment`; `confirmed | voided`) and `postings` (one posting for external income/expense/refund; two equal and opposite postings for an internal transfer; exactly one signed posting for a balance adjustment). Amounts are integer kopecks; no floats.
- Balances are derived, never mutated directly: opening position plus confirmed, non-voided postings. Credit debt follows one documented sign convention.
- This is **not** event sourcing and **not** full double-entry against the external world: the journal is just enough structure to make balances reconstructable and voiding explicit.
- Budgeting splits plan from funding: `plan_assignments` express intent, `funding_movements` move actually received money from the general pool. Monthly categories fold their remainder into the pool at close; savings envelopes carry balances forward; each credit card has a permanent reserve.
- Every financial space has exactly one visible system `savings` account paired with one system `savings_envelope` category, joined by `system_key`. The pair is created idempotently (space creation, migration, domain commands), is protected from rename/archive/delete, and is identified only by `system_key` — same-named user entities are never captured. Transfers into the envelope are carved out of the general pool as its backing; transfers back out return to the pool.
- A balance adjustment is a journal operation with a single signed posting for the delta (the operation amount stays positive; direction lives in the posting). It moves assets and the envelope balance but never income, expenses, cash flow or the budget pool, and every adjustment stores old/new/delta plus a mandatory reason in the audit log. Direct changes to stored balances remain forbidden.
- Expenses can be converted into savings transfers and converted back; the conversion marks the operation and stores the previous category so reversion restores type, category and postings exactly. Closed periods protect conversions, reversions and adjustments with the same reopen-before-change rule as other mutations.
- Period closure writes an immutable snapshot. Changing or voiding an operation in a closed month requires an explicit cascade reopen of all subsequent periods; late refunds after closure credit the current pool and never rewrite the closed fact.
## Consequences
+3
View File
@@ -1,2 +1,5 @@
packages:
- "."
allowBuilds:
esbuild@0.25.12: true
+24 -1
View File
@@ -2,13 +2,17 @@ import { apiClient } from './client'
import type {
AcceptInvitePayload,
Account,
AdjustBalancePayload,
AdjustBalanceResult,
BalancesReport,
BudgetCategory,
BudgetSummary,
CashFlowMonth,
CategoryDefinition,
CategorySpendItem,
ConfirmImportPayload,
CreateAccountPayload,
CreateCategoryPayload,
CreateOperationPayload,
ExportInfo,
ImportDetail,
@@ -17,8 +21,10 @@ import type {
Operation,
OperationFilters,
Page,
PatchCategoryDefinitionPayload,
PlanFactReport,
PasswordResetPayload,
ResolveHoldPayload,
SaveRowDecisionPayload,
SpaceDeletionStatus,
User,
@@ -42,6 +48,8 @@ export const api = {
apiClient.post<Account>('/accounts', { json: payload }),
archive: (id: string) =>
apiClient.patch<Account>(`/accounts/${id}`, { json: { status: 'archived' } }),
adjustBalance: (id: string, payload: AdjustBalancePayload) =>
apiClient.post<AdjustBalanceResult>(`/accounts/${id}/adjust-balance`, { json: payload }),
},
operations: {
list: (filters: OperationFilters) =>
@@ -52,6 +60,10 @@ export const api = {
apiClient.post<Operation>(`/operations/${id}/void`, { json: { reason } }),
categorize: (id: string, categoryId: string) =>
apiClient.patch<Operation>(`/operations/${id}`, { json: { categoryId } }),
savingsTransfer: (id: string) =>
apiClient.post<Operation>(`/operations/${id}/savings-transfer`),
savingsTransferRevert: (id: string) =>
apiClient.post<Operation>(`/operations/${id}/savings-transfer/revert`),
},
budget: {
summary: (month: string) =>
@@ -70,9 +82,18 @@ export const api = {
reopen: (month: string) => apiClient.post<BudgetSummary>('/budget/reopen', { json: { month } }),
copyPrevious: (month: string) =>
apiClient.post<BudgetSummary>('/budget/copy-previous', { json: { month } }),
categoryDefinitions: (includeArchived: boolean) =>
apiClient.get<CategoryDefinition[]>('/budget/category-definitions', {
query: { includeArchived },
}),
createCategory: (payload: CreateCategoryPayload) =>
apiClient.post<CategoryDefinition>('/budget/categories', { json: payload }),
updateCategoryDefinition: (id: string, payload: PatchCategoryDefinitionPayload) =>
apiClient.patch<CategoryDefinition>(`/budget/category-definitions/${id}`, { json: payload }),
},
imports: {
list: () => apiClient.get<ImportSummary[]>('/imports'),
list: (month?: string) =>
apiClient.get<ImportSummary[]>('/imports', { query: { month } }),
get: (id: string) => apiClient.get<ImportDetail>(`/imports/${id}`),
upload: (file: File, accountId: string) => {
const formData = new FormData()
@@ -82,6 +103,8 @@ export const api = {
},
saveRowDecision: (id: string, rowId: string, payload: SaveRowDecisionPayload) =>
apiClient.patch<ImportDetail>(`/imports/${id}/rows/${rowId}`, { json: payload }),
resolveHold: (id: string, rowId: string, payload: ResolveHoldPayload) =>
apiClient.post<ImportDetail>(`/imports/${id}/rows/${rowId}/resolve`, { json: payload }),
confirm: (id: string, payload: ConfirmImportPayload) =>
apiClient.post<ImportDetail>(`/imports/${id}/confirm`, { json: payload }),
reparse: (id: string) => apiClient.post<ImportDetail>(`/imports/${id}/reparse`),
+555 -8
View File
@@ -89,6 +89,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/auth/password-reset": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Request Password Reset */
post: operations["request_password_reset_api_v1_auth_password_reset_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/auth/password-reset/confirm": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Confirm Password Reset */
post: operations["confirm_password_reset_api_v1_auth_password_reset_confirm_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/auth/me": {
parameters: {
query?: never;
@@ -140,6 +174,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/spaces/current/invitations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Invite Member */
post: operations["invite_member_api_v1_spaces_current_invitations_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/spaces/current/deletion": {
parameters: {
query?: never;
@@ -264,6 +315,57 @@ export interface paths {
patch: operations["patch_operation_api_v1_operations__operation_id__patch"];
trace?: never;
};
"/api/v1/accounts/{account_id}/adjust-balance": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Adjust Balance */
post: operations["adjust_balance_api_v1_accounts__account_id__adjust_balance_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/operations/{operation_id}/savings-transfer": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Savings Transfer */
post: operations["savings_transfer_api_v1_operations__operation_id__savings_transfer_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/operations/{operation_id}/savings-transfer/revert": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Savings Transfer Revert */
post: operations["savings_transfer_revert_api_v1_operations__operation_id__savings_transfer_revert_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/budget/summary": {
parameters: {
query?: never;
@@ -299,6 +401,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/budget/category-definitions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Category Definitions */
get: operations["category_definitions_api_v1_budget_category_definitions_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/budget/category-definitions/{category_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/** Patch Category Definition */
patch: operations["patch_category_definition_api_v1_budget_category_definitions__category_id__patch"];
trace?: never;
};
"/api/v1/budget/categories/{category_id}": {
parameters: {
query?: never;
@@ -555,6 +691,23 @@ export interface paths {
patch: operations["decide_row_api_v1_imports__document_id__rows__row_id__patch"];
trace?: never;
};
"/api/v1/imports/{document_id}/rows/{row_id}/resolve": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Resolve Hold */
post: operations["resolve_hold_api_v1_imports__document_id__rows__row_id__resolve_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/imports/{document_id}/confirm": {
parameters: {
query?: never;
@@ -732,10 +885,10 @@ export interface paths {
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Download */
post: operations["download_api_v1_exports__export_id__download_post"];
get: operations["download_api_v1_exports__export_id__download_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
@@ -790,6 +943,18 @@ export interface components {
/** Maskednumber */
maskedNumber?: string | null;
};
/** AdjustBalanceBody */
AdjustBalanceBody: {
/** Targetbalancekopecks */
targetBalanceKopecks: number;
/**
* Occurredon
* Format: date
*/
occurredOn: string;
/** Reason */
reason: string;
};
/** AssignmentItem */
AssignmentItem: {
/**
@@ -823,6 +988,13 @@ export interface components {
*/
kind: "monthly" | "savings_envelope";
};
/** CategoryDefinitionPatchBody */
CategoryDefinitionPatchBody: {
/** Name */
name?: string | null;
/** Archived */
archived?: boolean | null;
};
/** CategoryFundingBody */
CategoryFundingBody: {
/** Month */
@@ -889,6 +1061,15 @@ export interface components {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/** HoldResolveBody */
HoldResolveBody: {
/** Action */
action: string;
/** Reason */
reason: string;
/** Accountid */
accountId?: string | null;
};
/** InviteBody */
InviteBody: {
/**
@@ -907,6 +1088,14 @@ export interface components {
/** Password */
password: string;
};
/** MemberInviteBody */
MemberInviteBody: {
/**
* Email
* Format: email
*/
email: string;
};
/** MonthBody */
MonthBody: {
/** Month */
@@ -957,6 +1146,21 @@ export interface components {
/** Plannedkopecks */
plannedKopecks: number;
};
/** ResetBody */
ResetBody: {
/** Token */
token: string;
/** Password */
password: string;
};
/** ResetRequestBody */
ResetRequestBody: {
/**
* Email
* Format: email
*/
email: string;
};
/** ReviewBody */
ReviewBody: {
/**
@@ -1150,6 +1354,76 @@ export interface operations {
};
};
};
request_password_reset_api_v1_auth_password_reset_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ResetRequestBody"];
};
};
responses: {
/** @description Successful Response */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
confirm_password_reset_api_v1_auth_password_reset_confirm_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ResetBody"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
me_api_v1_auth_me_get: {
parameters: {
query?: never;
@@ -1245,6 +1519,45 @@ export interface operations {
};
};
};
invite_member_api_v1_spaces_current_invitations_post: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path?: never;
cookie?: {
fin_session?: string | null;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["MemberInviteBody"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
deletion_status_api_v1_spaces_current_deletion_get: {
parameters: {
query?: never;
@@ -1541,6 +1854,7 @@ export interface operations {
occurredFrom?: string | null;
occurredTo?: string | null;
search?: string | null;
uncategorized?: boolean;
limit?: number;
cursor?: string | null;
};
@@ -1696,6 +2010,121 @@ export interface operations {
};
};
};
adjust_balance_api_v1_accounts__account_id__adjust_balance_post: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path: {
account_id: string;
};
cookie?: {
fin_session?: string | null;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["AdjustBalanceBody"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
savings_transfer_api_v1_operations__operation_id__savings_transfer_post: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path: {
operation_id: string;
};
cookie?: {
fin_session?: string | null;
};
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
savings_transfer_revert_api_v1_operations__operation_id__savings_transfer_revert_post: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path: {
operation_id: string;
};
cookie?: {
fin_session?: string | null;
};
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
summary_api_v1_budget_summary_get: {
parameters: {
query: {
@@ -1805,6 +2234,82 @@ export interface operations {
};
};
};
category_definitions_api_v1_budget_category_definitions_get: {
parameters: {
query?: {
includeArchived?: boolean;
};
header?: never;
path?: never;
cookie?: {
fin_session?: string | null;
};
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
}[];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
patch_category_definition_api_v1_budget_category_definitions__category_id__patch: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path: {
category_id: string;
};
cookie?: {
fin_session?: string | null;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["CategoryDefinitionPatchBody"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
update_plan_api_v1_budget_categories__category_id__patch: {
parameters: {
query?: never;
@@ -2246,7 +2751,9 @@ export interface operations {
};
imports_api_v1_imports_get: {
parameters: {
query?: never;
query?: {
month?: string | null;
};
header?: never;
path?: never;
cookie?: {
@@ -2434,6 +2941,48 @@ export interface operations {
};
};
};
resolve_hold_api_v1_imports__document_id__rows__row_id__resolve_post: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path: {
document_id: string;
row_id: string;
};
cookie?: {
fin_session?: string | null;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["HoldResolveBody"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
confirm_api_v1_imports__document_id__confirm_post: {
parameters: {
query?: never;
@@ -2796,12 +3345,10 @@ export interface operations {
};
};
};
download_api_v1_exports__export_id__download_post: {
download_api_v1_exports__export_id__download_get: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
header?: never;
path: {
export_id: string;
};
+3 -1
View File
@@ -23,8 +23,10 @@ export const queryKeys = {
operations: (filters: OperationFilters) => [SPACE_ROOT, 'operations', filters] as const,
budgetSummary: (month: string) => [SPACE_ROOT, 'budget', 'summary', month] as const,
budgetCategories: (month: string) => [SPACE_ROOT, 'budget', 'categories', month] as const,
imports: () => [SPACE_ROOT, 'imports'] as const,
imports: (month?: string) =>
month ? ([SPACE_ROOT, 'imports', month] as const) : ([SPACE_ROOT, 'imports'] as const),
import: (id: string) => [SPACE_ROOT, 'imports', id] as const,
budgetCategoryDefinitions: () => [SPACE_ROOT, 'budget', 'category-definitions'] as const,
analyticsCategorySpend: (from: string, to: string) =>
[SPACE_ROOT, 'analytics', 'category-spend', from, to] as const,
analyticsPlanFact: (month: string) => [SPACE_ROOT, 'analytics', 'plan-fact', month] as const,
+82 -4
View File
@@ -8,7 +8,7 @@ export interface User {
timezone: string
}
export type AccountType = 'cash' | 'debit' | 'credit'
export type AccountType = 'cash' | 'debit' | 'credit' | 'savings'
export type AccountStatus = 'active' | 'archived'
export interface Account {
@@ -20,9 +20,15 @@ export interface Account {
maskedNumber: string | null
openedOn: string
currency: 'RUB'
systemKey: string | null
nameKey: string | null
balanceKopecks: number
}
export function isSystemAccount(account: Account): boolean {
return account.systemKey !== null
}
export interface CreateAccountPayload {
name: string
type: AccountType
@@ -32,7 +38,7 @@ export interface CreateAccountPayload {
maskedNumber?: string
}
export type OperationKind = 'expense' | 'income' | 'transfer' | 'refund'
export type OperationKind = 'expense' | 'income' | 'transfer' | 'refund' | 'adjustment'
export type OperationStatus = 'confirmed' | 'voided'
export interface Operation {
@@ -51,10 +57,22 @@ export interface Operation {
incomeSourceName: string | null
description: string | null
parentOperationId: string | null
convertedFromExpense: boolean
version: number
createdAt: string
}
export interface AdjustBalancePayload {
targetBalanceKopecks: number
occurredOn: string
reason: string
}
export interface AdjustBalanceResult extends Operation {
oldBalanceKopecks: number
newBalanceKopecks: number
}
export type CreateOperationPayload =
| {
kind: 'expense'
@@ -131,11 +149,25 @@ export interface BudgetWarning {
message?: string
}
export interface BudgetBlocker {
export interface CountBlocker {
code: 'IMPORTS_UNRESOLVED' | 'IMPORT_UNRESOLVED_HOLDS' | 'BUDGET_UNCATEGORIZED_EXPENSES'
count: number
message?: string
}
export interface CategoriesBlocker {
code: 'BUDGET_NEGATIVE_ENVELOPE'
categories: string[]
message?: string
}
export interface UnknownBlocker {
code: string
message?: string
}
export type BudgetBlocker = CountBlocker | CategoriesBlocker | UnknownBlocker
export type ExpectedIncomeStatus = 'expected' | 'partially_matched' | 'matched'
export interface ExpectedIncome {
@@ -162,12 +194,14 @@ export interface BudgetSummary {
}
export type CategoryKind = 'monthly' | 'savings_envelope' | 'credit_reserve'
export type CreatableCategoryKind = 'monthly' | 'savings_envelope'
export interface BudgetCategory {
id: string
name: string | null
nameKey: string | null
kind: CategoryKind
system: boolean
archived: boolean
creditAccountId: string | null
plannedKopecks: number
@@ -176,6 +210,26 @@ export interface BudgetCategory {
availableKopecks: number
}
export interface CategoryDefinition {
id: string
name: string | null
nameKey: string | null
kind: CategoryKind
systemKey: string | null
linkedAccountId: string | null
archived: boolean
}
export interface CreateCategoryPayload {
name: string
kind: CreatableCategoryKind
}
export interface PatchCategoryDefinitionPayload {
name?: string
archived?: boolean
}
export type ImportStatus =
| 'uploaded'
| 'queued'
@@ -194,6 +248,7 @@ export interface ImportSummary {
accountName: string | null
periodFrom: string | null
periodTo: string | null
unresolvedHoldCount: number
createdAt: string
}
@@ -213,7 +268,20 @@ export interface Reconciliation {
reason: string | null
}
export type RowDecision = 'pending' | 'create' | 'discard' | 'duplicate' | 'transfer'
export type RowDecision = 'pending' | 'create' | 'discard' | 'duplicate' | 'transfer' | 'savings_transfer'
export type RowBankStatus = 'posted' | 'hold'
export type HoldResolutionAction = 'confirm_as_posted' | 'discard_as_cancelled'
export interface HoldResolution {
resolution: 'confirmed_as_posted' | 'discarded_as_cancelled'
resolvingSourceRecordId: string | null
operationId: string | null
resolvedByUserId: string | null
reason: string | null
resolvedAt: string | null
}
export interface ImportRow {
id: string
@@ -221,11 +289,13 @@ export interface ImportRow {
occurredOn: string
amountKopecks: number
direction: 'in' | 'out'
bankStatus: RowBankStatus
description: string | null
payeeHint: string | null
warnings: string[]
decision: RowDecision
duplicateOfOperationId: string | null
holdResolution: HoldResolution | null
}
export interface ImportDetail extends ImportSummary {
@@ -240,6 +310,8 @@ export interface ImportDetail extends ImportSummary {
discard: number
duplicate: number
transfer: number
savingsTransfer: number
pending: number
}
}
@@ -248,6 +320,12 @@ export interface SaveRowDecisionPayload {
duplicateOfOperationId?: string
}
export interface ResolveHoldPayload {
action: HoldResolutionAction
reason: string
accountId?: string
}
export interface ConfirmImportPayload {
confirmedWithoutFullReconciliation?: boolean
reason?: string
@@ -0,0 +1,77 @@
import { useTranslation } from 'react-i18next'
import type { TFunction } from 'i18next'
import { Link } from 'react-router-dom'
import type { BudgetBlocker } from '@/api/types'
interface Cta {
to: string
label: string
}
function blockerText(blocker: BudgetBlocker, t: TFunction): string {
if (blocker.code === 'IMPORTS_UNRESOLVED' && 'count' in blocker) {
return t('budget.blockers.IMPORTS_UNRESOLVED', { count: blocker.count })
}
if (blocker.code === 'IMPORT_UNRESOLVED_HOLDS' && 'count' in blocker) {
return t('budget.blockers.IMPORT_UNRESOLVED_HOLDS', { count: blocker.count })
}
if (blocker.code === 'BUDGET_UNCATEGORIZED_EXPENSES' && 'count' in blocker) {
return t('budget.blockers.BUDGET_UNCATEGORIZED_EXPENSES', { count: blocker.count })
}
if (blocker.code === 'BUDGET_NEGATIVE_ENVELOPE' && 'categories' in blocker) {
return t('budget.blockers.BUDGET_NEGATIVE_ENVELOPE', {
categories: blocker.categories.join(', '),
})
}
console.warn('[budget] unknown blocker code', blocker.code)
return t('budget.blockers.unknown')
}
function blockerCta(blocker: BudgetBlocker, month: string, t: TFunction): Cta | null {
if (
blocker.code === 'IMPORTS_UNRESOLVED' ||
blocker.code === 'IMPORT_UNRESOLVED_HOLDS' ||
blocker.code === 'BUDGET_UNCATEGORIZED_EXPENSES'
) {
const ctaKey = {
IMPORTS_UNRESOLVED: 'budget.blockers.cta.imports',
IMPORT_UNRESOLVED_HOLDS: 'budget.blockers.cta.holds',
BUDGET_UNCATEGORIZED_EXPENSES: 'budget.blockers.cta.uncategorized',
} as const
const to =
blocker.code === 'IMPORT_UNRESOLVED_HOLDS'
? `/review?month=${month}#holds`
: `/review?month=${month}`
return { to, label: t(ctaKey[blocker.code]) }
}
if (blocker.code === 'BUDGET_NEGATIVE_ENVELOPE') {
return { to: '/budget#categories', label: t('budget.blockers.cta.envelope') }
}
return null
}
interface BudgetBlockersProps {
blockers: readonly BudgetBlocker[]
month: string
}
export function BudgetBlockers({ blockers, month }: BudgetBlockersProps) {
const { t } = useTranslation()
return (
<ul className="blocker-list">
{blockers.map((blocker) => {
const cta = blockerCta(blocker, month, t)
return (
<li key={blocker.code} className="blocker-item">
<span>{blockerText(blocker, t)}</span>
{cta ? (
<Link className="blocker-link" to={cta.to}>
{cta.label}
</Link>
) : null}
</li>
)
})}
</ul>
)
}
@@ -0,0 +1,222 @@
import { useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import type { CategoryDefinition, CreatableCategoryKind } from '@/api/types'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { SelectField, TextField } from '@/components/ui/fields'
import { Modal } from '@/components/ui/Modal'
import { QueryGate } from '@/components/ui/QueryGate'
import {
useCategoryDefinitions,
useCreateCategory,
useUpdateCategoryDefinition,
} from '@/hooks/useBudget'
import { categoryLabel } from '@/lib/categoryLabel'
const CREATE_KINDS: readonly CreatableCategoryKind[] = ['monthly', 'savings_envelope']
function CreateCategoryForm() {
const { t } = useTranslation()
const create = useCreateCategory()
const schema = z.object({
name: z.string().trim().min(1, t('common.requiredField')),
kind: z.enum(['monthly', 'savings_envelope']),
})
type Values = z.infer<typeof schema>
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: { name: '', kind: 'monthly' },
})
const submit = handleSubmit((values) => {
create.mutate(values, { onSuccess: () => reset({ name: '', kind: values.kind }) })
})
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>
<Button type="submit" disabled={create.isPending}>
{create.isPending ? t('common.saving') : t('common.create')}
</Button>
</form>
)
}
function RenameModal({
definition,
onClose,
}: {
definition: CategoryDefinition
onClose: () => void
}) {
const { t } = useTranslation()
const update = useUpdateCategoryDefinition()
const schema = z.object({
name: z.string().trim().min(1, t('common.requiredField')),
})
type Values = z.infer<typeof schema>
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: { name: definition.name ?? '' },
})
const submit = handleSubmit((values) => {
update.mutate(
{ id: definition.id, payload: { name: values.name } },
{ onSuccess: onClose },
)
})
return (
<Modal open onClose={onClose} title={t('budget.manager.renameTitle')}>
<form onSubmit={submit} noValidate className="form-grid">
<TextField label={t('common.name')} error={errors.name?.message} {...register('name')} />
<Button type="submit" disabled={update.isPending}>
{update.isPending ? t('common.saving') : t('common.save')}
</Button>
</form>
</Modal>
)
}
export function CategoryManagerModal({ onClose }: { onClose: () => void }) {
const { t } = useTranslation()
const definitions = useCategoryDefinitions()
const update = useUpdateCategoryDefinition()
const [tab, setTab] = useState<'active' | 'archived'>('active')
const [renameTarget, setRenameTarget] = useState<CategoryDefinition | null>(null)
const [archiveTarget, setArchiveTarget] = useState<CategoryDefinition | null>(null)
return (
<Modal open onClose={onClose} title={t('budget.manager.title')}>
<CreateCategoryForm />
<div className="manager-tabs" role="group" aria-label={t('budget.manager.tabsLabel')}>
<Button
variant={tab === 'active' ? 'primary' : 'subtle'}
aria-pressed={tab === 'active'}
onClick={() => setTab('active')}
>
{t('budget.manager.tabActive')}
</Button>
<Button
variant={tab === 'archived' ? 'primary' : 'subtle'}
aria-pressed={tab === 'archived'}
onClick={() => setTab('archived')}
>
{t('budget.manager.tabArchive')}
</Button>
</div>
<QueryGate query={definitions}>
{(all) => {
const list = all.filter((item) => (tab === 'active' ? !item.archived : item.archived))
if (list.length === 0) {
return (
<p className="muted">
{tab === 'active'
? t('budget.manager.emptyActive')
: t('budget.manager.emptyArchive')}
</p>
)
}
return (
<ul className="manager-list">
{list.map((definition) => {
const isSystem = definition.systemKey !== null
return (
<li key={definition.id} className="manager-row">
<div className="manager-row-main">
<span className="manager-row-name">
{categoryLabel(definition, t)}
{isSystem ? (
<Badge tone="info">{t('budget.manager.systemBadge')}</Badge>
) : null}
{definition.archived ? (
<Badge tone="neutral">{t('accounts.status.archived')}</Badge>
) : null}
</span>
<span className="manager-row-meta">
{t(`budget.category.kind.${definition.kind}`)}
{isSystem ? ` · ${t('budget.manager.systemProtected')}` : ''}
</span>
</div>
{!isSystem ? (
<div className="inline-actions manager-row-actions">
{!definition.archived ? (
<>
<Button
variant="subtle"
disabled={update.isPending}
onClick={() => setRenameTarget(definition)}
>
{t('budget.manager.rename')}
</Button>
<Button
variant="subtle"
disabled={update.isPending}
onClick={() => setArchiveTarget(definition)}
>
{t('budget.manager.archive')}
</Button>
</>
) : (
<Button
variant="subtle"
disabled={update.isPending}
onClick={() =>
update.mutate({ id: definition.id, payload: { archived: false } })
}
>
{t('budget.manager.restore')}
</Button>
)}
</div>
) : null}
</li>
)
})}
</ul>
)
}}
</QueryGate>
{renameTarget ? (
<RenameModal definition={renameTarget} onClose={() => setRenameTarget(null)} />
) : null}
<ConfirmDialog
open={archiveTarget !== null}
danger
title={t('budget.manager.archiveTitle')}
body={t('budget.manager.archiveBody', { name: archiveTarget?.name ?? '' })}
confirmLabel={t('budget.manager.archive')}
onClose={() => setArchiveTarget(null)}
onConfirm={() => {
if (archiveTarget) {
update.mutate({ id: archiveTarget.id, payload: { archived: true } })
}
}}
/>
</Modal>
)
}
+1 -1
View File
@@ -16,7 +16,7 @@ const NAV_ITEMS = [
] as const
const MOBILE_NAV_ITEMS = NAV_ITEMS.filter((item) =>
['/', '/operations', '/imports', '/analytics', '/settings'].includes(item.to),
['/', '/budget', '/operations', '/imports', '/review', '/accounts'].includes(item.to),
)
export function AppLayout() {
+27 -1
View File
@@ -1,7 +1,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '@/api/endpoints'
import { invalidationRoots, queryKeys } from '@/api/queryKeys'
import type { CreateAccountPayload } from '@/api/types'
import type { AdjustBalancePayload, CreateAccountPayload } from '@/api/types'
import { isSystemAccount } from '@/api/types'
import { useToast } from '@/components/ui/Toaster'
import { useTranslation } from 'react-i18next'
import { describeApiError } from '@/lib/apiErrors'
@@ -15,6 +16,13 @@ export function useActiveAccounts() {
return { ...accounts, data: accounts.data?.filter((account) => account.status === 'active') }
}
/** Active non-system accounts: the savings system account is not a valid
* target for statements, expenses, incomes or refunds. */
export function useUserAccounts() {
const accounts = useActiveAccounts()
return { ...accounts, data: accounts.data?.filter((account) => !isSystemAccount(account)) }
}
export function useCreateAccount() {
const queryClient = useQueryClient()
const { t } = useTranslation()
@@ -44,3 +52,21 @@ export function useArchiveAccount() {
onError: (error) => toast.push('error', describeApiError(error, t)),
})
}
export function useAdjustBalance() {
const queryClient = useQueryClient()
const { t } = useTranslation()
const toast = useToast()
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: AdjustBalancePayload }) =>
api.accounts.adjustBalance(id, payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: invalidationRoots.accounts })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.operations })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.budget })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.analytics })
toast.push('success', t('toast.balanceAdjusted'))
},
onError: (error) => toast.push('error', describeApiError(error, t)),
})
}
+59
View File
@@ -1,6 +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 { useToast } from '@/components/ui/Toaster'
import { useTranslation } from 'react-i18next'
import { describeApiError } from '@/lib/apiErrors'
@@ -16,6 +17,64 @@ export function useBudgetCategories(month: string) {
})
}
export function useCategoryDefinitions() {
return useQuery({
queryKey: queryKeys.budgetCategoryDefinitions(),
queryFn: () => api.budget.categoryDefinitions(true),
})
}
function useCategoryDefinitionInvalidation() {
const queryClient = useQueryClient()
return () => {
void queryClient.invalidateQueries({ queryKey: invalidationRoots.budget })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.operations })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.analytics })
}
}
export function useCreateCategory() {
const invalidate = useCategoryDefinitionInvalidation()
const { t } = useTranslation()
const toast = useToast()
const onError = useBudgetErrorToaster()
return useMutation({
mutationFn: (payload: CreateCategoryPayload) => api.budget.createCategory(payload),
onSuccess: (definition) => {
invalidate()
toast.push('success', t('toast.categoryCreated', { name: definition.name ?? '' }))
},
onError,
})
}
export function useUpdateCategoryDefinition() {
const invalidate = useCategoryDefinitionInvalidation()
const { t } = useTranslation()
const toast = useToast()
const onError = useBudgetErrorToaster()
return useMutation({
mutationFn: ({
id,
payload,
}: {
id: string
payload: PatchCategoryDefinitionPayload
}) => api.budget.updateCategoryDefinition(id, payload),
onSuccess: (definition, variables) => {
invalidate()
if (variables.payload.archived === true) {
toast.push('success', t('toast.categoryArchived'))
} else if (variables.payload.archived === false) {
toast.push('success', t('toast.categoryRestored'))
} else {
toast.push('success', t('toast.categoryRenamed', { name: definition.name ?? '' }))
}
},
onError,
})
}
function useBudgetErrorToaster() {
const { t } = useTranslation()
const toast = useToast()
+23 -3
View File
@@ -4,6 +4,7 @@ import { invalidationRoots, queryKeys } from '@/api/queryKeys'
import type {
ConfirmImportPayload,
ImportStatus,
ResolveHoldPayload,
SaveRowDecisionPayload,
} from '@/api/types'
import { useToast } from '@/components/ui/Toaster'
@@ -11,10 +12,10 @@ import { useTranslation } from 'react-i18next'
import { describeApiError } from '@/lib/apiErrors'
import { importListPollInterval, importPollInterval } from '@/lib/importStatus'
export function useImports() {
export function useImports(month?: string) {
return useQuery({
queryKey: queryKeys.imports(),
queryFn: api.imports.list,
queryKey: queryKeys.imports(month),
queryFn: () => api.imports.list(month),
refetchInterval: (query) => {
const statuses = query.state.data?.map((item) => item.status as ImportStatus)
return importListPollInterval(statuses)
@@ -67,6 +68,25 @@ export function useSaveRowDecision(importId: string) {
})
}
export function useResolveHold(importId: string) {
const queryClient = useQueryClient()
const { t } = useTranslation()
const toast = useToast()
return useMutation({
mutationFn: ({ rowId, payload }: { rowId: string; payload: ResolveHoldPayload }) =>
api.imports.resolveHold(importId, rowId, payload),
onSuccess: (updated) => {
queryClient.setQueryData(queryKeys.import(importId), updated)
void queryClient.invalidateQueries({ queryKey: invalidationRoots.imports })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.operations })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.accounts })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.budget })
toast.push('success', t('toast.holdResolved'))
},
onError: (error) => toast.push('error', describeApiError(error, t)),
})
}
export function useConfirmImport(importId: string) {
const queryClient = useQueryClient()
const { t } = useTranslation()
+14
View File
@@ -55,3 +55,17 @@ export function useCategorizeOperation() {
'toast.categoryAssigned',
)
}
export function useConvertToSavings() {
return useLedgerMutation(
({ id }: { id: string }) => api.operations.savingsTransfer(id),
'toast.convertedToSavings',
)
}
export function useRevertSavingsConversion() {
return useLedgerMutation(
({ id }: { id: string }) => api.operations.savingsTransferRevert(id),
'toast.savingsConversionReverted',
)
}
+103 -8
View File
@@ -88,7 +88,23 @@
"validation_error": "Please check the form fields.",
"conflict": "Data was changed elsewhere. Refresh and try again.",
"rate_limited": "Too many attempts. Please wait a moment.",
"session_expired": "Your session has expired. Please sign in again."
"session_expired": "Your session has expired. Please sign in again.",
"LEDGER_ADJUST_NOOP": "The balance already equals the target — nothing to adjust.",
"LEDGER_ADJUST_TARGET_NEGATIVE": "The target balance cannot be negative.",
"LEDGER_ADJUST_REASON_REQUIRED": "An adjustment reason is required.",
"LEDGER_ACCOUNT_ARCHIVED": "The account is archived — restore it first.",
"LEDGER_SAVINGS_CONVERT_INVALID": "Only a confirmed expense in an open month can be converted to savings.",
"LEDGER_SAVINGS_REVERT_INVALID": "Only a previously converted transfer can be reverted to an expense.",
"LEDGER_SAVINGS_CONFLICT": "A system savings resource conflict was detected.",
"BUDGET_PERIOD_CLOSED": "The month is closed. Reopen it first.",
"BUDGET_CATEGORY_SYSTEM": "System categories cannot be renamed or archived.",
"BUDGET_CATEGORY_ARCHIVED": "The category is archived — restore it first.",
"BUDGET_CATEGORY_NAME_REQUIRED": "A category name is required.",
"BUDGET_CATEGORY_KIND_INVALID": "Unknown category kind.",
"IMPORT_HOLD_REASON_REQUIRED": "A resolution reason is required.",
"IMPORT_HOLD_ALREADY_RESOLVED": "This operation already has a resolution.",
"IMPORT_HOLD_DOCUMENT_NOT_CONFIRMED": "Holds can be resolved only after the import is applied.",
"IMPORT_HOLD_ACCOUNT_REQUIRED": "The statement account mapping is missing."
},
"auth": {
"loginTitle": "Sign in",
@@ -120,7 +136,7 @@
},
"accounts": {
"title": "Accounts",
"subtitle": "Debit, cash and credit accounts of the space.",
"subtitle": "Debit, cash, credit accounts and the system savings account.",
"add": "Add account",
"empty": "No accounts yet. Create your first account to start tracking.",
"formTitle": "New account",
@@ -128,6 +144,8 @@
"type.cash": "Cash",
"type.debit": "Debit",
"type.credit": "Credit card",
"type.savings": "Savings",
"systemBadge": "System",
"bank": "Bank",
"bankHint": "Optional, e.g. “alfa”",
"maskedNumber": "Masked number",
@@ -143,7 +161,15 @@
"archive": "Archive",
"archiveBlockedHint": "Only an account with zero balance can be archived",
"archiveConfirmTitle": "Archive account",
"archiveConfirmBody": "Account “{{name}}” will leave working lists but remain in history."
"archiveConfirmBody": "Account “{{name}}” will leave working lists but remain in history.",
"adjust": "Adjust",
"adjustTitle": "Adjust balance",
"adjustCurrentBalance": "Current balance",
"adjustTargetBalance": "New balance",
"adjustDate": "Adjustment date",
"adjustReason": "Reason",
"adjustReasonHint": "Required; kept in the audit journal",
"adjustNotice": "Adjusting changes the account balance directly: it is not income or expense. It never enters income, expenses, cash flow or the budget pool."
},
"operations": {
"title": "Ledger",
@@ -162,6 +188,7 @@
"kind.income": "Income",
"kind.transfer": "Transfer",
"kind.refund": "Refund",
"kind.adjustment": "Adjustment",
"status.confirmed": "Confirmed",
"status.voided": "Voided",
"noCategory": "Uncategorized",
@@ -183,6 +210,12 @@
"voidBody": "Operation of {{date}} for {{amount}} will be marked voided.",
"voidReason": "Void reason",
"voidReasonHint": "At least 3 characters",
"convert": "To savings",
"convertTitle": "Convert to savings",
"convertBody": "The expense of {{date}} for {{amount}} becomes a transfer to Savings: it leaves the month's expenses, grows the Savings account and reduces the common budget pool. You can revert this later.",
"revert": "Back to expense",
"revertTitle": "Revert to expense",
"revertBody": "The transfer of {{date}} for {{amount}} becomes a regular expense in the original category again; the common budget pool recalculates.",
"prevPage": "Back",
"nextPage": "Next"
},
@@ -208,9 +241,27 @@
"categories": "Categories",
"reserves": "Credit reserves",
"noCategories": "No categories this month yet. Copy the previous month or create a plan.",
"manageCategories": "Manage categories",
"savingsEnvelopeHint": "The Savings envelope is fed by transfers and account adjustments; it has no plan.",
"category.kind.monthly": "Monthly",
"category.kind.savings_envelope": "Envelope",
"category.kind.credit_reserve": "Credit reserve",
"manager": {
"title": "Manage categories",
"tabsLabel": "Category lists",
"tabActive": "Active",
"tabArchive": "Archive",
"systemBadge": "System",
"systemProtected": "renaming and archiving are unavailable",
"rename": "Rename",
"archive": "Archive",
"restore": "Restore",
"renameTitle": "Rename category",
"archiveTitle": "Archive category",
"archiveBody": "Category “{{name}}” will leave new plans and categorization, history remains.",
"emptyActive": "No user categories yet — create the first one in the form above.",
"emptyArchive": "The category archive is empty."
},
"planTitle": "Plan: {{name}}",
"fundTitle": "Fund: {{name}}",
"fund": "Fund",
@@ -223,13 +274,25 @@
"income.status.partially_matched": "Partial",
"income.status.matched": "Received",
"blockersTitle": "Close is blocked",
"blockers.uncategorized_expenses": "There are expenses without a category.",
"blockers.negative_envelope": "A savings envelope balance is negative.",
"blockers.unresolved_imports": "There are unfinished imports.",
"blockers.open_decisions": "There are unresolved import decisions.",
"blockers": {
"IMPORTS_UNRESOLVED": "Statement imports are unfinished: {{count}}",
"IMPORT_UNRESOLVED_HOLDS": "Unconfirmed operations without a resolution: {{count}}",
"BUDGET_UNCATEGORIZED_EXPENSES": "Expenses without a category: {{count}}",
"BUDGET_NEGATIVE_ENVELOPE": "Negative envelope balance: {{categories}}",
"unknown": "Close is blocked by an unresolved reason.",
"cta": {
"imports": "Open review queue",
"holds": "Resolve operations",
"uncategorized": "Assign categories",
"envelope": "Open categories"
}
},
"warnings.plan_imbalance": "The plan exceeds the projected pool.",
"warnings.plan_exceeds_pool": "The plan exceeds the projected pool.",
"warnings.underfunded_credit_debt": "Credit card debt is not fully funded.",
"warnings.underfunded_credit": "Credit card debt is not fully funded.",
"warnings.expected_income_variance": "Expected income deviates from actual.",
"warnings.unknown": "The month has another issue that needs review.",
"close": "Close month",
"closeConfirmTitle": "Close month",
"closeConfirmBody": "{{month}} will be closed and monthly category leftovers will roll into the general pool.",
@@ -256,7 +319,13 @@
"uploading": "Uploading…",
"adapterHint": "Supported: Alfa debit/credit XLSX, Ozon debit/credit PDF, Rocketbank debit PDF.",
"file": "Statement file",
"fileName": "Statement file",
"targetAccount": "Target account",
"adapters.alfa_debit_xlsx": "Alfa debit",
"adapters.alfa_credit_xlsx": "Alfa credit",
"adapters.ozon_debit_pdf": "Ozon debit",
"adapters.ozon_credit_pdf": "Ozon credit",
"adapters.rocketbank_debit_pdf": "Rocketbank debit",
"unassigned": "Unassigned",
"empty": "No imports yet.",
"bank": "Bank / contract",
@@ -289,6 +358,19 @@
"blockers.unresolved_duplicate": "There are unresolved duplicates.",
"blockers.unresolved_transfer": "There are unresolved transfers.",
"blockers.account_conflict": "Account fingerprint conflicts with an existing one.",
"unresolvedHolds": "On hold",
"unresolvedHoldsBadge": "On-hold operations: {{count}}",
"holdBadge": "Hold",
"holdPosted": "Operation posted",
"holdPostedTitle": "Operation posted",
"holdPostedBody": "The held operation of {{date}} for {{amount}} will be posted to the statement account as a regular operation.",
"holdCancelled": "Operation cancelled",
"holdCancelledTitle": "Operation cancelled",
"holdCancelledBody": "The held operation of {{date}} for {{amount}} will be marked cancelled and never enters the ledger.",
"holdReason": "Resolution reason",
"holdReasonHint": "Required; kept in the audit journal",
"holdResolution.confirmed_as_posted": "Posted",
"holdResolution.discarded_as_cancelled": "Cancelled",
"warnings": "Warnings ({{count}})",
"warnings.amount_warning": "Unusual amount.",
"warnings.date_warning": "Unusual date.",
@@ -303,7 +385,7 @@
"reconciliation.mismatched": "Mismatched",
"reconciliation.not_available": "Not available",
"reconciliation.confirmed_without": "Confirmed without reconciliation",
"totals": "Rows: {{total}} · create: {{create}} · discard: {{discard}} · duplicates: {{duplicate}} · transfers: {{transfer}}",
"totals": "Rows: {{total}} · create: {{create}} · discard: {{discard}} · duplicates: {{duplicate}} · transfers: {{transfer}} · savings: {{savings}} · pending: {{pending}}",
"rowsTitle": "Statement rows",
"noRows": "No rows.",
"rowWarnings": "Notes",
@@ -318,6 +400,7 @@
"decision.discard": "Discard",
"decision.duplicate": "Duplicate",
"decision.transfer": "Transfer",
"decision.savings_transfer": "Savings transfer",
"confirm": "Apply import",
"confirmTitle": "Apply import",
"confirmBody": "Operations will be created from the selected rows. Applying is atomic and can be reverted.",
@@ -372,9 +455,16 @@
"toast": {
"accountCreated": "Account “{{name}}” created",
"accountArchived": "Account archived",
"balanceAdjusted": "Account balance adjusted",
"operationCreated": "Operation created",
"operationVoided": "Operation voided",
"categoryAssigned": "Category assigned",
"convertedToSavings": "Expense converted to savings",
"savingsConversionReverted": "Transfer reverted to expense",
"categoryCreated": "Category “{{name}}” created",
"categoryRenamed": "Category renamed to “{{name}}”",
"categoryArchived": "Category archived",
"categoryRestored": "Category restored",
"planUpdated": "Plan updated",
"categoryFunded": "Category funded",
"monthClosed": "Month closed",
@@ -383,6 +473,7 @@
"importUploaded": "Statement uploaded, parsing started",
"importConfirmed": "Import applied",
"importReverted": "Import reverted",
"holdResolved": "Hold resolution saved",
"reparseQueued": "Reparse queued",
"exportQueued": "Export queued",
"exportReady": "Export is ready to download",
@@ -394,7 +485,11 @@
"subtitle": "This page does not exist. Check the address or go back to accounts.",
"back": "To accounts"
},
"account": {
"savings": "Savings"
},
"categories": {
"uncategorized": "Uncategorized",
"groceries": "Groceries",
"transport": "Transport",
"housing": "Housing",
+103 -8
View File
@@ -88,7 +88,23 @@
"validation_error": "Проверьте правильность заполнения полей.",
"conflict": "Данные изменены кем-то другим. Обновите страницу и повторите.",
"rate_limited": "Слишком много попыток. Подождите немного.",
"session_expired": "Сессия истекла. Войдите снова."
"session_expired": "Сессия истекла. Войдите снова.",
"LEDGER_ADJUST_NOOP": "Остаток уже равен целевому значению — менять нечего.",
"LEDGER_ADJUST_TARGET_NEGATIVE": "Целевой остаток не может быть отрицательным.",
"LEDGER_ADJUST_REASON_REQUIRED": "Укажите причину корректировки.",
"LEDGER_ACCOUNT_ARCHIVED": "Счёт в архиве — сначала восстановите его.",
"LEDGER_SAVINGS_CONVERT_INVALID": "Преобразовать в накопления можно только подтверждённый расход в открытом месяце.",
"LEDGER_SAVINGS_REVERT_INVALID": "Вернуть в расход можно только преобразованный ранее перевод.",
"LEDGER_SAVINGS_CONFLICT": "Обнаружен конфликт системных ресурсов накоплений.",
"BUDGET_PERIOD_CLOSED": "Месяц закрыт. Сначала откройте его заново.",
"BUDGET_CATEGORY_SYSTEM": "Системную категорию нельзя переименовать или архивировать.",
"BUDGET_CATEGORY_ARCHIVED": "Категория в архиве — сначала восстановите её.",
"BUDGET_CATEGORY_NAME_REQUIRED": "Укажите название категории.",
"BUDGET_CATEGORY_KIND_INVALID": "Неизвестный тип категории.",
"IMPORT_HOLD_REASON_REQUIRED": "Укажите причину решения по замороженной операции.",
"IMPORT_HOLD_ALREADY_RESOLVED": "По этой операции решение уже сохранено.",
"IMPORT_HOLD_DOCUMENT_NOT_CONFIRMED": "Решать замороженные операции можно только после применения импорта.",
"IMPORT_HOLD_ACCOUNT_REQUIRED": "Не указан счёт привязки выписки."
},
"auth": {
"loginTitle": "Вход",
@@ -120,7 +136,7 @@
},
"accounts": {
"title": "Счета",
"subtitle": "Дебетовые, наличные и кредитные счета пространства.",
"subtitle": "Дебетовые, наличные, кредитные и системный счёт накоплений.",
"add": "Добавить счёт",
"empty": "Счетов пока нет. Создайте первый счёт, чтобы начать учёт.",
"formTitle": "Новый счёт",
@@ -128,6 +144,8 @@
"type.cash": "Наличные",
"type.debit": "Дебетовый",
"type.credit": "Кредитная карта",
"type.savings": "Накопительный",
"systemBadge": "Системный",
"bank": "Банк",
"bankHint": "Необязательно, например «alfa»",
"maskedNumber": "Маскированный номер",
@@ -143,7 +161,15 @@
"archive": "В архив",
"archiveBlockedHint": "Архивировать можно только счёт с нулевым остатком",
"archiveConfirmTitle": "Архивировать счёт",
"archiveConfirmBody": "Счёт «{{name}}» скроется из рабочих списков, но останется в истории."
"archiveConfirmBody": "Счёт «{{name}}» скроется из рабочих списков, но останется в истории.",
"adjust": "Скорректировать",
"adjustTitle": "Корректировка остатка",
"adjustCurrentBalance": "Текущий остаток",
"adjustTargetBalance": "Новый остаток",
"adjustDate": "Дата корректировки",
"adjustReason": "Причина",
"adjustReasonHint": "Обязательно; попадёт в журнал аудита",
"adjustNotice": "Корректировка меняет остаток счёта напрямую: это не доход и не расход. Она не попадёт в доходы, расходы, денежный поток и общий пул бюджета."
},
"operations": {
"title": "Гроссбух",
@@ -162,6 +188,7 @@
"kind.income": "Доход",
"kind.transfer": "Перевод",
"kind.refund": "Возврат",
"kind.adjustment": "Корректировка",
"status.confirmed": "Подтверждена",
"status.voided": "Отменена",
"noCategory": "Без категории",
@@ -183,6 +210,12 @@
"voidBody": "Операция от {{date}} на {{amount}} будет помечена отменённой.",
"voidReason": "Причина отмены",
"voidReasonHint": "Минимум 3 символа",
"convert": "В накопления",
"convertTitle": "Перевести в накопления",
"convertBody": "Расход от {{date}} на {{amount}} станет переводом в «Накопления»: он уйдёт из расходов месяца, увеличит счёт «Накопления» и уменьшит общий бюджетный пул. Позже это можно вернуть.",
"revert": "Вернуть в расходы",
"revertTitle": "Вернуть в расходы",
"revertBody": "Перевод от {{date}} на {{amount}} снова станет обычным расходом исходной категории; общий бюджетный пул пересчитается.",
"prevPage": "Назад",
"nextPage": "Дальше"
},
@@ -208,9 +241,27 @@
"categories": "Категории",
"reserves": "Резервы кредиток",
"noCategories": "В этом месяце ещё нет категорий. Скопируйте прошлый месяц или создайте план.",
"manageCategories": "Управлять категориями",
"savingsEnvelopeHint": "Конверт «Накопления» наполняется переводами и корректировками счёта; план не задаётся.",
"category.kind.monthly": "Месячная",
"category.kind.savings_envelope": "Конверт",
"category.kind.credit_reserve": "Резерв кредитки",
"manager": {
"title": "Управление категориями",
"tabsLabel": "Списки категорий",
"tabActive": "Активные",
"tabArchive": "Архив",
"systemBadge": "Системная",
"systemProtected": "переименование и архивирование недоступны",
"rename": "Переименовать",
"archive": "В архив",
"restore": "Восстановить",
"renameTitle": "Переименовать категорию",
"archiveTitle": "Архивировать категорию",
"archiveBody": "Категория «{{name}}» скроется из новых планов и разметки, но история останется.",
"emptyActive": "Пользовательских категорий пока нет — создайте первую в форме выше.",
"emptyArchive": "Архив категорий пуст."
},
"planTitle": "План: {{name}}",
"fundTitle": "Обеспечение: {{name}}",
"fund": "Обеспечить",
@@ -223,13 +274,25 @@
"income.status.partially_matched": "Частично",
"income.status.matched": "Поступил",
"blockersTitle": "Закрытие заблокировано",
"blockers.uncategorized_expenses": "Есть расходы без категории.",
"blockers.negative_envelope": "Отрицательный баланс накопительного конверта.",
"blockers.unresolved_imports": "Есть незавершённые импорты.",
"blockers.open_decisions": "Есть неразобранные решения по импорту.",
"blockers": {
"IMPORTS_UNRESOLVED": "Не завершены импорты выписок: {{count}}",
"IMPORT_UNRESOLVED_HOLDS": "Неподтверждённые операции без решения: {{count}}",
"BUDGET_UNCATEGORIZED_EXPENSES": "Расходы без категории: {{count}}",
"BUDGET_NEGATIVE_ENVELOPE": "Отрицательный остаток конверта: {{categories}}",
"unknown": "Закрытие заблокировано необработанной причиной.",
"cta": {
"imports": "Открыть очередь разбора",
"holds": "Разобрать операции",
"uncategorized": "Назначить категории",
"envelope": "Открыть категории"
}
},
"warnings.plan_imbalance": "План превышает прогнозный пул.",
"warnings.plan_exceeds_pool": "План превышает прогнозный пул.",
"warnings.underfunded_credit_debt": "Долг по кредитке обеспечен не полностью.",
"warnings.underfunded_credit": "Долг по кредитке обеспечен не полностью.",
"warnings.expected_income_variance": "Ожидаемый доход расходится с фактическим.",
"warnings.unknown": "В месяце есть другая проблема, требующая проверки.",
"close": "Закрыть месяц",
"closeConfirmTitle": "Закрыть месяц",
"closeConfirmBody": "Месяц {{month}} будет закрыт, остатки месячных категорий свернутся в общий пул.",
@@ -256,7 +319,13 @@
"uploading": "Загрузка…",
"adapterHint": "Поддерживаются: Альфа debit/credit XLSX, Ozon debit/credit PDF, Рокетбанк debit PDF.",
"file": "Файл выписки",
"fileName": "Файл выписки",
"targetAccount": "Счёт привязки",
"adapters.alfa_debit_xlsx": "Альфа debit",
"adapters.alfa_credit_xlsx": "Альфа credit",
"adapters.ozon_debit_pdf": "Ozon debit",
"adapters.ozon_credit_pdf": "Ozon credit",
"adapters.rocketbank_debit_pdf": "Рокетбанк debit",
"unassigned": "Не привязан",
"empty": "Импортов пока нет.",
"bank": "Банк / контракт",
@@ -289,6 +358,19 @@
"blockers.unresolved_duplicate": "Есть неразобранные дубликаты.",
"blockers.unresolved_transfer": "Есть неразобранные переводы.",
"blockers.account_conflict": "Отпечаток счёта конфликтует с существующим.",
"unresolvedHolds": "Замороженные",
"unresolvedHoldsBadge": "Замороженных операций: {{count}}",
"holdBadge": "Заморожена",
"holdPosted": "Операция списалась",
"holdPostedTitle": "Операция списалась",
"holdPostedBody": "Замороженная операция от {{date}} на {{amount}} будет проведена по счёту выписки как обычная операция.",
"holdCancelled": "Операция отменена",
"holdCancelledTitle": "Операция отменена",
"holdCancelledBody": "Замороженная операция от {{date}} на {{amount}} будет помечена отменённой и не попадёт в операции.",
"holdReason": "Причина решения",
"holdReasonHint": "Обязательно; попадёт в журнал аудита",
"holdResolution.confirmed_as_posted": "Проведена",
"holdResolution.discarded_as_cancelled": "Отменена",
"warnings": "Предупреждения ({{count}})",
"warnings.amount_warning": "Нестандартная сумма.",
"warnings.date_warning": "Необычная дата.",
@@ -303,7 +385,7 @@
"reconciliation.mismatched": "Расхождение",
"reconciliation.not_available": "Данных для сверки нет",
"reconciliation.confirmed_without": "Подтверждено без сверки",
"totals": "Строк: {{total}} · создать: {{create}} · пропустить: {{discard}} · дубликатов: {{duplicate}} · переводов: {{transfer}}",
"totals": "Строк: {{total}} · создать: {{create}} · пропустить: {{discard}} · дубликатов: {{duplicate}} · переводов: {{transfer}} · в накопления: {{savings}} · без решения: {{pending}}",
"rowsTitle": "Строки выписки",
"noRows": "Строк нет.",
"rowWarnings": "Замечания",
@@ -318,6 +400,7 @@
"decision.discard": "Пропустить",
"decision.duplicate": "Это дубликат",
"decision.transfer": "Это перевод",
"decision.savings_transfer": "Перевод в накопления",
"confirm": "Применить импорт",
"confirmTitle": "Применить импорт",
"confirmBody": "Будут созданы операции по выбранным строкам. Применение атомарно, его можно отменить.",
@@ -374,9 +457,16 @@
"toast": {
"accountCreated": "Счёт «{{name}}» создан",
"accountArchived": "Счёт отправлен в архив",
"balanceAdjusted": "Остаток счёта скорректирован",
"operationCreated": "Операция создана",
"operationVoided": "Операция отменена",
"categoryAssigned": "Категория назначена",
"convertedToSavings": "Расход переведён в накопления",
"savingsConversionReverted": "Перевод возвращён в расходы",
"categoryCreated": "Категория «{{name}}» создана",
"categoryRenamed": "Категория переименована в «{{name}}»",
"categoryArchived": "Категория отправлена в архив",
"categoryRestored": "Категория восстановлена",
"planUpdated": "План обновлён",
"categoryFunded": "Категория обеспечена",
"monthClosed": "Месяц закрыт",
@@ -385,6 +475,7 @@
"importUploaded": "Выписка загружена, разбор запущен",
"importConfirmed": "Импорт применён",
"importReverted": "Импорт отменён",
"holdResolved": "Решение по замороженной операции сохранено",
"reparseQueued": "Переразбор запущен",
"exportQueued": "Экспорт поставлен в очередь",
"exportReady": "Экспорт готов к скачиванию",
@@ -396,7 +487,11 @@
"subtitle": "Такой страницы нет. Проверьте адрес или вернитесь к счетам.",
"back": "К счетам"
},
"account": {
"savings": "Накопления"
},
"categories": {
"uncategorized": "Без категории",
"groceries": "Продукты",
"transport": "Транспорт",
"housing": "Жильё",
+9
View File
@@ -0,0 +1,9 @@
import type { TFunction } from 'i18next'
import type { Account } from '@/api/types'
export function accountLabel(account: Pick<Account, 'name' | 'nameKey'>, t: TFunction): string {
if (account.nameKey) {
return t(account.nameKey, { defaultValue: account.name })
}
return account.name
}
+19
View File
@@ -0,0 +1,19 @@
import { ApiError, isApiError } from '@/api/client'
import { i18n } from '@/i18n'
import type { TFunction } from 'i18next'
export function describeApiError(error: unknown, t: TFunction): string {
if (isApiError(error)) {
const key = `errors.${error.code}`
if (i18n.exists(key)) return t(key)
if (error.detail) return error.detail
return t('errors.http', { status: error.status })
}
return t('errors.generic')
}
export function apiErrorCode(error: unknown): string | null {
return isApiError(error) ? error.code : null
}
export { ApiError, isApiError }
+24
View File
@@ -0,0 +1,24 @@
import type { TFunction } from 'i18next'
interface NamedCategory {
name: string | null
nameKey: string | null
}
export function categoryLabel(category: NamedCategory, t: TFunction): string {
if (category.nameKey) {
return t(`categories.${category.nameKey}`, { defaultValue: category.name ?? category.nameKey })
}
return category.name ?? t('common.dash')
}
export function reportItemLabel(
name: string | null,
nameKey: string | null,
t: TFunction,
): string {
if (nameKey) {
return t(`categories.${nameKey}`, { defaultValue: name ?? nameKey })
}
return name ?? t('analytics.unallocated')
}
+101
View File
@@ -0,0 +1,101 @@
import type { Locale } from '@/api/types'
const INTL_LOCALES: Record<Locale, string> = {
ru: 'ru-RU',
en: 'en-US',
}
const MONTH_RE = /^(\d{4})-(\d{2})$/
function parseMonth(month: string): { year: number; monthIndex: number } | null {
const match = MONTH_RE.exec(month)
if (!match) return null
const year = Number(match[1])
const monthIndex = Number(match[2]) - 1
if (!Number.isInteger(year) || monthIndex < 0 || monthIndex > 11) return null
return { year, monthIndex }
}
export function todayISO(): string {
const now = new Date()
const year = String(now.getFullYear()).padStart(4, '0')
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function monthOf(dateISO: string): string {
return dateISO.slice(0, 7)
}
export function currentMonth(): string {
return monthOf(todayISO())
}
export function shiftMonth(month: string, delta: number): string {
const parsed = parseMonth(month)
if (!parsed) return month
const date = new Date(Date.UTC(parsed.year, parsed.monthIndex + delta, 1))
const year = date.getUTCFullYear()
const m = String(date.getUTCMonth() + 1).padStart(2, '0')
return `${year}-${m}`
}
export function isValidMonth(month: string): boolean {
return parseMonth(month) !== null
}
export function formatMonth(month: string, locale: Locale): string {
const parsed = parseMonth(month)
if (!parsed) return month
const label = new Intl.DateTimeFormat(INTL_LOCALES[locale], {
month: 'long',
year: 'numeric',
timeZone: 'UTC',
}).format(new Date(Date.UTC(parsed.year, parsed.monthIndex, 1)))
return locale === 'ru' ? label.replace(' г.', '') : label
}
export function formatMonthShort(month: string, locale: Locale): string {
const parsed = parseMonth(month)
if (!parsed) return month
return new Intl.DateTimeFormat(INTL_LOCALES[locale], {
month: 'short',
timeZone: 'UTC',
}).format(new Date(Date.UTC(parsed.year, parsed.monthIndex, 1)))
}
export function formatDate(dateISO: string, locale: Locale): string {
const parsed = /^(\d{4})-(\d{2})-(\d{2})/.exec(dateISO)
if (!parsed) return dateISO
const date = new Date(Date.UTC(Number(parsed[1]), Number(parsed[2]) - 1, Number(parsed[3])))
return new Intl.DateTimeFormat(INTL_LOCALES[locale], {
day: 'numeric',
month: 'short',
year: '2-digit',
timeZone: 'UTC',
}).format(date)
}
export function formatDateTime(iso: string, locale: Locale): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return new Intl.DateTimeFormat(INTL_LOCALES[locale], {
dateStyle: 'short',
timeStyle: 'short',
}).format(date)
}
export function lastNMonths(count: number, endMonth = currentMonth()): string[] {
const months: string[] = []
for (let i = count - 1; i >= 0; i -= 1) months.push(shiftMonth(endMonth, -i))
return months
}
export function monthBounds(month: string): { start: string; end: string } {
const parsed = parseMonth(month)
if (!parsed) return { start: month, end: month }
const lastDay = new Date(Date.UTC(parsed.year, parsed.monthIndex + 1, 0)).getUTCDate()
const end = `${parsed.year}-${String(parsed.monthIndex + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
return { start: `${month}-01`, end }
}
+21
View File
@@ -0,0 +1,21 @@
import type { ImportStatus } from '@/api/types'
export const IMPORT_POLL_MS = 2000
const ACTIVE_STATUSES = new Set<ImportStatus>(['uploaded', 'queued', 'processing'])
export function isTerminalImportStatus(status: ImportStatus): boolean {
return !ACTIVE_STATUSES.has(status)
}
export function importPollInterval(status: ImportStatus | undefined): number | false {
if (!status || isTerminalImportStatus(status)) return false
return IMPORT_POLL_MS
}
export function importListPollInterval(
statuses: ImportStatus[] | undefined,
): number | false {
if (!statuses) return false
return statuses.some((status) => !isTerminalImportStatus(status)) ? IMPORT_POLL_MS : false
}
+5
View File
@@ -0,0 +1,5 @@
import type { Locale } from '@/api/types'
export function currentLocale(language: string | undefined): Locale {
return language?.toLowerCase().startsWith('ru') ? 'ru' : 'en'
}
+46
View File
@@ -0,0 +1,46 @@
import type { Locale } from '@/api/types'
const INTL_LOCALES: Record<Locale, string> = {
ru: 'ru-RU',
en: 'en-US',
}
export function formatMoney(kopecks: number, locale: Locale): string {
return new Intl.NumberFormat(INTL_LOCALES[locale], {
style: 'currency',
currency: 'RUB',
currencyDisplay: 'narrowSymbol',
maximumFractionDigits: 2,
}).format(kopecks / 100)
}
export function formatSignedMoney(kopecks: number, locale: Locale, sign: -1 | 1): string {
const formatted = formatMoney(Math.abs(kopecks), locale)
return sign === -1 ? `−${formatted}` : `+${formatted}`
}
export function kopecksToInput(kopecks: number): string {
const sign = kopecks < 0 ? '-' : ''
const abs = Math.abs(kopecks)
const rubles = Math.trunc(abs / 100)
const frac = String(abs % 100).padStart(2, '0')
return frac === '00' ? `${sign}${rubles}` : `${sign}${rubles}.${frac}`
}
export function parseAmountToKopecks(raw: string): number | null {
const cleaned = raw.replace(/[\s\u00A0\u202F']/g, '').replace(',', '.')
const match = /^([+-]?)(\d+)(?:\.(\d{1,2}))?$/.exec(cleaned)
if (!match) return null
const sign = match[1] ?? ''
const rubles = match[2] ?? '0'
const frac = match[3] ?? ''
const kopecks = Number(rubles) * 100 + (frac === '' ? 0 : Number(frac.padEnd(2, '0')))
if (!Number.isSafeInteger(kopecks)) return null
return sign === '-' ? -kopecks : kopecks
}
export function sumKopecks(values: Iterable<number>): number {
let total = 0
for (const value of values) total += value
return total
}
+41
View File
@@ -0,0 +1,41 @@
import type { Locale, Operation } from '@/api/types'
import { formatMoney } from '@/lib/money'
export interface OperationAmountView {
text: string
tone: 'neg' | 'pos' | 'transfer'
}
export function operationAmount(operation: Operation, locale: Locale): OperationAmountView {
const amount = formatMoney(operation.amountKopecks, locale)
switch (operation.kind) {
case 'expense':
return { text: `−${amount}`, tone: 'neg' }
case 'income':
case 'refund':
return { text: `+${amount}`, tone: 'pos' }
case 'transfer':
case 'adjustment':
// Transfers move money between own accounts and an adjustment carries the
// sign in its single posting, so both render without an income/expense sign.
return { text: amount, tone: 'transfer' }
}
}
export const OPERATION_KIND_TONES = {
expense: 'danger',
income: 'accent',
refund: 'info',
transfer: 'neutral',
adjustment: 'neutral',
} as const
export const IMPORT_STATUS_TONES = {
uploaded: 'info',
queued: 'info',
processing: 'info',
review_required: 'warn',
ready: 'accent',
confirmed: 'accent',
failed: 'danger',
} as const
+112 -8
View File
@@ -3,7 +3,8 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import type { Account, AccountType } from '@/api/types'
import type { Account } from '@/api/types'
import { isSystemAccount } from '@/api/types'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
@@ -12,14 +13,20 @@ import { MoneyField, SelectField, TextField } from '@/components/ui/fields'
import { Modal } from '@/components/ui/Modal'
import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate'
import { useAccounts, useArchiveAccount, useCreateAccount } from '@/hooks/useAccounts'
import { formatDate } from '@/lib/dates'
import { formatMoney, parseAmountToKopecks, sumKopecks } from '@/lib/money'
import {
useAccounts,
useAdjustBalance,
useArchiveAccount,
useCreateAccount,
} from '@/hooks/useAccounts'
import { accountLabel } from '@/lib/accountLabel'
import { formatDate, todayISO } from '@/lib/dates'
import { formatMoney, kopecksToInput, parseAmountToKopecks, sumKopecks } from '@/lib/money'
import { currentLocale } from '@/lib/locale'
interface AccountFormValues {
name: string
type: AccountType
type: 'cash' | 'debit' | 'credit'
openedOn: string
openingBalance: string
bankCode: string
@@ -113,6 +120,84 @@ function useMemoSchema() {
})
}
function AdjustBalanceModal({
account,
onClose,
}: {
account: Account
onClose: () => void
}) {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const adjust = useAdjustBalance()
const schema = z.object({
targetBalance: z
.string()
.refine((value) => (parseAmountToKopecks(value) ?? -1) >= 0, t('errors.badAmount')),
occurredOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, t('common.requiredField')),
reason: z.string().trim().min(1, t('common.requiredField')),
})
type Values = z.infer<typeof schema>
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: {
targetBalance: kopecksToInput(account.balanceKopecks),
occurredOn: todayISO(),
reason: '',
},
})
const submit = handleSubmit((values) => {
adjust.mutate(
{
id: account.id,
payload: {
targetBalanceKopecks: parseAmountToKopecks(values.targetBalance) ?? 0,
occurredOn: values.occurredOn,
reason: values.reason,
},
},
{ onSuccess: onClose },
)
})
return (
<Modal open onClose={onClose} title={t('accounts.adjustTitle')}>
<form onSubmit={submit} noValidate className="form-grid">
<p className="adjust-notice">{t('accounts.adjustNotice')}</p>
<div className="adjust-current">
<span>{t('accounts.adjustCurrentBalance')}</span>
<strong className="num">{formatMoney(account.balanceKopecks, locale)}</strong>
</div>
<MoneyField
label={t('accounts.adjustTargetBalance')}
error={errors.targetBalance?.message}
{...register('targetBalance')}
/>
<TextField
label={t('accounts.adjustDate')}
type="date"
error={errors.occurredOn?.message}
{...register('occurredOn')}
/>
<TextField
label={t('accounts.adjustReason')}
hint={t('accounts.adjustReasonHint')}
error={errors.reason?.message}
{...register('reason')}
/>
<Button type="submit" disabled={adjust.isPending}>
{adjust.isPending ? t('common.saving') : t('accounts.adjust')}
</Button>
</form>
</Modal>
)
}
export function AccountsPage() {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
@@ -126,6 +211,7 @@ export function AccountsPage() {
const liabilities = sumKopecks(
active.filter((a) => a.type === 'credit').map((a) => a.balanceKopecks),
)
const [adjustTarget, setAdjustTarget] = useState<Account | null>(null)
return (
<>
@@ -174,10 +260,13 @@ export function AccountsPage() {
</tr>
</thead>
<tbody>
{list.map((account) => (
{list.map((account) => {
const system = isSystemAccount(account)
return (
<tr key={account.id}>
<th scope="row" data-label={t('common.name')}>
{account.name}
{accountLabel(account, t)}
{system ? <Badge tone="info">{t('accounts.systemBadge')}</Badge> : null}
{account.maskedNumber ? (
<span className="muted"> · {account.maskedNumber}</span>
) : null}
@@ -203,7 +292,15 @@ export function AccountsPage() {
{formatMoney(account.balanceKopecks, locale)}
</td>
<td data-label={t('common.actions')}>
<span className="inline-actions">
{account.status === 'active' ? (
<>
{account.type !== 'credit' ? (
<Button variant="subtle" onClick={() => setAdjustTarget(account)}>
{t('accounts.adjust')}
</Button>
) : null}
{!system ? (
<Button
variant="subtle"
disabled={account.balanceKopecks !== 0}
@@ -217,9 +314,13 @@ export function AccountsPage() {
{t('accounts.archive')}
</Button>
) : null}
</>
) : null}
</span>
</td>
</tr>
))}
)
})}
</tbody>
</table>
)}
@@ -228,6 +329,9 @@ export function AccountsPage() {
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title={t('accounts.formTitle')}>
<AccountForm onDone={() => setCreateOpen(false)} />
</Modal>
{adjustTarget ? (
<AdjustBalanceModal account={adjustTarget} onClose={() => setAdjustTarget(null)} />
) : null}
<ConfirmDialog
open={archiveTarget !== null}
danger
+45 -13
View File
@@ -7,6 +7,8 @@ import type { BudgetCategory } from '@/api/types'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { BudgetBlockers } from '@/components/budget/BudgetBlockers'
import { CategoryManagerModal } from '@/components/budget/CategoryManagerModal'
import { MoneyField } from '@/components/ui/fields'
import { Modal } from '@/components/ui/Modal'
import { MonthSwitcher } from '@/components/ui/MonthSwitcher'
@@ -26,6 +28,8 @@ import { formatDate, currentMonth, formatMonth } from '@/lib/dates'
import { currentLocale } from '@/lib/locale'
import { formatMoney, kopecksToInput, parseAmountToKopecks } from '@/lib/money'
const BLOCKER_WARNING_CODES = new Set(['uncategorized_expenses', 'negative_envelopes'])
function PlanModal({
category,
month,
@@ -134,6 +138,7 @@ export function BudgetPage() {
const [fundTarget, setFundTarget] = useState<BudgetCategory | null>(null)
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false)
const [reopenConfirmOpen, setReopenConfirmOpen] = useState(false)
const [managerOpen, setManagerOpen] = useState(false)
const status = summary.data?.status
const allCategories = categories.data ?? []
@@ -141,25 +146,49 @@ export function BudgetPage() {
(category) => category.kind !== 'credit_reserve' && !category.archived,
)
const reserves = allCategories.filter((category) => category.kind === 'credit_reserve')
const supplementalWarnings =
summary.data?.warnings.filter((warning) => !BLOCKER_WARNING_CODES.has(warning.code)) ?? []
const renderCategoryRows = (rows: BudgetCategory[]) =>
rows.map((category) => (
rows.map((category) => {
const isSystemCategory = category.system
const systemHint =
category.nameKey === 'savings'
? t('budget.savingsEnvelopeHint')
: t('budget.manager.systemNote')
return (
<tr key={category.id}>
<th scope="row" data-label={t('common.name')}>
{categoryLabel(category, t)}
{isSystemCategory ? (
<Badge tone="info">{t('budget.manager.systemBadge')}</Badge>
) : (
<Badge tone={category.kind === 'savings_envelope' ? 'info' : 'neutral'}>
{t(`budget.category.kind.${category.kind}`)}
</Badge>
)}
</th>
<td data-label={t('budget.planned')}>
{isSystemCategory ? (
<span className="num" title={systemHint}>
{formatMoney(category.plannedKopecks, locale)}
</span>
) : (
<Button variant="subtle" onClick={() => setPlanTarget(category)}>
{formatMoney(category.plannedKopecks, locale)}
</Button>
)}
</td>
<td data-label={t('budget.funded')}>
{isSystemCategory ? (
<span className="num" title={systemHint}>
{formatMoney(category.fundedKopecks, locale)}
</span>
) : (
<Button variant="subtle" onClick={() => setFundTarget(category)}>
{formatMoney(category.fundedKopecks, locale)}
</Button>
)}
</td>
<td data-label={t('budget.spent')} className="num">
{formatMoney(category.spentKopecks, locale)}
@@ -171,7 +200,8 @@ export function BudgetPage() {
{formatMoney(category.availableKopecks, locale)}
</td>
</tr>
))
)
})
return (
<>
@@ -217,21 +247,17 @@ export function BudgetPage() {
{summary.data && summary.data.blockers.length > 0 ? (
<div className="banner banner-danger" role="alert">
<strong>{t('budget.blockersTitle')}</strong>
<ul>
{summary.data.blockers.map((blocker) => (
<li key={blocker.code}>
{t(`budget.blockers.${blocker.code}`, { defaultValue: blocker.message ?? blocker.code })}
</li>
))}
</ul>
<BudgetBlockers blockers={summary.data.blockers} month={month} />
</div>
) : null}
{summary.data && summary.data.warnings.length > 0 ? (
{supplementalWarnings.length > 0 ? (
<div className="banner banner-warn">
<ul>
{summary.data.warnings.map((warning) => (
{supplementalWarnings.map((warning) => (
<li key={warning.code}>
{t(`budget.warnings.${warning.code}`, { defaultValue: warning.message ?? warning.code })}
{t(`budget.warnings.${warning.code}`, {
defaultValue: t('budget.warnings.unknown'),
})}
</li>
))}
</ul>
@@ -308,8 +334,13 @@ export function BudgetPage() {
</table>
</section>
) : null}
<section aria-label={t('budget.categories')}>
<section aria-label={t('budget.categories')} id="categories">
<div className="section-heading categories-heading">
<h2 className="section-title">{t('budget.categories')}</h2>
<Button variant="subtle" onClick={() => setManagerOpen(true)}>
{t('budget.manageCategories')}
</Button>
</div>
{userCategories.length === 0 ? (
<p className="muted">{t('budget.noCategories')}</p>
) : (
@@ -355,6 +386,7 @@ export function BudgetPage() {
{fundTarget ? (
<FundModal category={fundTarget} month={month} onClose={() => setFundTarget(null)} />
) : null}
{managerOpen ? <CategoryManagerModal onClose={() => setManagerOpen(false)} /> : null}
<ConfirmDialog
open={closeConfirmOpen}
danger
+139 -3
View File
@@ -1,11 +1,14 @@
import { useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { Link, useParams } from 'react-router-dom'
import type { RowDecision } from '@/api/types'
import { z } from 'zod'
import type { HoldResolutionAction, ImportRow, RowDecision } from '@/api/types'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { TextField } from '@/components/ui/fields'
import { TextareaField, TextField } from '@/components/ui/fields'
import { Modal } from '@/components/ui/Modal'
import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate'
@@ -13,6 +16,7 @@ import {
useConfirmImport,
useImport,
useReparseImport,
useResolveHold,
useRevertImport,
useSaveRowDecision,
} from '@/hooks/useImports'
@@ -26,6 +30,7 @@ const DECISIONS: readonly Exclude<RowDecision, 'pending'>[] = [
'discard',
'duplicate',
'transfer',
'savings_transfer',
]
const STEP_ORDER = ['uploaded', 'queued', 'processing', 'review_required', 'confirmed'] as const
@@ -37,6 +42,76 @@ function stepIndexFor(status: string): number {
return index === -1 ? 0 : index
}
function HoldResolveModal({
importId,
row,
action,
onClose,
}: {
importId: string
row: ImportRow
action: HoldResolutionAction
onClose: () => void
}) {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const resolve = useResolveHold(importId)
const schema = z.object({
reason: z.string().trim().min(1, t('common.requiredField')),
})
type Values = z.infer<typeof schema>
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: { reason: '' },
})
const submit = handleSubmit((values) => {
resolve.mutate(
{ rowId: row.id, payload: { action, reason: values.reason } },
{ onSuccess: onClose },
)
})
return (
<Modal
open
onClose={onClose}
title={
action === 'confirm_as_posted'
? t('imports.holdPostedTitle')
: t('imports.holdCancelledTitle')
}
>
<form onSubmit={submit} noValidate className="form-grid">
<p className="muted">
{action === 'confirm_as_posted'
? t('imports.holdPostedBody', {
date: formatDate(row.occurredOn, locale),
amount: formatMoney(row.amountKopecks, locale),
})
: t('imports.holdCancelledBody', {
date: formatDate(row.occurredOn, locale),
amount: formatMoney(row.amountKopecks, locale),
})}
</p>
<TextareaField
label={t('imports.holdReason')}
hint={t('imports.holdReasonHint')}
error={errors.reason?.message}
{...register('reason')}
/>
<Button type="submit" variant="primary" disabled={resolve.isPending}>
{resolve.isPending ? t('common.saving') : t('common.confirm')}
</Button>
</form>
</Modal>
)
}
function ConfirmModal({
importId,
needsConfirmation,
@@ -101,6 +176,7 @@ export function ImportDetailPage() {
const [confirmOpen, setConfirmOpen] = useState(false)
const [revertOpen, setRevertOpen] = useState(false)
const [holdTarget, setHoldTarget] = useState<{ row: ImportRow; action: HoldResolutionAction } | null>(null)
const status = detail.data?.status
const needsConfirmation =
@@ -134,6 +210,9 @@ export function ImportDetailPage() {
}
/>
{status ? <Badge tone={IMPORT_STATUS_TONES[status]}>{t(`imports.status.${status}`)}</Badge> : null}
{detail.data && detail.data.unresolvedHoldCount > 0 ? (
<Badge tone="warn">{t('imports.unresolvedHoldsBadge', { count: detail.data.unresolvedHoldCount })}</Badge>
) : null}
<QueryGate query={detail}>
{(data) => (
<>
@@ -225,6 +304,8 @@ export function ImportDetailPage() {
discard: data.totals.discard,
duplicate: data.totals.duplicate,
transfer: data.totals.transfer,
savings: data.totals.savingsTransfer,
pending: data.totals.pending,
})}
</p>
</section>
@@ -262,7 +343,9 @@ export function ImportDetailPage() {
{formatMoney(row.amountKopecks, locale)}
</td>
<td data-label={t('imports.rowWarnings')} className="statement-warnings">
{row.warnings.length === 0 ? (
{row.bankStatus === 'hold' ? (
<span className="chip">{t('imports.holdBadge')}</span>
) : row.warnings.length === 0 ? (
'—'
) : (
<ul className="chip-list">
@@ -275,6 +358,50 @@ export function ImportDetailPage() {
)}
</td>
<td data-label={t('imports.decision')} className="statement-decision">
{row.bankStatus === 'hold' ? (
row.holdResolution ? (
<div className="hold-history">
<Badge
tone={
row.holdResolution.resolution === 'confirmed_as_posted'
? 'accent'
: 'neutral'
}
>
{t(
`imports.holdResolution.${row.holdResolution.resolution}`,
)}
</Badge>
{row.holdResolution.reason ? (
<span className="muted">{row.holdResolution.reason}</span>
) : null}
{row.holdResolution.resolvedAt ? (
<span className="muted">
{formatDateTime(row.holdResolution.resolvedAt, locale)}
</span>
) : null}
</div>
) : (
<div className="inline-actions">
<Button
variant="subtle"
onClick={() =>
setHoldTarget({ row, action: 'confirm_as_posted' })
}
>
{t('imports.holdPosted')}
</Button>
<Button
variant="subtle"
onClick={() =>
setHoldTarget({ row, action: 'discard_as_cancelled' })
}
>
{t('imports.holdCancelled')}
</Button>
</div>
)
) : (
<select
className="input select"
aria-label={t('imports.decision')}
@@ -295,6 +422,7 @@ export function ImportDetailPage() {
</option>
))}
</select>
)}
</td>
</tr>
))}
@@ -302,6 +430,14 @@ export function ImportDetailPage() {
</table>
)}
</section>
{holdTarget ? (
<HoldResolveModal
importId={data.id}
row={holdTarget.row}
action={holdTarget.action}
onClose={() => setHoldTarget(null)}
/>
) : null}
{confirmOpen ? (
<ConfirmModal
importId={data.id}
+4 -3
View File
@@ -8,15 +8,16 @@ import { SelectField } from '@/components/ui/fields'
import { Modal } from '@/components/ui/Modal'
import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate'
import { useActiveAccounts } from '@/hooks/useAccounts'
import { useUserAccounts } from '@/hooks/useAccounts'
import { useImports, useUploadImport } from '@/hooks/useImports'
import { accountLabel } from '@/lib/accountLabel'
import { formatDateTime } from '@/lib/dates'
import { currentLocale } from '@/lib/locale'
import { IMPORT_STATUS_TONES } from '@/lib/operationFormat'
function UploadModal({ onClose }: { onClose: () => void }) {
const { t } = useTranslation()
const accounts = useActiveAccounts()
const accounts = useUserAccounts()
const upload = useUploadImport()
const [accountId, setAccountId] = useState('')
const [file, setFile] = useState<File | null>(null)
@@ -62,7 +63,7 @@ function UploadModal({ onClose }: { onClose: () => void }) {
<option value="">{t('common.choose')}</option>
{(accounts.data ?? []).map((account) => (
<option key={account.id} value={account.id}>
{account.name}
{accountLabel(account, t)}
</option>
))}
</SelectField>
+127 -9
View File
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
import type { TFunction } from 'i18next'
import { z } from 'zod'
import type { Account, Operation, OperationFilters, OperationKind, OperationStatus } from '@/api/types'
import { isSystemAccount } from '@/api/types'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/ui/EmptyState'
@@ -13,12 +14,15 @@ import { Modal } from '@/components/ui/Modal'
import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate'
import { useBudgetCategories } from '@/hooks/useBudget'
import { useActiveAccounts } from '@/hooks/useAccounts'
import { useActiveAccounts, useUserAccounts } from '@/hooks/useAccounts'
import {
useConvertToSavings,
useCreateOperation,
useOperations,
useRevertSavingsConversion,
useVoidOperation,
} from '@/hooks/useOperations'
import { accountLabel } from '@/lib/accountLabel'
import { categoryLabel } from '@/lib/categoryLabel'
import { currentMonth, formatDate, todayISO } from '@/lib/dates'
import { currentLocale } from '@/lib/locale'
@@ -50,7 +54,7 @@ function AccountSelect({
<option value="">{t('common.choose')}</option>
{(accounts ?? []).map((account) => (
<option key={account.id} value={account.id}>
{account.name}
{accountLabel(account, t)}
</option>
))}
</SelectField>
@@ -60,7 +64,7 @@ function AccountSelect({
function ExpenseForm({ onDone }: { onDone: () => void }) {
const { t } = useTranslation()
const create = useCreateOperation()
const accounts = useActiveAccounts()
const accounts = useUserAccounts()
const categories = useBudgetCategories(currentMonth())
const schema = z.object({
accountId: z.string().min(1, t('common.requiredField')),
@@ -143,7 +147,7 @@ function ExpenseForm({ onDone }: { onDone: () => void }) {
function IncomeForm({ onDone }: { onDone: () => void }) {
const { t } = useTranslation()
const create = useCreateOperation()
const accounts = useActiveAccounts()
const accounts = useUserAccounts()
const schema = z.object({
accountId: z.string().min(1, t('common.requiredField')),
amount: amountField(t),
@@ -297,7 +301,7 @@ function RefundForm({ onDone }: { onDone: () => void }) {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const create = useCreateOperation()
const accounts = useActiveAccounts()
const accounts = useUserAccounts()
const recent = useOperations({ kind: 'expense', limit: 20 })
const schema = z.object({
accountId: z.string().min(1, t('common.requiredField')),
@@ -447,6 +451,76 @@ function VoidModal({ operation, onClose }: { operation: Operation; onClose: () =
const PAGE_SIZE = 50
function SavingsConvertModal({
operation,
onClose,
}: {
operation: Operation
onClose: () => void
}) {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const convert = useConvertToSavings()
return (
<Modal open onClose={onClose} title={t('operations.convertTitle')}>
<div className="form-grid">
<p className="muted">
{t('operations.convertBody', {
date: formatDate(operation.occurredOn, locale),
amount: formatMoney(operation.amountKopecks, locale),
})}
</p>
<div className="modal-actions">
<Button variant="subtle" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
disabled={convert.isPending}
onClick={() => convert.mutate({ id: operation.id }, { onSuccess: onClose })}
>
{t('operations.convert')}
</Button>
</div>
</div>
</Modal>
)
}
function SavingsRevertModal({
operation,
onClose,
}: {
operation: Operation
onClose: () => void
}) {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const revert = useRevertSavingsConversion()
return (
<Modal open onClose={onClose} title={t('operations.revertTitle')}>
<div className="form-grid">
<p className="muted">
{t('operations.revertBody', {
date: formatDate(operation.occurredOn, locale),
amount: formatMoney(operation.amountKopecks, locale),
})}
</p>
<div className="modal-actions">
<Button variant="subtle" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
disabled={revert.isPending}
onClick={() => revert.mutate({ id: operation.id }, { onSuccess: onClose })}
>
{t('operations.revert')}
</Button>
</div>
</div>
</Modal>
)
}
export function OperationsPage() {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
@@ -481,9 +555,13 @@ export function OperationsPage() {
const operations = useOperations(filters)
const [createOpen, setCreateOpen] = useState(false)
const [voidTarget, setVoidTarget] = useState<Operation | null>(null)
const [convertTarget, setConvertTarget] = useState<Operation | null>(null)
const [revertTarget, setRevertTarget] = useState<Operation | null>(null)
const accountName = (id: string | null) =>
accounts.data?.find((account) => account.id === id)?.name ?? '—'
const accountName = (id: string | null) => {
const account = accounts.data?.find((item) => item.id === id)
return account ? accountLabel(account, t) : '—'
}
const goNext = () => {
const next = operations.data?.nextCursor
@@ -536,7 +614,7 @@ export function OperationsPage() {
<option value="">{t('operations.filterAll')}</option>
{(accounts.data ?? []).map((account) => (
<option key={account.id} value={account.id}>
{account.name}
{accountLabel(account, t)}
</option>
))}
</SelectField>
@@ -618,6 +696,22 @@ export function OperationsPage() {
<tbody>
{page.items.map((operation) => {
const amount = operationAmount(operation, locale)
const isConvertedTransfer =
operation.kind === 'transfer' && operation.convertedFromExpense
const sourceAccount =
accounts.data?.find((item) => item.id === operation.accountId) ?? null
const canConvert =
operation.kind === 'expense' &&
operation.status === 'confirmed' &&
!(sourceAccount && isSystemAccount(sourceAccount))
const categoryText =
operation.kind === 'adjustment'
? '—'
: operation.categoryKey
? t(`categories.${operation.categoryKey}`, {
defaultValue: operation.categoryName ?? operation.categoryKey,
})
: operation.categoryName ?? t('operations.noCategory')
return (
<tr
key={operation.id}
@@ -644,16 +738,34 @@ export function OperationsPage() {
: ''}
</td>
<td data-label={t('operations.category')} className="operation-category">
{operation.categoryName ?? t('operations.noCategory')}
{categoryText}
</td>
<td data-label={t('common.amount')} className={`operation-amount num ${amount.tone}`}>
{amount.text}
</td>
<td data-label={t('common.actions')} className="operation-actions">
{operation.status === 'confirmed' ? (
<span className="inline-actions">
{canConvert ? (
<Button
variant="subtle"
onClick={() => setConvertTarget(operation)}
>
{t('operations.convert')}
</Button>
) : null}
{isConvertedTransfer ? (
<Button
variant="subtle"
onClick={() => setRevertTarget(operation)}
>
{t('operations.revert')}
</Button>
) : null}
<Button variant="subtle" onClick={() => setVoidTarget(operation)}>
{t('operations.void')}
</Button>
</span>
) : null}
</td>
</tr>
@@ -676,6 +788,12 @@ export function OperationsPage() {
{voidTarget ? (
<VoidModal operation={voidTarget} onClose={() => setVoidTarget(null)} />
) : null}
{convertTarget ? (
<SavingsConvertModal operation={convertTarget} onClose={() => setConvertTarget(null)} />
) : null}
{revertTarget ? (
<SavingsRevertModal operation={revertTarget} onClose={() => setRevertTarget(null)} />
) : null}
</>
)
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { BudgetBlockers } from '@/components/budget/BudgetBlockers'
import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate'
import { useBudgetCategories, useBudgetSummary } from '@/hooks/useBudget'
@@ -61,7 +62,7 @@ export function OverviewPage() {
<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 ? (
<ul>{data.blockers.map((blocker) => <li key={blocker.code}>{t(`budget.blockers.${blocker.code}`, { defaultValue: blocker.message ?? blocker.code })}</li>)}</ul>
<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>
+47 -12
View File
@@ -1,20 +1,25 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import type { BudgetCategory, Operation } from '@/api/types'
import { Link, useSearchParams } from 'react-router-dom'
import type { BudgetCategory, ImportSummary, Operation } from '@/api/types'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/ui/EmptyState'
import { MonthSwitcher } from '@/components/ui/MonthSwitcher'
import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate'
import { useBudgetCategories } from '@/hooks/useBudget'
import { useCategorizeOperation, useOperations } from '@/hooks/useOperations'
import { useImports } from '@/hooks/useImports'
import { categoryLabel } from '@/lib/categoryLabel'
import { currentMonth, formatDateTime } from '@/lib/dates'
import { currentMonth, formatDateTime, isValidMonth, monthBounds } from '@/lib/dates'
import { currentLocale } from '@/lib/locale'
import { IMPORT_STATUS_TONES } from '@/lib/operationFormat'
function needsAttention(item: ImportSummary): boolean {
return item.status === 'review_required' || item.status === 'failed' || item.unresolvedHoldCount > 0
}
function UncategorizedRow({
operation,
categories,
@@ -68,23 +73,41 @@ function UncategorizedRow({
export function ReviewQueuePage() {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const imports = useImports()
const uncategorized = useOperations({ uncategorized: true, status: 'confirmed', limit: 50 })
const categories = useBudgetCategories(currentMonth())
const [searchParams, setSearchParams] = useSearchParams()
const problematicImports = (imports.data ?? []).filter(
(item) => item.status === 'review_required' || item.status === 'failed',
)
const monthParam = searchParams.get('month')
const month = monthParam && isValidMonth(monthParam) ? monthParam : currentMonth()
const bounds = monthBounds(month)
const setMonth = (next: string) => {
setSearchParams(next === currentMonth() ? {} : { month: next })
}
const imports = useImports(month)
const uncategorized = useOperations({
uncategorized: true,
status: 'confirmed',
limit: 50,
occurredFrom: bounds.start,
occurredTo: bounds.end,
})
const categories = useBudgetCategories(month)
const problematicImports = (imports.data ?? []).filter(needsAttention)
const hasWork = problematicImports.length > 0 || (uncategorized.data?.items.length ?? 0) > 0
return (
<>
<PageHeader title={t('review.title')} subtitle={t('review.subtitle')} />
<PageHeader
title={t('review.title')}
subtitle={t('review.subtitle')}
actions={<MonthSwitcher month={month} onChange={setMonth} />}
/>
{imports.isPending || uncategorized.isPending ? null : !hasWork ? (
<EmptyState title={t('review.empty')} />
) : null}
{problematicImports.length > 0 ? (
<section aria-label={t('review.sectionImports')}>
<section id="holds" aria-label={t('review.sectionImports')}>
<h2 className="section-title">{t('review.sectionImports')}</h2>
<table className="data-table">
<caption className="visually-hidden">{t('review.sectionImports')}</caption>
@@ -93,6 +116,7 @@ export function ReviewQueuePage() {
<th scope="col">{t('imports.fileName')}</th>
<th scope="col">{t('imports.bank')}</th>
<th scope="col">{t('common.status')}</th>
<th scope="col">{t('imports.unresolvedHolds')}</th>
<th scope="col">{t('imports.createdAt')}</th>
<th scope="col">{t('common.actions')}</th>
</tr>
@@ -103,12 +127,23 @@ export function ReviewQueuePage() {
<th scope="row" data-label={t('imports.fileName')}>
{item.fileName}
</th>
<td data-label={t('imports.bank')}>{item.bankAdapter}</td>
<td data-label={t('imports.bank')}>
{t(`imports.adapters.${item.bankAdapter}`, {
defaultValue: item.bankAdapter,
})}
</td>
<td data-label={t('common.status')}>
<Badge tone={IMPORT_STATUS_TONES[item.status]}>
{t(`imports.status.${item.status}`)}
</Badge>
</td>
<td data-label={t('imports.unresolvedHolds')} className="num">
{item.unresolvedHoldCount > 0 ? (
<Badge tone="warn">{item.unresolvedHoldCount}</Badge>
) : (
'0'
)}
</td>
<td data-label={t('imports.createdAt')}>
{formatDateTime(item.createdAt, locale)}
</td>
+37 -1
View File
@@ -545,6 +545,16 @@ input[type='file']::file-selector-button { min-height: 30px; margin-right: 10px;
.banner-success { color: var(--green); background: var(--green-soft); border-color: #82c7a1; }
.banner summary { cursor: pointer; font-weight: 600; }
.blocker-list { display: grid; gap: 8px; margin: 8px 0 0; padding: 0; list-style: none; }
.blocker-item { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.blocker-link { color: var(--blue); font-weight: 600; white-space: nowrap; }
.adjust-notice { margin: 0; padding: 10px 12px; color: var(--amber); background: var(--amber-soft); border: 1px solid #e5c16a; font-size: 12px; }
.adjust-current { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; color: #9d9d98; font-family: var(--font-data); font-size: 10px; text-transform: uppercase; }
.adjust-current strong { color: var(--white); font-size: 20px; text-transform: none; }
.hold-history { display: grid; gap: 4px; justify-items: start; font-size: 12px; }
.modal {
width: min(430px, 100vw);
max-width: none;
@@ -580,6 +590,22 @@ input[type='file']::file-selector-button { min-height: 30px; margin-right: 10px;
.month-switch { display: inline-flex; align-items: center; }
.month-label { min-width: 145px; text-align: center; font-family: var(--font-data); font-size: 11px; font-weight: 600; }
.categories-heading { margin: 34px 0 14px; }
.categories-heading .section-title { margin: 0; }
.manager-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(130px, auto) auto; gap: 10px; align-items: end; margin-bottom: 18px; }
.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-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; }
.steps {
display: grid;
grid-template-columns: repeat(5, 1fr);
@@ -665,6 +691,7 @@ input[type='file']::file-selector-button { min-height: 30px; margin-right: 10px;
.decision-panel li { padding: 10px 0; color: #d5d5d1; border-bottom: 1px solid #323230; font-size: 12px; }
.decision-panel p { color: #b9b9b5; font-size: 12px; }
.decision-panel .btn { width: 100%; color: #a8a8a3; background: #2e2e2c; border: 0; }
.decision-panel .blocker-link { color: #a9c4ff; }
.category-pulse { padding-top: 4px; }
.pulse-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 1px solid var(--line); border-left: 1px solid var(--line); }
.pulse-item { display: grid; grid-template-columns: 1fr auto; gap: 5px 12px; min-width: 0; padding: 16px; background: var(--white); border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
@@ -727,7 +754,7 @@ input[type='file']::file-selector-button { min-height: 30px; margin-right: 10px;
inset: auto 0 0;
z-index: 50;
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-template-columns: repeat(6, 1fr);
min-height: 62px;
background: var(--white);
border-top: 1px solid var(--line);
@@ -743,6 +770,11 @@ input[type='file']::file-selector-button { min-height: 30px; margin-right: 10px;
.page-actions { justify-content: flex-end; }
.page-actions-row { justify-content: flex-end; }
.page-actions .btn { min-height: 34px; padding-inline: 10px; font-size: 11px; }
.view-budget .page-header,
.view-review .page-header { flex-direction: column; gap: 14px; }
.view-budget .page-actions,
.view-budget .page-actions-row,
.view-review .page-actions { width: 100%; justify-content: flex-start; }
.stats { grid-template-columns: repeat(3, minmax(0, 1fr)); margin-inline: -14px; }
.stat { padding: 14px 10px; }
.stat:nth-child(n + 4) { display: none; }
@@ -765,6 +797,10 @@ input[type='file']::file-selector-button { min-height: 30px; margin-right: 10px;
.decision-panel li { color: var(--ink); }
.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-row-actions { width: 100%; }
.manager-row-actions .btn { flex: 1; }
.pulse-grid { display: block; border: 0; }
.pulse-item { padding: 13px 0; border-width: 0 0 1px; background: transparent; }
.mobile-primary-action { position: fixed; right: 12px; bottom: 72px; z-index: 45; display: block; }
@@ -0,0 +1,125 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import userEvent from '@testing-library/user-event'
import { within } from '@testing-library/react'
import { api } from '@/api/endpoints'
import type { Account, AdjustBalanceResult } from '@/api/types'
import { AccountsPage } from '@/pages/AccountsPage'
import { renderWithProviders } from './helpers'
vi.mock('@/api/endpoints', () => ({
api: {
accounts: {
list: vi.fn(),
create: vi.fn(),
archive: vi.fn(),
adjustBalance: vi.fn(),
},
},
}))
const listMock = vi.mocked(api.accounts.list)
const adjustBalanceMock = vi.mocked(api.accounts.adjustBalance)
function account(overrides: Partial<Account> = {}): Account {
return {
id: 'a1',
name: 'Alfa Card',
type: 'debit',
status: 'active',
bankCode: 'alfa',
maskedNumber: null,
openedOn: '2026-01-15',
currency: 'RUB',
systemKey: null,
nameKey: null,
balanceKopecks: 123456,
...overrides,
}
}
describe('AccountsPage balance adjustment', () => {
beforeEach(() => {
listMock.mockResolvedValue([
account(),
account({
id: 'a-sys',
name: 'Накопления',
nameKey: 'account.savings',
type: 'savings',
systemKey: 'savings',
balanceKopecks: 4500000,
}),
])
})
afterEach(() => {
vi.clearAllMocks()
})
it('renders the savings system account with a system badge and without archive', async () => {
const screen = renderWithProviders(<AccountsPage />)
const savingsNameCell = (
await screen.findAllByText('Savings')
).find((element) => element.tagName === 'TH')
expect(savingsNameCell).toBeDefined()
expect(screen.getByText('System')).toBeInTheDocument()
const savingsRow = savingsNameCell?.closest('tr')
expect(savingsRow?.textContent).toContain('45,000.00')
const archiveButtons = screen.getAllByRole('button', { name: 'Archive' })
expect(archiveButtons).toHaveLength(1)
})
it('adjusts the balance through the drawer with kopeck conversion and reason', async () => {
adjustBalanceMock.mockResolvedValue({ id: 'op-1' } as unknown as AdjustBalanceResult)
const screen = renderWithProviders(<AccountsPage />)
const savingsNameCell = (
await screen.findAllByText('Savings')
).find((element) => element.tagName === 'TH')
const savingsRow = savingsNameCell?.closest('tr')
const adjustButtonsInRow = Array.from(savingsRow?.querySelectorAll('button') ?? []).filter(
(button) => button.textContent === 'Adjust',
)
await userEvent.click(adjustButtonsInRow[0] as HTMLButtonElement)
const dialog = await screen.findByRole('dialog', { name: 'Adjust balance' })
expect(dialog).toBeInTheDocument()
expect(screen.getByText('Current balance')).toBeInTheDocument()
expect(screen.getByText(/not income or expense/i)).toBeInTheDocument()
const targetInput = screen.getByLabelText('New balance')
expect(targetInput).toHaveValue('45000')
await userEvent.clear(targetInput)
await userEvent.type(targetInput, '50000')
await userEvent.type(screen.getByLabelText('Reason'), 'Bank interest payout')
await userEvent.click(within(dialog).getByRole('button', { name: 'Adjust' }))
expect(adjustBalanceMock).toHaveBeenCalledTimes(1)
expect(adjustBalanceMock).toHaveBeenCalledWith('a-sys', {
targetBalanceKopecks: 5000000,
occurredOn: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
reason: 'Bank interest payout',
})
expect(await screen.findByText('Account balance adjusted')).toBeInTheDocument()
})
it('requires a non-empty reason before submitting', async () => {
const screen = renderWithProviders(<AccountsPage />)
const savingsNameCell = (
await screen.findAllByText('Savings')
).find((element) => element.tagName === 'TH')
const savingsRow = savingsNameCell?.closest('tr')
const adjustButtonsInRow = Array.from(savingsRow?.querySelectorAll('button') ?? []).filter(
(button) => button.textContent === 'Adjust',
)
await userEvent.click(adjustButtonsInRow[0] as HTMLButtonElement)
const dialog = await screen.findByRole('dialog', { name: 'Adjust balance' })
await userEvent.click(within(dialog).getByRole('button', { name: 'Adjust' }))
expect(await screen.findByText('Required field')).toBeInTheDocument()
expect(adjustBalanceMock).not.toHaveBeenCalled()
})
})
+2
View File
@@ -28,6 +28,8 @@ function account(overrides: Partial<Account> = {}): Account {
maskedNumber: '•• 4021',
openedOn: '2026-01-15',
currency: 'RUB',
systemKey: null,
nameKey: null,
balanceKopecks: 123456,
...overrides,
}
@@ -0,0 +1,64 @@
import { describe, expect, it, vi, afterEach } from 'vitest'
import { screen, within } from '@testing-library/react'
import { i18n } from '@/i18n'
import type { BudgetBlocker } from '@/api/types'
import { BudgetBlockers } from '@/components/budget/BudgetBlockers'
import { renderWithProviders } from './helpers'
describe('BudgetBlockers rendering', () => {
afterEach(async () => {
await i18n.changeLanguage('en')
vi.restoreAllMocks()
})
it('renders known blockers with counts and targeted CTAs', () => {
const blockers: BudgetBlocker[] = [
{ code: 'IMPORTS_UNRESOLVED', count: 2 },
{ code: 'IMPORT_UNRESOLVED_HOLDS', count: 3 },
{ code: 'BUDGET_UNCATEGORIZED_EXPENSES', count: 5 },
{ code: 'BUDGET_NEGATIVE_ENVELOPE', categories: ['Savings', 'Car fund'] },
]
renderWithProviders(<BudgetBlockers blockers={blockers} month="2026-02" />)
expect(
screen.getByText('Statement imports are unfinished: 2'),
).toBeInTheDocument()
expect(
screen.getByText('Unconfirmed operations without a resolution: 3'),
).toBeInTheDocument()
expect(screen.getByText('Expenses without a category: 5')).toBeInTheDocument()
expect(screen.getByText('Negative envelope balance: Savings, Car fund')).toBeInTheDocument()
const links = screen.getAllByRole('link')
expect(links.map((link) => link.getAttribute('href'))).toEqual([
'/review?month=2026-02',
'/review?month=2026-02#holds',
'/review?month=2026-02',
'/budget#categories',
])
expect(links[0]).toHaveTextContent('Open review queue')
expect(links[1]).toHaveTextContent('Resolve operations')
expect(links[2]).toHaveTextContent('Assign categories')
expect(links[3]).toHaveTextContent('Open categories')
})
it('localizes unknown blocker codes instead of showing the raw enum', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
renderWithProviders(
<BudgetBlockers blockers={[{ code: 'SOMETHING_NEW' }]} month="2026-02" />,
)
expect(screen.getByText('Close is blocked by an unresolved reason.')).toBeInTheDocument()
expect(screen.queryByText('SOMETHING_NEW')).not.toBeInTheDocument()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
expect(warnSpy).toHaveBeenCalledWith('[budget] unknown blocker code', 'SOMETHING_NEW')
await i18n.changeLanguage('ru')
expect(
await screen.findByText('Закрытие заблокировано необработанной причиной.'),
).toBeInTheDocument()
expect(
within(screen.getByRole('list')).queryByText(/SOMETHING_NEW/),
).not.toBeInTheDocument()
})
})
+2
View File
@@ -46,6 +46,7 @@ const categories: BudgetCategory[] = [
name: null,
nameKey: 'groceries',
kind: 'monthly',
system: false,
archived: false,
creditAccountId: null,
plannedKopecks: 200000,
@@ -58,6 +59,7 @@ const categories: BudgetCategory[] = [
name: 'Buffer',
nameKey: null,
kind: 'savings_envelope',
system: false,
archived: false,
creditAccountId: null,
plannedKopecks: 0,
+191
View File
@@ -0,0 +1,191 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import userEvent from '@testing-library/user-event'
import { within } from '@testing-library/react'
import { api } from '@/api/endpoints'
import type { BudgetCategory, BudgetSummary, CategoryDefinition } from '@/api/types'
import { BudgetPage } from '@/pages/BudgetPage'
import { currentMonth } from '@/lib/dates'
import { renderWithProviders } from './helpers'
vi.mock('@/api/endpoints', () => ({
api: {
accounts: {
list: vi.fn(),
create: vi.fn(),
archive: vi.fn(),
adjustBalance: vi.fn(),
},
operations: {
list: vi.fn(),
create: vi.fn(),
void: vi.fn(),
categorize: vi.fn(),
savingsTransfer: vi.fn(),
savingsTransferRevert: vi.fn(),
},
budget: {
summary: vi.fn(),
categories: vi.fn(),
updatePlan: vi.fn(),
fund: vi.fn(),
close: vi.fn(),
reopen: vi.fn(),
copyPrevious: vi.fn(),
categoryDefinitions: vi.fn(),
createCategory: vi.fn(),
updateCategoryDefinition: vi.fn(),
},
},
}))
const summaryMock = vi.mocked(api.budget.summary)
const categoriesMock = vi.mocked(api.budget.categories)
const definitionsMock = vi.mocked(api.budget.categoryDefinitions)
const createCategoryMock = vi.mocked(api.budget.createCategory)
const updateDefinitionMock = vi.mocked(api.budget.updateCategoryDefinition)
const month = currentMonth()
const summary: BudgetSummary = {
month,
status: 'open',
projectedPoolKopecks: 8000000,
actualPoolKopecks: 7000000,
plannedTotalKopecks: 5000000,
fundedTotalKopecks: 4000000,
spentTotalKopecks: 1200000,
availableKopecks: 3000000,
warnings: [],
blockers: [],
expectedIncomes: [],
}
const monthCategories: BudgetCategory[] = []
const diningDefinition: CategoryDefinition = {
id: 'def-dining',
name: 'Dining',
nameKey: null,
kind: 'monthly',
systemKey: null,
linkedAccountId: null,
archived: false,
}
const definitions: CategoryDefinition[] = [
diningDefinition,
{
id: 'def-savings',
name: 'Накопления',
nameKey: 'savings',
kind: 'savings_envelope',
systemKey: 'savings',
linkedAccountId: 'a-sys',
archived: false,
},
{
id: 'def-old-gym',
name: 'Old Gym',
nameKey: null,
kind: 'monthly',
systemKey: null,
linkedAccountId: null,
archived: true,
},
]
function definition(overrides: Partial<CategoryDefinition>): CategoryDefinition {
return { ...diningDefinition, ...overrides }
}
describe('BudgetPage category manager lifecycle', () => {
beforeEach(() => {
summaryMock.mockResolvedValue(summary)
categoriesMock.mockResolvedValue(monthCategories)
definitionsMock.mockResolvedValue(definitions)
})
afterEach(() => {
vi.clearAllMocks()
})
async function openManager() {
const screen = renderWithProviders(<BudgetPage />)
await screen.findByText('Manage categories')
await userEvent.click(screen.getByRole('button', { name: 'Manage categories' }))
const dialog = await screen.findByRole('dialog', { name: 'Manage categories' })
return { screen, dialog }
}
it('creates a monthly category from the compact form', async () => {
createCategoryMock.mockResolvedValue(definition({ id: 'def-new', name: 'Dining' }))
const { screen, dialog } = await openManager()
await userEvent.type(within(dialog).getByLabelText('Name'), 'Dining')
await userEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
expect(createCategoryMock).toHaveBeenCalledTimes(1)
expect(createCategoryMock).toHaveBeenCalledWith({ name: 'Dining', kind: 'monthly' })
expect(await screen.findByText('Category “Dining” created')).toBeInTheDocument()
})
it('renames and archives a user category through inline actions', async () => {
updateDefinitionMock.mockResolvedValue(definition({ name: 'Eating out' }))
const { screen, dialog } = await openManager()
const diningRow = (await within(dialog).findByText('Dining')).closest('li')
expect(diningRow).not.toBeNull()
await userEvent.click(
Array.from(diningRow?.querySelectorAll('button') ?? []).find(
(button) => button.textContent === 'Rename',
) as HTMLButtonElement,
)
const renameDialog = await screen.findByRole('dialog', { name: 'Rename category' })
const nameInput = within(renameDialog).getByLabelText('Name')
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'Eating out')
await userEvent.click(within(renameDialog).getByRole('button', { name: 'Save' }))
expect(updateDefinitionMock).toHaveBeenCalledWith('def-dining', { name: 'Eating out' })
await userEvent.click(
Array.from(diningRow?.querySelectorAll('button') ?? []).find(
(button) => button.textContent === 'Archive',
) as HTMLButtonElement,
)
const archiveDialog = await screen.findByRole('dialog', { name: 'Archive category' })
await userEvent.click(
Array.from(archiveDialog.querySelectorAll('button')).find(
(button) => button.textContent === 'Archive',
) as HTMLButtonElement,
)
expect(updateDefinitionMock).toHaveBeenCalledWith('def-dining', { archived: true })
expect(await screen.findByText('Category archived')).toBeInTheDocument()
})
it('protects the system savings category and restores archived user categories', async () => {
updateDefinitionMock.mockResolvedValue(definition({ id: 'def-old-gym', archived: false }))
const { screen, dialog } = await openManager()
const savingsRow = (await within(dialog).findByText('Savings')).closest('li')
expect(savingsRow).not.toBeNull()
expect(savingsRow?.textContent).toContain('System')
expect(savingsRow?.textContent).toContain('renaming and archiving are unavailable')
expect(within(savingsRow as HTMLElement).queryByRole('button', { name: 'Rename' })).toBeNull()
expect(within(savingsRow as HTMLElement).queryByRole('button', { name: 'Archive' })).toBeNull()
const tabs = dialog.querySelector('.manager-tabs') as HTMLElement
await userEvent.click(within(tabs).getByRole('button', { name: 'Archive' }))
const oldGymRow = (await within(dialog).findByText('Old Gym')).closest('li')
expect(oldGymRow).not.toBeNull()
await userEvent.click(
Array.from(oldGymRow?.querySelectorAll('button') ?? []).find(
(button) => button.textContent === 'Restore',
) as HTMLButtonElement,
)
expect(updateDefinitionMock).toHaveBeenCalledWith('def-old-gym', { archived: false })
expect(await screen.findByText('Category restored')).toBeInTheDocument()
})
})
@@ -0,0 +1,155 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import userEvent from '@testing-library/user-event'
import { within } from '@testing-library/react'
import { api } from '@/api/endpoints'
import type { ImportDetail, ImportRow } from '@/api/types'
import { ImportDetailPage } from '@/pages/ImportDetailPage'
import { renderWithProviders } from './helpers'
vi.mock('@/api/endpoints', () => ({
api: {
imports: {
list: vi.fn(),
get: vi.fn(),
upload: vi.fn(),
saveRowDecision: vi.fn(),
resolveHold: vi.fn(),
confirm: vi.fn(),
reparse: vi.fn(),
revert: vi.fn(),
},
},
}))
const getImportMock = vi.mocked(api.imports.get)
const resolveHoldMock = vi.mocked(api.imports.resolveHold)
function row(overrides: Partial<ImportRow>): ImportRow {
return {
id: 'row-1',
externalId: 'ext-1',
occurredOn: '2026-09-10',
amountKopecks: 150000,
direction: 'out',
bankStatus: 'posted',
description: 'Coffee beans',
payeeHint: null,
warnings: [],
decision: 'create',
duplicateOfOperationId: null,
holdResolution: null,
...overrides,
}
}
const unresolvedHold = row({
id: 'row-hold',
bankStatus: 'hold',
decision: 'pending',
description: 'Card freeze at cafe',
})
const resolvedHold = row({
id: 'row-hold-done',
externalId: 'ext-2',
bankStatus: 'hold',
decision: 'pending',
description: 'Frozen taxi charge',
holdResolution: {
resolution: 'confirmed_as_posted',
resolvingSourceRecordId: null,
operationId: 'op-1',
resolvedByUserId: 'u1',
reason: 'Money left the account later',
resolvedAt: '2026-09-21T12:00:00Z',
},
})
function detailFixture(rows: ImportRow[]): ImportDetail {
return {
id: 'imp-1',
fileName: 'alfa.xlsx',
bankAdapter: 'alfa_debit_xlsx',
status: 'confirmed',
accountId: 'a1',
accountName: 'Alfa Card',
periodFrom: null,
periodTo: null,
unresolvedHoldCount: rows.filter((item) => item.bankStatus === 'hold' && !item.holdResolution)
.length,
createdAt: '2026-09-01T10:00:00Z',
failureReason: null,
blockers: [],
warnings: [],
reconciliation: {
status: 'matched',
openingBalanceKopecks: null,
closingBalanceKopecks: null,
computedClosingKopecks: null,
reason: null,
},
rows,
totals: {
total: rows.length,
create: 1,
discard: 0,
duplicate: 0,
transfer: 0,
savingsTransfer: 0,
pending: rows.filter((item) => item.decision === 'pending').length,
},
}
}
describe('ImportDetailPage HOLD resolution', () => {
beforeEach(() => {
getImportMock.mockResolvedValue(
detailFixture([row({}), unresolvedHold, resolvedHold]),
)
})
afterEach(() => {
vi.clearAllMocks()
})
it('shows resolution actions instead of the decision select for unresolved holds', async () => {
const screen = renderWithProviders(<ImportDetailPage />)
await screen.findByText('alfa.xlsx')
const selects = screen.getAllByRole('combobox')
expect(selects).toHaveLength(1)
expect(screen.getAllByText('Hold')).toHaveLength(2)
expect(screen.getByRole('button', { name: 'Operation posted' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Operation cancelled' })).toBeInTheDocument()
})
it('requires a reason and resolves a hold as posted', async () => {
resolveHoldMock.mockResolvedValue(detailFixture([row({}), resolvedHold]))
const screen = renderWithProviders(<ImportDetailPage />)
await screen.findByRole('button', { name: 'Operation posted' })
await userEvent.click(screen.getByRole('button', { name: 'Operation posted' }))
const dialog = await screen.findByRole('dialog', { name: 'Operation posted' })
await userEvent.click(within(dialog).getByRole('button', { name: 'Confirm' }))
expect(await screen.findByText('Required field')).toBeInTheDocument()
expect(resolveHoldMock).not.toHaveBeenCalled()
await userEvent.type(screen.getByLabelText('Resolution reason'), 'Charge cleared at the bank')
await userEvent.click(within(dialog).getByRole('button', { name: 'Confirm' }))
expect(resolveHoldMock).toHaveBeenCalledTimes(1)
expect(resolveHoldMock).toHaveBeenCalledWith('imp-1', 'row-hold', {
action: 'confirm_as_posted',
reason: 'Charge cleared at the bank',
})
})
it('displays the resolved history of settled holds', async () => {
const screen = renderWithProviders(<ImportDetailPage />)
const historyRow = (await screen.findByText('Money left the account later')).closest('tr')
expect(historyRow?.textContent).toContain('Posted')
expect(historyRow?.querySelectorAll('button')).toHaveLength(0)
})
})
+10 -1
View File
@@ -32,6 +32,7 @@ function detail(status: ImportStatus): ImportDetail {
accountName: 'Alfa Card',
periodFrom: null,
periodTo: null,
unresolvedHoldCount: 0,
createdAt: '2026-09-01T10:00:00Z',
failureReason: null,
blockers: [],
@@ -44,7 +45,15 @@ function detail(status: ImportStatus): ImportDetail {
reason: null,
},
rows: [],
totals: { total: 0, create: 0, discard: 0, duplicate: 0, transfer: 0 },
totals: {
total: 0,
create: 0,
discard: 0,
duplicate: 0,
transfer: 0,
savingsTransfer: 0,
pending: 0,
},
}
}
@@ -0,0 +1,110 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { api } from '@/api/endpoints'
import type { BudgetCategory, ImportSummary, Page, Operation } from '@/api/types'
import { ReviewQueuePage } from '@/pages/ReviewQueuePage'
import { renderWithProviders } from './helpers'
vi.mock('@/api/endpoints', () => ({
api: {
accounts: {
list: vi.fn(),
create: vi.fn(),
archive: vi.fn(),
adjustBalance: vi.fn(),
},
operations: {
list: vi.fn(),
create: vi.fn(),
void: vi.fn(),
categorize: vi.fn(),
savingsTransfer: vi.fn(),
savingsTransferRevert: vi.fn(),
},
budget: {
summary: vi.fn(),
categories: vi.fn(),
updatePlan: vi.fn(),
fund: vi.fn(),
close: vi.fn(),
reopen: vi.fn(),
copyPrevious: vi.fn(),
categoryDefinitions: vi.fn(),
createCategory: vi.fn(),
updateCategoryDefinition: vi.fn(),
},
imports: {
list: vi.fn(),
get: vi.fn(),
upload: vi.fn(),
saveRowDecision: vi.fn(),
resolveHold: vi.fn(),
confirm: vi.fn(),
reparse: vi.fn(),
revert: vi.fn(),
},
},
}))
const importsListMock = vi.mocked(api.imports.list)
const operationsListMock = vi.mocked(api.operations.list)
const categoriesMock = vi.mocked(api.budget.categories)
const importSummary: ImportSummary = {
id: 'imp-hold',
fileName: 'alfa-february.xlsx',
bankAdapter: 'alfa_debit_xlsx',
status: 'confirmed',
accountId: 'a1',
accountName: 'Alfa Card',
periodFrom: null,
periodTo: null,
unresolvedHoldCount: 2,
createdAt: '2026-03-01T10:00:00Z',
}
describe('ReviewQueuePage month awareness', () => {
beforeEach(() => {
importsListMock.mockResolvedValue([importSummary])
operationsListMock.mockResolvedValue({ items: [], nextCursor: null } as Page<Operation>)
categoriesMock.mockResolvedValue([] as BudgetCategory[])
})
afterEach(() => {
vi.clearAllMocks()
})
it('scopes imports, categories and uncategorized operations to the ?month= param', async () => {
const screen = renderWithProviders(<ReviewQueuePage />, '/review?month=2026-02')
await screen.findByText('alfa-february.xlsx')
expect(importsListMock).toHaveBeenCalledWith('2026-02')
expect(categoriesMock).toHaveBeenCalledWith('2026-02')
expect(operationsListMock).toHaveBeenCalledWith(
expect.objectContaining({
uncategorized: true,
status: 'confirmed',
occurredFrom: '2026-02-01',
occurredTo: '2026-02-28',
}),
)
})
it('keeps confirmed imports with unresolved holds visible for the month', async () => {
const screen = renderWithProviders(<ReviewQueuePage />, '/review?month=2026-02')
const row = (await screen.findByText('alfa-february.xlsx')).closest('tr')
expect(row?.textContent).toContain('2')
const link = screen.getByRole('link', { name: 'Open import' })
expect(link).toHaveAttribute('href', '/imports/imp-hold')
})
it('falls back to the current month without a query param', async () => {
const screen = renderWithProviders(<ReviewQueuePage />)
await screen.findByText('alfa-february.xlsx')
expect(importsListMock).toHaveBeenCalledWith(expect.stringMatching(/^\d{4}-\d{2}$/))
expect(importsListMock.mock.calls[0]?.[0]).not.toBe('2026-02')
})
})
@@ -0,0 +1,183 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import userEvent from '@testing-library/user-event'
import { api } from '@/api/endpoints'
import type { Account, BudgetCategory, Operation } from '@/api/types'
import { OperationsPage } from '@/pages/OperationsPage'
import { renderWithProviders } from './helpers'
vi.mock('@/api/endpoints', () => ({
api: {
accounts: {
list: vi.fn(),
create: vi.fn(),
archive: vi.fn(),
adjustBalance: vi.fn(),
},
operations: {
list: vi.fn(),
create: vi.fn(),
void: vi.fn(),
categorize: vi.fn(),
savingsTransfer: vi.fn(),
savingsTransferRevert: vi.fn(),
},
budget: {
summary: vi.fn(),
categories: vi.fn(),
updatePlan: vi.fn(),
fund: vi.fn(),
close: vi.fn(),
reopen: vi.fn(),
copyPrevious: vi.fn(),
categoryDefinitions: vi.fn(),
createCategory: vi.fn(),
updateCategoryDefinition: vi.fn(),
},
},
}))
const listAccountsMock = vi.mocked(api.accounts.list)
const listOperationsMock = vi.mocked(api.operations.list)
const savingsTransferMock = vi.mocked(api.operations.savingsTransfer)
const revertMock = vi.mocked(api.operations.savingsTransferRevert)
const budgetCategoriesMock = vi.mocked(api.budget.categories)
function account(overrides: Partial<Account> = {}): Account {
return {
id: 'a1',
name: 'Alfa Card',
type: 'debit',
status: 'active',
bankCode: null,
maskedNumber: null,
openedOn: '2026-01-15',
currency: 'RUB',
systemKey: null,
nameKey: null,
balanceKopecks: 123456,
...overrides,
}
}
function operation(overrides: Partial<Operation> & { id: string }): Operation {
return {
kind: 'expense',
status: 'confirmed',
amountKopecks: 500000,
occurredOn: '2026-09-05',
postedOn: null,
accountId: 'a1',
counterpartyAccountId: null,
categoryId: null,
categoryName: null,
categoryKey: null,
payeeName: 'Coffee shop',
incomeSourceName: null,
description: null,
parentOperationId: null,
convertedFromExpense: false,
version: 1,
createdAt: '2026-09-05T10:00:00Z',
...overrides,
}
}
const operations: Operation[] = [
operation({ id: 'op-expense' }),
operation({
id: 'op-converted',
kind: 'transfer',
counterpartyAccountId: 'a-sys',
categoryKey: 'savings',
payeeName: null,
convertedFromExpense: true,
}),
operation({
id: 'op-adjust',
kind: 'adjustment',
amountKopecks: 25000,
payeeName: null,
description: 'Cash delivery',
}),
]
describe('OperationsPage savings convert/revert and adjustment rendering', () => {
beforeEach(() => {
listAccountsMock.mockResolvedValue([
account(),
account({
id: 'a-sys',
name: 'Накопления',
nameKey: 'account.savings',
type: 'savings',
systemKey: 'savings',
balanceKopecks: 0,
}),
])
listOperationsMock.mockResolvedValue({ items: operations, nextCursor: null })
budgetCategoriesMock.mockResolvedValue([] as BudgetCategory[])
})
afterEach(() => {
vi.clearAllMocks()
})
it('converts a confirmed expense into a savings transfer after explicit confirmation', async () => {
savingsTransferMock.mockResolvedValue(operation({ id: 'op-expense', kind: 'transfer' }))
const screen = renderWithProviders(<OperationsPage />)
const row = (await screen.findByText('Coffee shop')).closest('tr') as HTMLTableRowElement
const convertButton = Array.from(row.querySelectorAll('button')).find(
(button) => button.textContent === 'To savings',
)
expect(convertButton).toBeDefined()
await userEvent.click(convertButton as HTMLButtonElement)
const dialog = await screen.findByRole('dialog', { name: 'Convert to savings' })
expect(dialog.textContent).toContain('leaves the month')
expect(dialog.textContent).toContain('grows the Savings account')
await userEvent.click(
withinDialogButton(dialog, 'To savings') as HTMLButtonElement,
)
expect(savingsTransferMock).toHaveBeenCalledTimes(1)
expect(savingsTransferMock).toHaveBeenCalledWith('op-expense')
})
it('reverts a converted savings transfer back to an expense', async () => {
revertMock.mockResolvedValue(operation({ id: 'op-converted', kind: 'expense' }))
const screen = renderWithProviders(<OperationsPage />)
expect(await screen.findByText('Coffee shop')).toBeInTheDocument()
const convertedRow = screen
.getAllByRole('row')
.find((row) => row.textContent?.includes('Savings →') || row.textContent?.includes('→ Savings'))
const revertButton = Array.from(convertedRow?.querySelectorAll('button') ?? []).find(
(button) => button.textContent === 'Back to expense',
)
expect(revertButton).toBeDefined()
await userEvent.click(revertButton as HTMLButtonElement)
const dialog = await screen.findByRole('dialog', { name: 'Revert to expense' })
await userEvent.click(withinDialogButton(dialog, 'Back to expense') as HTMLButtonElement)
expect(revertMock).toHaveBeenCalledTimes(1)
expect(revertMock).toHaveBeenCalledWith('op-converted')
})
it('renders adjustments with a neutral amount, adjustment badge and reason', async () => {
const screen = renderWithProviders(<OperationsPage />)
const row = (await screen.findByText('Cash delivery')).closest('tr') as HTMLTableRowElement
expect(row.textContent).toContain('Adjustment')
const amountCell = row.querySelector('.operation-amount')
expect(amountCell?.textContent).toBe('₽250.00')
expect(amountCell?.textContent?.startsWith('−')).toBe(false)
expect(amountCell?.textContent?.startsWith('+')).toBe(false)
})
})
function withinDialogButton(dialog: HTMLElement, label: string): HTMLElement | undefined {
return Array.from(dialog.querySelectorAll('button')).find(
(button) => button.textContent === label && !button.hasAttribute('aria-label'),
)
}
@@ -0,0 +1,134 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import userEvent from '@testing-library/user-event'
import { within } from '@testing-library/react'
import { api } from '@/api/endpoints'
import type { Account, BudgetCategory, Page, Operation } from '@/api/types'
import { ImportsPage } from '@/pages/ImportsPage'
import { OperationsPage } from '@/pages/OperationsPage'
import { renderWithProviders } from './helpers'
vi.mock('@/api/endpoints', () => ({
api: {
accounts: {
list: vi.fn(),
create: vi.fn(),
archive: vi.fn(),
adjustBalance: vi.fn(),
},
operations: {
list: vi.fn(),
create: vi.fn(),
void: vi.fn(),
categorize: vi.fn(),
savingsTransfer: vi.fn(),
savingsTransferRevert: vi.fn(),
},
budget: {
summary: vi.fn(),
categories: vi.fn(),
updatePlan: vi.fn(),
fund: vi.fn(),
close: vi.fn(),
reopen: vi.fn(),
copyPrevious: vi.fn(),
categoryDefinitions: vi.fn(),
createCategory: vi.fn(),
updateCategoryDefinition: vi.fn(),
},
imports: {
list: vi.fn(),
get: vi.fn(),
upload: vi.fn(),
saveRowDecision: vi.fn(),
resolveHold: vi.fn(),
confirm: vi.fn(),
reparse: vi.fn(),
revert: vi.fn(),
},
},
}))
const listAccountsMock = vi.mocked(api.accounts.list)
const operationsListMock = vi.mocked(api.operations.list)
const budgetCategoriesMock = vi.mocked(api.budget.categories)
const importsListMock = vi.mocked(api.imports.list)
function account(overrides: Partial<Account> = {}): Account {
return {
id: 'a1',
name: 'Alfa Card',
type: 'debit',
status: 'active',
bankCode: null,
maskedNumber: null,
openedOn: '2026-01-15',
currency: 'RUB',
systemKey: null,
nameKey: null,
balanceKopecks: 123456,
...overrides,
}
}
describe('system savings account picker exclusion', () => {
beforeEach(() => {
listAccountsMock.mockResolvedValue([
account(),
account({ id: 'a2', name: 'Cash wallet', type: 'cash' }),
account({
id: 'a-sys',
name: 'Накопления',
nameKey: 'account.savings',
type: 'savings',
systemKey: 'savings',
balanceKopecks: 0,
}),
])
operationsListMock.mockResolvedValue({ items: [], nextCursor: null } as Page<Operation>)
budgetCategoriesMock.mockResolvedValue([] as BudgetCategory[])
importsListMock.mockResolvedValue([])
})
afterEach(() => {
vi.clearAllMocks()
})
it('hides the savings account in the expense form but keeps it in the transfer form and filters', async () => {
const screen = renderWithProviders(<OperationsPage />)
await userEvent.click(
await screen.findByRole('button', { name: '+ New operation' }),
)
const dialog = await screen.findByRole('dialog', { name: 'New operation' })
const expenseSelect = within(dialog).getByLabelText('Account') as HTMLSelectElement
const expenseOptions = Array.from(expenseSelect.options).map((option) => option.textContent)
expect(expenseOptions).toContain('Alfa Card')
expect(expenseOptions).not.toContain('Savings')
await userEvent.click(within(dialog).getByRole('button', { name: 'Transfer' }))
const transferSelects = within(dialog)
.getAllByRole('combobox')
.filter((select) => (select as HTMLSelectElement).options.length > 2) as HTMLSelectElement[]
const transferOptions = transferSelects.flatMap((select) =>
Array.from(select.options).map((option) => option.textContent),
)
expect(transferOptions).toContain('Savings')
const filterSelect = screen.getByLabelText('Account') as HTMLSelectElement
const filterOptions = Array.from(filterSelect.options).map((option) => option.textContent)
expect(filterOptions).toContain('Savings')
})
it('hides the savings account in the statement upload target selector', async () => {
const screen = renderWithProviders(<ImportsPage />)
await userEvent.click(await screen.findByRole('button', { name: '+ Upload statement' }))
const dialog = await screen.findByRole('dialog', { name: 'New import' })
const select = within(dialog).getByLabelText('Target account') as HTMLSelectElement
const options = Array.from(select.options).map((option) => option.textContent)
expect(options).toContain('Alfa Card')
expect(options).toContain('Cash wallet')
expect(options).not.toContain('Savings')
})
})
+27 -11
View File
@@ -2,28 +2,28 @@
"0": "identity/service.py",
"1": "budgeting/router.py",
"2": "Base",
"3": "set_space_context",
"3": "exports/service.py",
"4": "imports/service.py",
"5": "alfa_xlsx.py",
"6": "index.ts",
"6": "system-account-picker.test.tsx",
"7": "types.ts",
"8": "router.tsx",
"9": "ledger/router.py",
"10": "@testing-library/jest-dom",
"11": "compilerOptions",
"12": "AnalyticsPage.tsx",
"12": "BudgetPage.tsx",
"13": "@testing-library/user-event",
"14": "devDependencies",
"15": "Session",
"16": "dependencies",
"17": "ImportDetailPage.tsx",
"18": "OperationsPage.tsx",
"19": "SettingsPage.tsx",
"20": "Toaster.tsx",
"19": "useAccounts.ts",
"20": "index.ts",
"21": "test_adapter_contracts.py",
"22": "BudgetPage.tsx",
"22": "AccountsPage.tsx",
"23": "client.ts",
"24": "AccountsPage.tsx",
"24": "fields.tsx",
"25": "Docker Compose Development Stack",
"26": "Financial Space",
"27": "scripts",
@@ -43,10 +43,13 @@
"41": "test_api.py",
"42": "i18n-parity.test.ts",
"43": "Expected Income",
"44": "TestAlfaDebitContract",
"44": "helpers.tsx",
"45": "20260922_0003_savings_system.py",
"46": "category-manager.test.tsx",
"47": "graphify",
"48": "@types/react-dom",
"49": "typescript-eslint",
"50": "Ближайший край: накопления, категории и блокировки бюджета",
"51": "vitest",
"52": "Favicon SVG (ledger lines icon)",
"53": "init-app-role.sh",
@@ -54,7 +57,7 @@
"55": "spaces/__init__.py",
"56": "Payee",
"57": "eslint.config.js",
"58": "money.test.ts",
"58": "spaces/router.py",
"59": "vite-env.d.ts",
"60": "vite.config.ts",
"61": "finance-backend",
@@ -63,9 +66,22 @@
"64": "Gitea CI and real statement imports",
"65": "20260918_0002_hold_lifecycle.py",
"66": "eslint-plugin-react-hooks",
"67": "main.py",
"68": "current_session",
"69": "TestAlfaDebitContract",
"70": "vite",
"73": "AppError",
"71": "rocketbank_pdf.py",
"72": "TestAlfaSavingsRule",
"73": "savings-convert-revert.test.tsx",
"74": "api",
"75": "ColumnChart.tsx",
"76": "get_settings",
"77": "AGENTS.md",
"78": "graphify.js"
"78": "graphify.js",
"79": "dependencies.py",
"80": "test_category_definitions.py",
"81": "budget-plan.test.tsx",
"82": "import-hold-resolution.test.tsx",
"83": "test_postgres_and_s3.py",
"86": "test_import_holds.py"
}
+1 -1
View File
@@ -1 +1 @@
{"0": "6c063baf517f1eb6", "1": "a4f4a311e078675c", "2": "65ccdcb8bb230ca5", "3": "44b04b925819e26d", "4": "0d50b69f5218f11b", "5": "092f91ab80789a1d", "6": "22c5de371abd09fa", "7": "8bec3f9e493248dd", "8": "af34a2fe936fc26f", "9": "5ebc227112f367f4", "10": "0a5a0ba97778625e", "11": "f039d542469fefc7", "12": "29f9890d7a645660", "13": "8744084aeb65fbd3", "14": "b3e752fefc98c002", "15": "9a0c62b0c24bbbd9", "16": "8ef6cf9118db3195", "17": "f7ebad4765114795", "18": "77b2f7c0291aab31", "19": "792f137ff135bd4e", "20": "15859d93c4ace0e3", "21": "aaac3631365f21d3", "22": "e2e45bae8a909001", "23": "cf7c706bee4e29cc", "24": "345cd1b7ec82598d", "25": "f01c92b243645b4e", "26": "54c3380f579b6729", "27": "d8d767c824110809", "28": "8bc2e2fc5c336465", "29": "3147966ac3ee3b0f", "30": "2918b65a60e97b85", "31": "099ee7edfccfb30f", "32": "0778a13a682f0cbb", "33": "6be20de557b963e9", "34": "da7b9f31790187c3", "35": "17bba31989deabe4", "36": "267c43d4a566a343", "37": "cff206ec60c41144", "38": "c9cd0ce5f2cf7cba", "39": "0e87c765d5713ad6", "40": "ce680f01cc87ac2b", "41": "24380e4087fb70d5", "42": "c58209407d4237e8", "43": "54905c779f6306ec", "44": "b0793b63498bdb9d", "47": "2f8c667574e48967", "48": "08332675f638af21", "49": "4ed4ad08eaab7c1c", "51": "ba6724f5637cd5d9", "52": "fdf8b4d4bf2be849", "53": "66f8ffc9c3fbedf2", "54": "73723cec09ed2417", "55": "f9e8dac18e2e7bdf", "56": "86619fe91fc50452", "57": "8a3d1d863ddb4d2f", "58": "acee339eea91405a", "59": "99c0fcbdc4f53a0e", "60": "cd42295e6b9fcac1", "61": "3712295d0a11267c", "62": "f787d08450bb4ac6", "63": "9dac551857afb104", "64": "fa2cf116b4b9871b", "65": "f3a0edb87b006f25", "66": "a0aa708a9e56bca1", "70": "e049e07b5afc08cf", "73": "dd576fea95f2c4b2", "76": "ef6090e1505912ff", "77": "97ec841e4f367258", "78": "c9051bc3ad4170f1"}
{"0": "c347d287a7f3a6ce", "1": "5b2b5135d389ebc4", "2": "3ff2ac44aa20ca83", "3": "51897f64f56621c8", "4": "2a70b0292e8ae169", "5": "8759229a1634c950", "6": "c854d88fbb83cb56", "7": "68e73285c0067f67", "8": "35d07c09aa48f345", "9": "09b32adce261b645", "10": "0a5a0ba97778625e", "11": "f039d542469fefc7", "12": "c745c877b713a2ce", "13": "8744084aeb65fbd3", "14": "b3e752fefc98c002", "15": "672078e1316fbdfd", "16": "8ef6cf9118db3195", "17": "717661b235a2b452", "18": "ba4f5f0de2481418", "19": "7cf5574993a70b4d", "20": "f544a0eb8929475b", "21": "0ae43a136d1c49b8", "22": "91216f82cff5baf2", "23": "f3004ec80ca9f448", "24": "0e1e4d3fb2a7b5c6", "25": "f01c92b243645b4e", "26": "54c3380f579b6729", "27": "d8d767c824110809", "28": "8bc2e2fc5c336465", "29": "3147966ac3ee3b0f", "30": "2918b65a60e97b85", "31": "099ee7edfccfb30f", "32": "0778a13a682f0cbb", "33": "6be20de557b963e9", "34": "da7b9f31790187c3", "35": "17bba31989deabe4", "36": "267c43d4a566a343", "37": "cff206ec60c41144", "38": "40e8d8816af26a7d", "39": "0e87c765d5713ad6", "40": "ce680f01cc87ac2b", "41": "24380e4087fb70d5", "42": "c58209407d4237e8", "43": "54905c779f6306ec", "44": "ffaa580c9703d37b", "45": "0e25b613a054fa87", "46": "28cda6a600d51f90", "47": "2f8c667574e48967", "48": "08332675f638af21", "49": "4ed4ad08eaab7c1c", "50": "39e65f28c37e0e46", "51": "ba6724f5637cd5d9", "52": "fdf8b4d4bf2be849", "53": "66f8ffc9c3fbedf2", "54": "73723cec09ed2417", "55": "f9e8dac18e2e7bdf", "56": "86619fe91fc50452", "57": "8a3d1d863ddb4d2f", "58": "ade19319347f3d3e", "59": "99c0fcbdc4f53a0e", "60": "cd42295e6b9fcac1", "61": "3712295d0a11267c", "62": "f787d08450bb4ac6", "63": "c70141073bf2394a", "64": "fa2cf116b4b9871b", "65": "f3a0edb87b006f25", "66": "a0aa708a9e56bca1", "67": "37f0b6f332e5305b", "68": "46974fbae1bedb4d", "69": "2b697108a93af879", "70": "e049e07b5afc08cf", "71": "dfd55e9d6c47182b", "72": "2b92b704e5454347", "73": "e404eb8a5c1d93eb", "74": "b422dafdddc8c8e8", "75": "c11d4d615ce6b927", "76": "f82d1ba2ab5e9425", "77": "97ec841e4f367258", "78": "c9051bc3ad4170f1", "79": "05b0f20d97232a3d", "80": "214c5c2b62d5e491", "81": "30a50b463d66a82d", "82": "eb03ce59893de6ca", "83": "9cb55d4b9082fb1b", "86": "4aee66a8f780c711"}
+171 -94
View File
@@ -1,16 +1,16 @@
# Graph Report - finance (2026-09-22)
# Graph Report - finance (2026-09-23)
## Corpus Check
- 153 files · ~65,647 words
- 178 files · ~83,334 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 1288 nodes · 3759 edges · 69 communities (47 shown, 15 thin omitted)
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 71 edges (avg confidence: 0.93)
- 1567 nodes · 4903 edges · 85 communities (62 shown, 17 thin omitted)
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 91 edges (avg confidence: 0.93)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `30bd1b48`
- Built from commit: `8c2b89c8`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@@ -18,28 +18,28 @@
- identity/service.py
- budgeting/router.py
- Base
- set_space_context
- exports/service.py
- imports/service.py
- alfa_xlsx.py
- index.ts
- system-account-picker.test.tsx
- types.ts
- router.tsx
- ledger/router.py
- @testing-library/jest-dom
- compilerOptions
- AnalyticsPage.tsx
- BudgetPage.tsx
- @testing-library/user-event
- devDependencies
- Session
- dependencies
- ImportDetailPage.tsx
- OperationsPage.tsx
- SettingsPage.tsx
- Toaster.tsx
- useAccounts.ts
- index.ts
- test_adapter_contracts.py
- BudgetPage.tsx
- client.ts
- AccountsPage.tsx
- client.ts
- fields.tsx
- Docker Compose Development Stack
- Financial Space
- scripts
@@ -59,36 +59,53 @@
- test_api.py
- i18n-parity.test.ts
- Expected Income
- TestAlfaDebitContract
- helpers.tsx
- 20260922_0003_savings_system.py
- category-manager.test.tsx
- graphify
- @types/react-dom
- typescript-eslint
- Ближайший край: накопления, категории и блокировки бюджета
- vitest
- Favicon SVG (ledger lines icon)
- init-app-role.sh
- Payee
- spaces/router.py
- finance-backend
- LocalDirectoryBlobStore
- TestOzonDebitContract
- Gitea CI and real statement imports
- 20260918_0002_hold_lifecycle.py
- eslint-plugin-react-hooks
- main.py
- current_session
- TestAlfaDebitContract
- vite
- AppError
- rocketbank_pdf.py
- TestAlfaSavingsRule
- savings-convert-revert.test.tsx
- api
- ColumnChart.tsx
- get_settings
- AGENTS.md
- dependencies.py
- test_category_definitions.py
- budget-plan.test.tsx
- import-hold-resolution.test.tsx
- test_postgres_and_s3.py
- test_import_holds.py
## God Nodes (most connected - your core abstractions)
1. `Session` - 146 edges
2. `AppError` - 54 edges
1. `Session` - 198 edges
2. `AppError` - 73 edges
3. `Base` - 47 edges
4. `PkMixin` - 39 edges
5. `get_settings()` - 38 edges
6. `TimestampMixin` - 38 edges
7. `conflict()` - 35 edges
8. `record()` - 34 edges
9. `StatementDocument` - 32 edges
10. `UnsupportedStatementLayout` - 30 edges
4. `conflict()` - 42 edges
5. `get_settings()` - 39 edges
6. `PkMixin` - 39 edges
7. `record()` - 38 edges
8. `TimestampMixin` - 38 edges
9. `StatementDocument` - 36 edges
10. `create_expense()` - 35 edges
## Surprising Connections (you probably didn't know these)
- `Typed Same-Origin API Client` --conceptually_related_to--> `Same-Origin Reverse Proxy` [INFERRED]
@@ -110,99 +127,99 @@
- **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 (69 total, 15 thin omitted)
## Communities (85 total, 17 thin omitted)
### Community 0 - "identity/service.py"
Cohesion: 0.07
Nodes (58): current_context(), current_session(), alias, Context, CurrentSession, Db, Header, User (+50 more)
Cohesion: 0.28
Nodes (18): unauthorized(), accept_invitation(), authenticate(), create_invitation(), create_password_reset(), create_session(), get_active_session(), normalize_email() (+10 more)
### Community 1 - "budgeting/router.py"
Cohesion: 0.20
Nodes (34): AssignmentItem, assignments(), AssignmentsBody, categories(), CategoryBody, CategoryFundingBody, close(), close_frontend() (+26 more)
Cohesion: 0.13
Nodes (48): balances(), cash_flow(), category_spend(), plan_fact(), Context, date, Db, get (+40 more)
### Community 2 - "Base"
Cohesion: 0.07
Nodes (67): downgrade(), upgrade(), balances(), cash_flow(), category_spend(), date, UUID, AuditEvent (+59 more)
Nodes (73): downgrade(), upgrade(), AuditEvent, Append-only domain/security event. Never stores raw financial payloads., ExpectedIncome, ExpectedIncomeMatch, FundingMovement, PeriodClosure (+65 more)
### Community 3 - "set_space_context"
Cohesion: 0.06
Nodes (52): balances(), cash_flow(), category_spend(), plan_fact(), Context, date, Db, get (+44 more)
### Community 3 - "exports/service.py"
Cohesion: 0.10
Nodes (35): Any, Transactional scope for workers/endpoints that own the transaction lifecycle., session_scope(), download(), export_status(), Context, Csrf, Db (+27 more)
### Community 4 - "imports/service.py"
Cohesion: 0.06
Nodes (92): datetime, utcnow(), not_found(), normalize_description(), date, record_fingerprint(), magic_kind(), BlobStore (+84 more)
Nodes (85): datetime, utcnow(), magic_kind(), StatementDocument, confirm(), ConfirmBody, decide_row(), DecisionBody (+77 more)
### Community 5 - "alfa_xlsx.py"
Cohesion: 0.17
Nodes (24): AlfaDebitXlsxAdapter, _AlfaHeaderMeta, AlfaXlsxAdapterBase, _cell_str(), _debit_find_header(), _debit_header_metadata(), _debit_header_value(), _debit_norm() (+16 more)
Cohesion: 0.15
Nodes (27): AlfaCreditXlsxAdapter, AlfaDebitXlsxAdapter, _AlfaHeaderMeta, AlfaXlsxAdapterBase, _cell_str(), _debit_find_header(), _debit_header_metadata(), _debit_header_value() (+19 more)
### Community 6 - "index.ts"
Cohesion: 0.26
Nodes (8): Locale, LangSwitcher(), createI18n(), detectLocale(), i18n, LOCALES, setLocale(), storedLocale()
### Community 6 - "system-account-picker.test.tsx"
Cohesion: 0.15
Nodes (12): AdjustBalanceResult, BudgetCategory, Operation, Page, categoriesMock, importsListMock, importSummary, operationsListMock (+4 more)
### Community 7 - "types.ts"
Cohesion: 0.06
Nodes (47): api, Account, AccountStatus, BalanceItem, BalancesReport, BudgetBlocker, BudgetCategory, BudgetStatus (+39 more)
Cohesion: 0.08
Nodes (35): AccountStatus, AccountType, BalanceItem, BalancesReport, BudgetStatus, BudgetWarning, CashFlowMonth, CategoriesBlocker (+27 more)
### Community 8 - "router.tsx"
Cohesion: 0.10
Nodes (33): AcceptInvitePayload, LoginPayload, PasswordResetPayload, AcceptInvitePage, BudgetPage, ImportDetailPage, LoginPage, OperationsPage (+25 more)
Cohesion: 0.07
Nodes (49): isApiError(), AcceptInvitePayload, LoginPayload, PasswordResetPayload, App(), queryClient, AcceptInvitePage, AccountsPage (+41 more)
### Community 9 - "ledger/router.py"
Cohesion: 0.15
Nodes (31): account_json(), AccountBody, accounts(), archive_account(), create_account(), create_operation(), delete_account(), operation_json() (+23 more)
Cohesion: 0.16
Nodes (35): account_json(), AccountBody, accounts(), adjust_balance(), AdjustBalanceBody, archive_account(), create_account(), create_operation() (+27 more)
### Community 11 - "compilerOptions"
Cohesion: 0.07
Nodes (28): compilerOptions, baseUrl, esModuleInterop, forceConsistentCasingInFileNames, isolatedModules, jsx, lib, module (+20 more)
### Community 12 - "AnalyticsPage.tsx"
Cohesion: 0.12
Nodes (19): AnalyticsPage, CashFlowColumn, ColumnChart(), ColumnChartProps, BarRow, HorizontalBars(), HorizontalBarsProps, PlanFactBars() (+11 more)
### Community 12 - "BudgetPage.tsx"
Cohesion: 0.08
Nodes (53): NotFoundPage, CategoryManagerModal(), CreateCategoryForm(), RenameModal(), PlanFactBars(), PlanFactBarsProps, PlanFactRow, MonthSwitcher() (+45 more)
### Community 14 - "devDependencies"
Cohesion: 0.10
Nodes (21): eslint, @eslint/js, eslint-plugin-jsx-a11y, devDependencies, eslint, @eslint/js, eslint-plugin-jsx-a11y, globals (+13 more)
### Community 15 - "Session"
Cohesion: 0.07
Nodes (122): plan_fact(), Any, record(), BudgetPeriod, Calendar month budget period in the space timezone (month granularity)., add_expected_income(), archive_category(), _balance_until() (+114 more)
Cohesion: 0.05
Nodes (165): balances(), cash_flow(), category_spend(), plan_fact(), date, UUID, Any, record() (+157 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.20
Nodes (18): Button(), ConfirmDialog(), ConfirmDialogProps, Modal(), ModalProps, useToast(), useConfirmImport(), useImport() (+10 more)
Cohesion: 0.16
Nodes (23): HoldResolutionAction, ImportStatus, RowDecision, useToast(), useConfirmImport(), useImport(), useImportErrorToaster(), useReparseImport() (+15 more)
### Community 18 - "OperationsPage.tsx"
Cohesion: 0.11
Nodes (30): Operation, OperationKind, OperationStatus, ImportsPage, Badge(), BadgeTone, EmptyState(), EmptyStateProps (+22 more)
Cohesion: 0.12
Nodes (43): isSystemAccount(), Locale, useAccounts(), useActiveAccounts(), useUserAccounts(), useCategorizeOperation(), useConvertToSavings(), useCreateOperation() (+35 more)
### Community 19 - "SettingsPage.tsx"
### Community 19 - "useAccounts.ts"
Cohesion: 0.16
Nodes (16): invalidationRoots, queryKeys, SESSION_ROOT, sessionInvalidationRoot, SPACE_ROOT, spaceInvalidationRoot, OperationFilters, useCancelDeletion() (+8 more)
Nodes (13): invalidationRoots, queryKeys, SESSION_ROOT, sessionInvalidationRoot, SPACE_ROOT, spaceInvalidationRoot, AdjustBalancePayload, CreateAccountPayload (+5 more)
### Community 20 - "Toaster.tsx"
Cohesion: 0.18
Nodes (10): App(), queryClient, AppRoutes(), ToastApi, ToastContext, ToastItem, ToastKind, ToastProvider() (+2 more)
### Community 20 - "index.ts"
Cohesion: 0.16
Nodes (13): BudgetBlocker, blockerCta(), blockerText(), BudgetBlockers(), BudgetBlockersProps, Cta, LangSwitcher(), createI18n() (+5 more)
### Community 21 - "test_adapter_contracts.py"
Cohesion: 0.11
Nodes (26): 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 (+18 more)
Cohesion: 0.12
Nodes (19): account_fingerprint(), amount_to_kopecks(), _mask_digits(), money_to_kopecks(), Parse statement money strings like '-1 234,56 RUR', '+ 500.00 ₽', '12 345'.…, SourceRow, StatementDraft, _first_page_text() (+11 more)
### Community 22 - "BudgetPage.tsx"
Cohesion: 0.17
Nodes (20): NotFoundPage, OverviewPage, PageHeader(), PageHeaderProps, useBudgetCategories(), useBudgetErrorToaster(), useBudgetSummary(), useCloseMonth() (+12 more)
### Community 22 - "AccountsPage.tsx"
Cohesion: 0.12
Nodes (23): Account, CREATE_KINDS, Badge(), BadgeTone, Button(), ButtonProps, Variant, ConfirmDialog() (+15 more)
### Community 23 - "client.ts"
Cohesion: 0.23
Cohesion: 0.26
Nodes (10): apiClient, ApiError, buildUrl(), ErrorEnvelope, parseErrorEnvelope(), QueryValue, readCsrfToken(), request() (+2 more)
### Community 24 - "AccountsPage.tsx"
Cohesion: 0.13
Nodes (21): AccountType, CreateAccountPayload, AccountsPage, FieldChrome, InputProps, MoneyField(), MoneyFieldProps, SelectField() (+13 more)
### Community 24 - "fields.tsx"
Cohesion: 0.23
Nodes (10): FieldChrome, InputProps, MoneyField(), MoneyFieldProps, SelectFieldProps, SelectProps, TextareaField(), TextareaFieldProps (+2 more)
### Community 25 - "Docker Compose Development Stack"
Cohesion: 0.29
@@ -253,8 +270,8 @@ 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 - "TestRocketbankDebitContract"
Cohesion: 0.24
Nodes (3): same_page_rocket_lines(), synthetic_rocket_lines(), TestRocketbankDebitContract
Cohesion: 0.13
Nodes (7): minimal_pdf(), _QuotaSettings, Build a small valid single-font PDF with empty or ASCII-only pages., same_page_rocket_lines(), synthetic_rocket_lines(), TestRocketbankDebitContract, MonkeyPatch
### Community 39 - "generate-api.mjs"
Cohesion: 0.40
@@ -268,22 +285,34 @@ 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 - "TestAlfaDebitContract"
Cohesion: 0.08
Nodes (23): Any, Reconcile parsed rows against declared bank controls with stable diagnostics.…, _reconciliation_summary(), alfa_workbook_bytes(), synthetic_alfa_rows(), TestAlfaDebitContract, _check(), _fail() (+15 more)
### Community 44 - "helpers.tsx"
Cohesion: 0.16
Nodes (13): User, ToastApi, ToastContext, ToastItem, ToastKind, ToastProvider(), openManager(), createTestQueryClient() (+5 more)
### Community 45 - "20260922_0003_savings_system.py"
Cohesion: 0.32
Nodes (11): _check_constraints(), downgrade(), _has_column(), _has_row(), _has_unique_constraint(), _normalized_sqltext(), Savings system resources, adjustments and reversible conversions., Swap a check constraint for a new value list, dialect-safely. Fresh databases… (+3 more)
### Community 46 - "category-manager.test.tsx"
Cohesion: 0.15
Nodes (11): CategoryDefinition, categoriesMock, createCategoryMock, definitions, definitionsMock, diningDefinition, month, monthCategories (+3 more)
### Community 47 - "graphify"
Cohesion: 0.17
Nodes (11): command, enabled, timeout, type, mcp, graphify, plugin, $schema (+3 more)
### Community 50 - "Ближайший край: накопления, категории и блокировки бюджета"
Cohesion: 0.10
Nodes (20): 1. Доменная основа накоплений, 2. Альфа debit: «Копилка для сдачи», 3. Управление категориями, 4. Понятные и устранимые блокировки, Backend-контракт, Frontend, Ближайший край: накопления, категории и блокировки бюджета, Бюджетная семантика (+12 more)
### Community 58 - "spaces/router.py"
Cohesion: 0.17
Nodes (20): forbidden(), cancel_deletion(), cancel_deletion(), current(), deletion_status(), DeletionBody, invite_member(), MemberInviteBody (+12 more)
### Community 62 - "LocalDirectoryBlobStore"
Cohesion: 0.39
Nodes (3): LocalDirectoryBlobStore, Path, S3-neutral local store; production swaps in an S3-compatible client.
### Community 63 - "TestOzonDebitContract"
Cohesion: 0.16
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
Nodes (6): Confirmed decisions, Gitea CI and real statement imports, Goal and constraints, Implementation plan, Principal risks, Validation and rollout
@@ -292,33 +321,81 @@ Nodes (6): Confirmed decisions, Gitea CI and real statement imports, Goal and co
Cohesion: 0.80
Nodes (4): downgrade(), _has_check_constraint(), _has_column(), upgrade()
### Community 73 - "AppError"
Cohesion: 0.21
Nodes (11): AppError, 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., Crypto-shred hook: deleting wrapped DEK rows in DB plus blobs removes access. (+3 more)
### Community 67 - "main.py"
Cohesion: 0.13
Nodes (23): get_db(), get_engine(), get_session_factory(), sessionmaker, app_error_handler(), health(), healthz(), get (+15 more)
### Community 68 - "current_session"
Cohesion: 0.20
Nodes (10): current_session(), alias, Context, Db, Header, User, require_csrf(), Cookie (+2 more)
### Community 69 - "TestAlfaDebitContract"
Cohesion: 0.18
Nodes (3): alfa_workbook_bytes(), synthetic_alfa_rows(), TestAlfaDebitContract
### Community 71 - "rocketbank_pdf.py"
Cohesion: 0.24
Nodes (7): _date_from(), _PendingRecord, date, Rocketbank debit statement contract, evidenced against the real document.…, RocketbankDebitPdfAdapter, open_pdf(), _quota_error()
### Community 73 - "savings-convert-revert.test.tsx"
Cohesion: 0.20
Nodes (6): budgetCategoriesMock, listAccountsMock, listOperationsMock, operations, revertMock, savingsTransferMock
### Community 74 - "api"
Cohesion: 0.22
Nodes (5): api, adjustBalanceMock, listMock, createMock, listMock
### Community 75 - "ColumnChart.tsx"
Cohesion: 0.24
Nodes (7): CashFlowColumn, ColumnChart(), ColumnChartProps, BarRow, HorizontalBars(), HorizontalBarsProps, rows
### Community 76 - "get_settings"
Cohesion: 0.12
Nodes (30): get_settings(), Settings, send_token_email(), accept_invite(), AcceptInviteBody, confirm_password_reset(), create_invite(), InviteBody (+22 more)
Nodes (31): get_settings(), Settings, send_token_email(), accept_invite(), AcceptInviteBody, confirm_password_reset(), create_invite(), InviteBody (+23 more)
### Community 79 - "dependencies.py"
Cohesion: 0.26
Nodes (14): Transaction-local RLS context. No-op on SQLite (tests); fail-closed on…, set_space_context(), current_context(), CurrentSession, RequestContext, FinancialSpace, Membership, get_membership() (+6 more)
### Community 80 - "test_category_definitions.py"
Cohesion: 0.54
Nodes (7): bootstrap(), TestClient, HTTP contract for category definitions, system protections and CSRF., test_adjust_and_savings_conversion_http_contracts(), test_category_lifecycle_create_rename_archive_restore(), test_openapi_contains_new_contracts(), test_space_has_protected_savings_pair()
### Community 81 - "budget-plan.test.tsx"
Cohesion: 0.25
Nodes (7): BudgetSummary, categories, categoriesMock, month, summary, summaryMock, updatePlanMock
### Community 82 - "import-hold-resolution.test.tsx"
Cohesion: 0.25
Nodes (5): ImportRow, getImportMock, resolvedHold, resolveHoldMock, unresolvedHold
### Community 83 - "test_postgres_and_s3.py"
Cohesion: 0.17
Nodes (7): BlobStore, get_blob_store(), Protocol, S3BlobStore, test_postgres_rls_requires_matching_transaction_context(), test_s3_blob_round_trip(), skipif
### Community 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)
## Knowledge Gaps
- **190 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` (+185 more)
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 343 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.
- **248 isolated node(s):** `$schema`, `file:///home/k1nq/repos/finance/.kilo/plugins/graphify.js`, `type`, `/home/k1nq/.local/bin/graphify-mcp`, `/home/k1nq/repos/finance/graphify-out/graph.json` (+243 more)
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 429 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
- **17 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `Session` connect `Session` to `identity/service.py`, `Base`, `set_space_context`, `imports/service.py`?**
_High betweenness centrality (0.082) - this node is a cross-community bridge._
- **Why does `AppError` connect `AppError` to `identity/service.py`, `set_space_context`, `imports/service.py`, `alfa_xlsx.py`, `TestRocketbankDebitContract`, `get_settings`, `Session`, `test_adapter_contracts.py`, `TestOzonDebitContract`?**
_High betweenness centrality (0.068) - this node is a cross-community bridge._
- **Why does `bad_request()` connect `Session` to `identity/service.py`, `budgeting/router.py`, `imports/service.py`, `AppError`, `ledger/router.py`?**
_High betweenness centrality (0.017) - this node is a cross-community bridge._
- **Are the 2 inferred relationships involving `Session` (e.g. with `UTCDateTime` and `test_postgres_rls_requires_matching_transaction_context()`) actually correct?**
_`Session` has 2 INFERRED edges - model-reasoned connections that need verification._
- **Are the 24 inferred relationships involving `AppError` (e.g. with `request_export()` and `login()`) actually correct?**
_`AppError` has 24 INFERRED edges - model-reasoned connections that need verification._
- **Why does `Session` connect `Session` to `identity/service.py`, `Base`, `exports/service.py`, `main.py`, `imports/service.py`, `20260922_0003_savings_system.py`, `dependencies.py`, `test_postgres_and_s3.py`, `test_import_holds.py`, `spaces/router.py`?**
_High betweenness centrality (0.063) - this node is a cross-community bridge._
- **Why does `AppError` connect `Session` to `identity/service.py`, `Base`, `exports/service.py`, `imports/service.py`, `alfa_xlsx.py`, `main.py`, `rocketbank_pdf.py`, `TestRocketbankDebitContract`, `get_settings`, `test_adapter_contracts.py`, `test_import_holds.py`, `spaces/router.py`, `TestOzonDebitContract`?**
_High betweenness centrality (0.050) - this node is a cross-community bridge._
- **Why does `get_settings()` connect `get_settings` to `identity/service.py`, `Base`, `exports/service.py`, `main.py`, `alfa_xlsx.py`, `imports/service.py`, `rocketbank_pdf.py`, `dependencies.py`, `test_postgres_and_s3.py`?**
_High betweenness centrality (0.018) - this node is a cross-community bridge._
- **Are the 3 inferred relationships involving `Session` (e.g. with `upgrade()` and `UTCDateTime`) actually correct?**
_`Session` has 3 INFERRED edges - model-reasoned connections that need verification._
- **Are the 40 inferred relationships involving `AppError` (e.g. with `request_export()` and `login()`) actually correct?**
_`AppError` has 40 INFERRED edges - model-reasoned connections that need verification._
- **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?**
_190 weakly-connected nodes found - possible documentation gaps or missing edges._
_248 weakly-connected nodes found - possible documentation gaps or missing edges._
File diff suppressed because one or more lines are too long
+24537 -7779
View File
File diff suppressed because it is too large Load Diff
+304 -154
View File
@@ -24,16 +24,16 @@
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"backend/app/analytics/router.py": {
"mtime": 1789129123.9786532,
"seen": 1789377303.0691855,
"ast_hash": "dc4d5f43d1e17ae77b21f774d37c1106",
"semantic_hash": "dc4d5f43d1e17ae77b21f774d37c1106"
"mtime": 1790061745.2630062,
"seen": 1790096479.536423,
"ast_hash": "6207eef3120bdd3ae7a9692f17e80134",
"semantic_hash": ""
},
"backend/app/analytics/service.py": {
"mtime": 1789129314.8210335,
"seen": 1789377303.0691862,
"ast_hash": "0385e76cccd5d140b8cc01af5b74821e",
"semantic_hash": "0385e76cccd5d140b8cc01af5b74821e"
"mtime": 1790061159.3943744,
"seen": 1790096479.536424,
"ast_hash": "58ac24a62b4cf8a986527f79d456127d",
"semantic_hash": ""
},
"backend/app/audit/__init__.py": {
"mtime": 1789127312.3538313,
@@ -66,15 +66,15 @@
"semantic_hash": "039081cab3d91704e8f8a7152a981304"
},
"backend/app/budgeting/router.py": {
"mtime": 1789129314.8240335,
"seen": 1789377303.0691917,
"ast_hash": "c352243e4fb85ab49f9cf4532c11bd3f",
"semantic_hash": "c352243e4fb85ab49f9cf4532c11bd3f"
"mtime": 1790097111.8510067,
"seen": 1790129548.0529666,
"ast_hash": "64e5050629404acc7877b8489944f28f",
"semantic_hash": ""
},
"backend/app/budgeting/service.py": {
"mtime": 1789730971.072982,
"seen": 1789735433.158112,
"ast_hash": "c30d4c58608f88b81ff05be63a314247",
"mtime": 1790129321.9786477,
"seen": 1790129548.052968,
"ast_hash": "9e7273a5b90092b3df2fc7eb89c7bde5",
"semantic_hash": ""
},
"backend/app/categorization/__init__.py": {
@@ -90,10 +90,10 @@
"semantic_hash": "922083067aa2a24d67954296844db8f0"
},
"backend/app/categorization/service.py": {
"mtime": 1789128297.7359908,
"seen": 1789377303.0691953,
"ast_hash": "9590b3c99e7c2b20b40d895d7495b5cf",
"semantic_hash": "9590b3c99e7c2b20b40d895d7495b5cf"
"mtime": 1790060937.0384295,
"seen": 1790096479.536434,
"ast_hash": "175c114e437a93f2c4deb76c7bd7df2c",
"semantic_hash": ""
},
"backend/app/config.py": {
"mtime": 1789356583.024539,
@@ -210,15 +210,15 @@
"semantic_hash": ""
},
"backend/app/imports/adapters/alfa_xlsx.py": {
"mtime": 1789736201.215202,
"seen": 1789736272.9455209,
"ast_hash": "31eee0cded845f9ddaff98d2127f4354",
"mtime": 1790061232.3863332,
"seen": 1790096479.5364504,
"ast_hash": "6a3d3456eceef5ad3e2a79d2c43bcdc2",
"semantic_hash": ""
},
"backend/app/imports/adapters/base.py": {
"mtime": 1789733778.7145572,
"seen": 1789735433.1581285,
"ast_hash": "4f6364ab07cf38ba2eea1ba94678d784",
"mtime": 1790061188.1227522,
"seen": 1790096479.536451,
"ast_hash": "f6bce881a99c0233fa6b129ddf0c255e",
"semantic_hash": ""
},
"backend/app/imports/adapters/ozon_pdf.py": {
@@ -264,15 +264,15 @@
"semantic_hash": "0904afccafd9eb544b4d6846fc6384c8"
},
"backend/app/imports/router.py": {
"mtime": 1789987595.224261,
"seen": 1789987623.1673603,
"ast_hash": "561967cd45b365aeeb5f06dcba55e0a3",
"mtime": 1790097243.6436644,
"seen": 1790129548.052993,
"ast_hash": "5c3be5ef9ee835a376b463b59132ecef",
"semantic_hash": ""
},
"backend/app/imports/service.py": {
"mtime": 1789987595.224261,
"seen": 1789987623.1673615,
"ast_hash": "d78aede9091025373227eeea80758ffb",
"mtime": 1790097243.6436644,
"seen": 1790129548.0529938,
"ast_hash": "2d46532c0c4294ac19adfbc4696506dd",
"semantic_hash": ""
},
"backend/app/imports/tasks.py": {
@@ -288,22 +288,22 @@
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"backend/app/ledger/models.py": {
"mtime": 1789130236.8679283,
"seen": 1789377303.069223,
"ast_hash": "81335e0d9f1f5a71be1626ec218da410",
"semantic_hash": "81335e0d9f1f5a71be1626ec218da410"
"mtime": 1790060833.8770478,
"seen": 1790096479.5364609,
"ast_hash": "86891c404082d8d2c84214f31d8616ff",
"semantic_hash": ""
},
"backend/app/ledger/router.py": {
"mtime": 1789358018.8352346,
"seen": 1789377303.0692236,
"ast_hash": "809411054fbede0694ed6ee863723fe5",
"semantic_hash": "809411054fbede0694ed6ee863723fe5"
"mtime": 1790097195.1290338,
"seen": 1790129548.052997,
"ast_hash": "cc108ddf12a8020198afe9d3d0847e2a",
"semantic_hash": ""
},
"backend/app/ledger/service.py": {
"mtime": 1789358018.8349617,
"seen": 1789377303.0692244,
"ast_hash": "96a715590e5af44c494e5c7c817780a3",
"semantic_hash": "96a715590e5af44c494e5c7c817780a3"
"mtime": 1790077834.4411142,
"seen": 1790096479.536463,
"ast_hash": "9d5895e92011706e835b582aca3878c8",
"semantic_hash": ""
},
"backend/app/main.py": {
"mtime": 1789130118.4222198,
@@ -342,10 +342,10 @@
"semantic_hash": "073fa39a355b257a832ead3830942d8f"
},
"backend/app/spaces/service.py": {
"mtime": 1789129887.1068308,
"seen": 1789377303.0692303,
"ast_hash": "99f21c94dad6403b77f4cafed97c1c2e",
"semantic_hash": "99f21c94dad6403b77f4cafed97c1c2e"
"mtime": 1790060979.7579985,
"seen": 1790096479.5364685,
"ast_hash": "214a4094630b761ea2a64de83d59317a",
"semantic_hash": ""
},
"backend/app/worker.py": {
"mtime": 1789129206.4285526,
@@ -402,8 +402,8 @@
"semantic_hash": "ed54799a921d10394f826893acb46876"
},
"frontend/package.json": {
"mtime": 1789990591.2584393,
"seen": 1790048203.8354623,
"mtime": 1790055350.366911,
"seen": 1790096479.5364823,
"ast_hash": "172e2880b6084c1fc423ea759b99280a",
"semantic_hash": ""
},
@@ -426,32 +426,32 @@
"semantic_hash": "dbea075a006b7703340f7f93e69d53ac"
},
"frontend/src/api/endpoints.ts": {
"mtime": 1789357272.302138,
"seen": 1789377303.069242,
"ast_hash": "c4d7620bf50bef28b20286b6093e7e30",
"semantic_hash": "c4d7620bf50bef28b20286b6093e7e30"
"mtime": 1790076936.1693087,
"seen": 1790096479.536486,
"ast_hash": "bc61b518c946899ff0a9f4f00aaff9a0",
"semantic_hash": ""
},
"frontend/src/api/generated/schema.d.ts": {
"mtime": 1789130160.8338337,
"seen": 1789377303.069243,
"ast_hash": "8857d50fb909f019e1d8c03009c0ada7",
"semantic_hash": "8857d50fb909f019e1d8c03009c0ada7"
"mtime": 1790129418.1908941,
"seen": 1790129548.0530229,
"ast_hash": "36e1ccd2ad9eb079ab1502d535ed90ff",
"semantic_hash": ""
},
"frontend/src/api/queryKeys.ts": {
"mtime": 1789127044.43022,
"seen": 1789377303.0692437,
"ast_hash": "5ec23457cf6b0f223df671d3bc0f8455",
"semantic_hash": "5ec23457cf6b0f223df671d3bc0f8455"
"mtime": 1790076958.2303789,
"seen": 1790096479.5364876,
"ast_hash": "e2bf898fcebf3d8b13b51dccfd6d34ba",
"semantic_hash": ""
},
"frontend/src/api/types.ts": {
"mtime": 1789357272.302138,
"seen": 1789377303.069248,
"ast_hash": "0d3865b4525321cf5a6b360f72de5f5a",
"semantic_hash": "0d3865b4525321cf5a6b360f72de5f5a"
"mtime": 1790097111.8510067,
"seen": 1790129548.0530243,
"ast_hash": "744887bcd7ef47a37a6899108a5fcbc0",
"semantic_hash": ""
},
"frontend/src/app/router.tsx": {
"mtime": 1789990322.8798492,
"seen": 1790048203.8354712,
"mtime": 1790055350.367207,
"seen": 1790096479.5364902,
"ast_hash": "9ae2869af5fbf84240281598d14e30b9",
"semantic_hash": ""
},
@@ -474,9 +474,9 @@
"semantic_hash": "f581486baa48447ec4c573acef3190ef"
},
"frontend/src/components/layout/AppLayout.tsx": {
"mtime": 1789990291.3144267,
"seen": 1790048203.8354752,
"ast_hash": "3077033bf2d5b262eaadeb8519b28160",
"mtime": 1790096202.721176,
"seen": 1790096479.5364962,
"ast_hash": "40d35a9e1278142b233541c6c104e289",
"semantic_hash": ""
},
"frontend/src/components/layout/LangSwitcher.tsx": {
@@ -516,8 +516,8 @@
"semantic_hash": "6827eb04b914e8c675e842fea74b5ba5"
},
"frontend/src/components/ui/Modal.tsx": {
"mtime": 1789990425.5642233,
"seen": 1790048203.8354838,
"mtime": 1790055350.367207,
"seen": 1790096479.5365033,
"ast_hash": "4250ebda526b86679ba5eb965feccf59",
"semantic_hash": ""
},
@@ -558,10 +558,10 @@
"semantic_hash": "5e1b157a5cd5b484582ba959049443aa"
},
"frontend/src/hooks/useAccounts.ts": {
"mtime": 1789127463.8123653,
"seen": 1789377303.0692647,
"ast_hash": "5cbd401bea752132870ca35a0b2cf29f",
"semantic_hash": "5cbd401bea752132870ca35a0b2cf29f"
"mtime": 1790077275.5295525,
"seen": 1790096479.5365093,
"ast_hash": "2104274d4051595fe50ab8b55e99c1c7",
"semantic_hash": ""
},
"frontend/src/hooks/useAnalytics.ts": {
"mtime": 1789127476.179531,
@@ -576,22 +576,22 @@
"semantic_hash": "40f9f47b108ec0b484f9564bd1d319cb"
},
"frontend/src/hooks/useBudget.ts": {
"mtime": 1789127463.8123653,
"seen": 1789377303.0692673,
"ast_hash": "06c203b89322dca6cf80931f3558b0dc",
"semantic_hash": "06c203b89322dca6cf80931f3558b0dc"
"mtime": 1790077001.481125,
"seen": 1790096479.5365117,
"ast_hash": "2ab1d3e626a5771c11c6f35b39d9055f",
"semantic_hash": ""
},
"frontend/src/hooks/useImports.ts": {
"mtime": 1789127476.176531,
"seen": 1789377303.0692682,
"ast_hash": "d23f116da79b15438601751b45519268",
"semantic_hash": "d23f116da79b15438601751b45519268"
"mtime": 1790077023.7244031,
"seen": 1790096479.5365126,
"ast_hash": "58c2ec19df6427aaf77fdc8120ce8f34",
"semantic_hash": ""
},
"frontend/src/hooks/useOperations.ts": {
"mtime": 1789127463.8123653,
"seen": 1789377303.069269,
"ast_hash": "3cac9924554a5465276a2c4431121866",
"semantic_hash": "3cac9924554a5465276a2c4431121866"
"mtime": 1790076985.3479233,
"seen": 1790096479.5365133,
"ast_hash": "26f72b4e30b26fe12d90b29dfb4c4246",
"semantic_hash": ""
},
"frontend/src/hooks/useSpaceLifecycle.ts": {
"mtime": 1789356647.3201435,
@@ -606,34 +606,34 @@
"semantic_hash": "243512f6e095deeaa29393b7c831777f"
},
"frontend/src/i18n/locales/en.json": {
"mtime": 1790048119.281922,
"seen": 1790048203.835497,
"ast_hash": "2a494738341ae850ef916befc380db56",
"mtime": 1790096831.314309,
"seen": 1790129548.0530467,
"ast_hash": "2d62cdde2e726baf5e6b9627d58372ec",
"semantic_hash": ""
},
"frontend/src/i18n/locales/ru.json": {
"mtime": 1790048119.281922,
"seen": 1790048203.8354976,
"ast_hash": "08995a7a5ca546d034ab9ab05d5621a5",
"mtime": 1790096831.3153088,
"seen": 1790129548.0530472,
"ast_hash": "aebf731fc4a592f2f20abd2d309e9a97",
"semantic_hash": ""
},
"frontend/src/main.tsx": {
"mtime": 1789990619.7178197,
"seen": 1790048203.8354983,
"mtime": 1790055350.367207,
"seen": 1790096479.5365248,
"ast_hash": "b88ee6ed2ae6eab1f3db7097b6912329",
"semantic_hash": ""
},
"frontend/src/pages/AcceptInvitePage.tsx": {
"mtime": 1789990425.548223,
"seen": 1790048203.835499,
"mtime": 1790055350.367207,
"seen": 1790096479.5365257,
"ast_hash": "936aaf3f5f5874de65e1bd31af620936",
"semantic_hash": ""
},
"frontend/src/pages/AccountsPage.tsx": {
"mtime": 1789127567.9147615,
"seen": 1789377303.0692747,
"ast_hash": "5da5d66e5eb40222e4ceb1f75ca951bd",
"semantic_hash": "5da5d66e5eb40222e4ceb1f75ca951bd"
"mtime": 1790097195.1318128,
"seen": 1790129548.0530555,
"ast_hash": "e3646de544b00aa8a3ab294afb02e159",
"semantic_hash": ""
},
"frontend/src/pages/AnalyticsPage.tsx": {
"mtime": 1789127904.7983484,
@@ -642,26 +642,26 @@
"semantic_hash": "4a83070566fde66ff6423b1064cde4e8"
},
"frontend/src/pages/BudgetPage.tsx": {
"mtime": 1789127621.1764753,
"seen": 1789377303.0692763,
"ast_hash": "c1421d4088b0f174dc7ecb082b2a257d",
"semantic_hash": "c1421d4088b0f174dc7ecb082b2a257d"
"mtime": 1790097127.972161,
"seen": 1790129548.053057,
"ast_hash": "7435e885343ed139a64dfbe152b44624",
"semantic_hash": ""
},
"frontend/src/pages/ImportDetailPage.tsx": {
"mtime": 1790048024.9117486,
"seen": 1790048203.8355024,
"ast_hash": "ebd497c87a8600cf030d905bbdedcb94",
"mtime": 1790077605.334677,
"seen": 1790096479.5365288,
"ast_hash": "75923869914b7ef53b939676f9d5f095",
"semantic_hash": ""
},
"frontend/src/pages/ImportsPage.tsx": {
"mtime": 1789127830.2842758,
"seen": 1789377303.069278,
"ast_hash": "721376253dda864ff81a5025691947e0",
"semantic_hash": "721376253dda864ff81a5025691947e0"
"mtime": 1790078666.8189514,
"seen": 1790096479.5365298,
"ast_hash": "89167c348b1cef4b9a7a1e754da22857",
"semantic_hash": ""
},
"frontend/src/pages/LoginPage.tsx": {
"mtime": 1789990425.5480313,
"seen": 1790048203.8355043,
"mtime": 1790055350.367207,
"seen": 1790096479.5365305,
"ast_hash": "cb1c4cfbb684a4667e0662b452fb9459",
"semantic_hash": ""
},
@@ -672,22 +672,22 @@
"semantic_hash": "53e014a77ff3070820a83458af08a5fd"
},
"frontend/src/pages/OperationsPage.tsx": {
"mtime": 1790048012.6410372,
"seen": 1790048203.8355062,
"ast_hash": "8820a38967f7c248508cb8c62ea4fa00",
"mtime": 1790095531.9706314,
"seen": 1790096479.5365317,
"ast_hash": "f3eb4c376ab882b8b83cc27a09f0ab68",
"semantic_hash": ""
},
"frontend/src/pages/ResetPasswordPage.tsx": {
"mtime": 1789990425.558781,
"seen": 1790048203.8355079,
"mtime": 1790055350.367207,
"seen": 1790096479.5365329,
"ast_hash": "2b2c87734763d8abc2af638babc012d6",
"semantic_hash": ""
},
"frontend/src/pages/ReviewQueuePage.tsx": {
"mtime": 1789127802.1288989,
"seen": 1789377303.0692823,
"ast_hash": "b3f57d73ef457c9b2427d74603972213",
"semantic_hash": "b3f57d73ef457c9b2427d74603972213"
"mtime": 1790097345.6599903,
"seen": 1790129548.0530627,
"ast_hash": "3fd04897b64b97c0423b5b539e2736a6",
"semantic_hash": ""
},
"frontend/src/pages/SettingsPage.tsx": {
"mtime": 1789128572.7868366,
@@ -696,16 +696,16 @@
"semantic_hash": "6a35c2799ba84fdbc1367d768907e8fa"
},
"frontend/src/test/accounts-page.test.tsx": {
"mtime": 1789128626.8475864,
"seen": 1789377303.0692837,
"ast_hash": "d17270fb5ca22286d3d62d7e2679fbcb",
"semantic_hash": "d17270fb5ca22286d3d62d7e2679fbcb"
"mtime": 1790078077.4305809,
"seen": 1790096479.536536,
"ast_hash": "333f6f0a741400d5f7b0fc778bd17999",
"semantic_hash": ""
},
"frontend/src/test/budget-plan.test.tsx": {
"mtime": 1789128448.8001103,
"seen": 1789377303.0692847,
"ast_hash": "b2abf4ff7b3ea5b6f730cddd021b4127",
"semantic_hash": "b2abf4ff7b3ea5b6f730cddd021b4127"
"mtime": 1790097111.8589516,
"seen": 1790129548.0530663,
"ast_hash": "0eb496e1c89aa33ed330b71f7bde8185",
"semantic_hash": ""
},
"frontend/src/test/chart-accessible-table.test.tsx": {
"mtime": 1789128448.8031104,
@@ -726,10 +726,10 @@
"semantic_hash": "cc870c8a667b6982c9fabd654468a46f"
},
"frontend/src/test/import-polling.test.tsx": {
"mtime": 1789128692.423248,
"seen": 1789377303.0692883,
"ast_hash": "2b83dee550ca31b0d5980c8ce3235fd0",
"semantic_hash": "2b83dee550ca31b0d5980c8ce3235fd0"
"mtime": 1790078095.9788127,
"seen": 1790096479.5365417,
"ast_hash": "49ccb2891bba56407a762149d55e59be",
"semantic_hash": ""
},
"frontend/src/test/login-page.test.tsx": {
"mtime": 1789128286.0698264,
@@ -804,10 +804,10 @@
"semantic_hash": "8fbddf58406caccd2d5a5b0e5f9ffab4"
},
"docs/adr/0002-journal-and-budget-reserves.md": {
"mtime": 1789126779.7821348,
"seen": 1789377303.0693,
"ast_hash": "6ca9f9b8f91733ce37e15f339a656726",
"semantic_hash": "6ca9f9b8f91733ce37e15f339a656726"
"mtime": 1790061406.9456153,
"seen": 1790096479.5365562,
"ast_hash": "8a83c81c137c5698631ad0ed3ff7a3bb",
"semantic_hash": ""
},
"docs/adr/0003-versioned-statement-imports.md": {
"mtime": 1789732137.8203447,
@@ -864,9 +864,9 @@
"semantic_hash": "0a7bb82cce04631f36f2d561a1665478"
},
"infra/docker-compose.yml": {
"mtime": 1789400438.2433548,
"seen": 1789735433.1582313,
"ast_hash": "9f04dd1906326ddea306584fe68a2ae4",
"mtime": 1790067636.943902,
"seen": 1790096479.5365646,
"ast_hash": "24a00b6c306c0d043c7e2bd7dffa4cc1",
"semantic_hash": ""
},
"frontend/public/favicon.svg": {
@@ -888,9 +888,9 @@
"semantic_hash": ""
},
"backend/tests/test_adapter_contracts.py": {
"mtime": 1789735944.366427,
"seen": 1789736272.9455402,
"ast_hash": "c3f63850c85b7c5262e66370ab7ab8a9",
"mtime": 1790061478.1675427,
"seen": 1790096479.5364716,
"ast_hash": "9d446a10c953a14c8d4962eff50f5b97",
"semantic_hash": ""
},
"backend/tests/test_import_holds.py": {
@@ -918,9 +918,9 @@
"semantic_hash": ""
},
"frontend/pnpm-workspace.yaml": {
"mtime": 1789377936.9976747,
"seen": 1789735433.1582298,
"ast_hash": "2ae2370fa527b015cd7bd98dc7ca2a6b",
"mtime": 1790076555.3445592,
"seen": 1790096479.5365627,
"ast_hash": "abd02edee0c2599c6acaf570827446e9",
"semantic_hash": ""
},
".kilo/plugins/graphify.js": {
@@ -936,15 +936,165 @@
"semantic_hash": ""
},
"frontend/src/components/layout/AuthLayout.tsx": {
"mtime": 1789990425.546223,
"seen": 1790048203.8354764,
"mtime": 1790055350.367207,
"seen": 1790096479.5364969,
"ast_hash": "0b6a76918da0118d0580d2c36c9649e6",
"semantic_hash": ""
},
"frontend/src/pages/OverviewPage.tsx": {
"mtime": 1789990291.3154266,
"seen": 1790048203.835507,
"ast_hash": "02a37c24bbeb6de67b2c6449209a5f0f",
"mtime": 1790077489.1771908,
"seen": 1790096479.5365322,
"ast_hash": "6a426b63545f169490e67d52ca231180",
"semantic_hash": ""
},
"backend/alembic/versions/20260922_0003_savings_system.py": {
"mtime": 1790078031.6120079,
"seen": 1790096479.536419,
"ast_hash": "12acc1a6c82583b06da88974500498a7",
"semantic_hash": ""
},
"backend/app/ledger/savings.py": {
"mtime": 1790097195.1310222,
"seen": 1790129548.0529978,
"ast_hash": "5367de3a6184c6df07170721a6a0fcf8",
"semantic_hash": ""
},
"backend/tests/test_budget_savings.py": {
"mtime": 1790077946.050938,
"seen": 1790096479.5364738,
"ast_hash": "47280e68387aee190b930ac53ac79e93",
"semantic_hash": ""
},
"backend/tests/test_category_definitions.py": {
"mtime": 1790078212.0062637,
"seen": 1790096479.5364745,
"ast_hash": "4d16f9374862cea5f66bec35ea4ffec9",
"semantic_hash": ""
},
"backend/tests/test_import_savings.py": {
"mtime": 1790129378.2103765,
"seen": 1790129548.0530114,
"ast_hash": "7bf7f4500852520a2a975a452911af1c",
"semantic_hash": ""
},
"backend/tests/test_savings.py": {
"mtime": 1790097195.1310337,
"seen": 1790129548.053015,
"ast_hash": "c5eb8af5219e2f5cc153d09c0c4a37a1",
"semantic_hash": ""
},
"frontend/src/components/budget/BudgetBlockers.tsx": {
"mtime": 1790097345.6529903,
"seen": 1790129548.0530257,
"ast_hash": "e96fad155df8e394b641b993d8e6130c",
"semantic_hash": ""
},
"frontend/src/components/budget/CategoryManagerModal.tsx": {
"mtime": 1790078043.7267377,
"seen": 1790096479.5364919,
"ast_hash": "72a373612048b42b68405b965daa3153",
"semantic_hash": ""
},
"frontend/src/lib/accountLabel.ts": {
"mtime": 1790077060.9918694,
"seen": 1790096479.5365171,
"ast_hash": "bb3a61289adb5d6229bb6a12410d02d0",
"semantic_hash": ""
},
"frontend/src/lib/apiErrors.ts": {
"mtime": 1789128567.5937645,
"seen": 1790096479.536518,
"ast_hash": "d51645bca1a77ef23039aef1efa743a0",
"semantic_hash": ""
},
"frontend/src/lib/categoryLabel.ts": {
"mtime": 1790078053.7822852,
"seen": 1790096479.5365188,
"ast_hash": "b19e49e9732aac18915836ab673f2e06",
"semantic_hash": ""
},
"frontend/src/lib/dates.ts": {
"mtime": 1790077043.631652,
"seen": 1790096479.5365195,
"ast_hash": "61aec482c5ddb04bd345fe00646f55dd",
"semantic_hash": ""
},
"frontend/src/lib/importStatus.ts": {
"mtime": 1789127070.0500677,
"seen": 1790096479.5365202,
"ast_hash": "3cf55ae86628ddc5ed2f875eacbb01d5",
"semantic_hash": ""
},
"frontend/src/lib/locale.ts": {
"mtime": 1789127112.2586372,
"seen": 1790096479.5365229,
"ast_hash": "e02e2a4931690366bbbe0d3f453b02af",
"semantic_hash": ""
},
"frontend/src/lib/money.ts": {
"mtime": 1789127028.213317,
"seen": 1790096479.5365236,
"ast_hash": "9415a280fd82fe03ed97eb59861531f2",
"semantic_hash": ""
},
"frontend/src/lib/operationFormat.ts": {
"mtime": 1790077054.0287824,
"seen": 1790096479.536524,
"ast_hash": "9e824008057c462f8deb68426177dc4e",
"semantic_hash": ""
},
"frontend/src/test/account-adjustment.test.tsx": {
"mtime": 1790078758.2890954,
"seen": 1790096479.5365353,
"ast_hash": "b26ad41ca4891543d2d7b6c1782ee82b",
"semantic_hash": ""
},
"frontend/src/test/budget-blockers.test.tsx": {
"mtime": 1790129482.1347215,
"seen": 1790129548.0530655,
"ast_hash": "758d2e006f6653b1da3f8c7d969c9e60",
"semantic_hash": ""
},
"frontend/src/test/category-manager.test.tsx": {
"mtime": 1790078889.2937336,
"seen": 1790096479.5365384,
"ast_hash": "e32016dfb312c6fd11e9b485b9499798",
"semantic_hash": ""
},
"frontend/src/test/import-hold-resolution.test.tsx": {
"mtime": 1790078821.0050712,
"seen": 1790096479.5365412,
"ast_hash": "8d46975e4f292e69d2531486578b0e1e",
"semantic_hash": ""
},
"frontend/src/test/review-queue-month.test.tsx": {
"mtime": 1790078386.9584517,
"seen": 1790096479.5365443,
"ast_hash": "ca49d684ae8f1cd1d5c2ad790c8cd130",
"semantic_hash": ""
},
"frontend/src/test/savings-convert-revert.test.tsx": {
"mtime": 1790078835.9295752,
"seen": 1790096479.536545,
"ast_hash": "d653ff8e32c8f48bc3f426629f2358b1",
"semantic_hash": ""
},
"frontend/src/test/system-account-picker.test.tsx": {
"mtime": 1790078451.265256,
"seen": 1790096479.5365462,
"ast_hash": "0b78c425dc9713dc2097588d4857dcf0",
"semantic_hash": ""
},
".kilo/plans/1790055652854-savings-import-categories-blockers.md": {
"mtime": 1790060073.3451552,
"seen": 1790096479.5365524,
"ast_hash": "c9de58c67335a1dccee2e8d8be5e7acc",
"semantic_hash": ""
},
"backend/tests/test_savings_migration.py": {
"mtime": 1790129378.2103765,
"seen": 1790129548.053016,
"ast_hash": "9ada17014ccdef9572af77bb00f2590b",
"semantic_hash": ""
}
}
+1 -1
View File
@@ -84,7 +84,7 @@ services:
networks: [backend]
minio:
image: minio/minio:latest
image: quay.io/minio/minio:latest
restart: unless-stopped
command: ["server", "/data", "--console-address", ":9001"]
environment: