Update file metadata in manifest.json and change MinIO image source in docker-compose.yml
This commit is contained in:
+4
-1
@@ -17,6 +17,9 @@ eggs/
|
|||||||
.eggs/
|
.eggs/
|
||||||
lib/
|
lib/
|
||||||
lib64/
|
lib64/
|
||||||
|
# The frontend's runtime utilities live in a source directory named `lib`.
|
||||||
|
!frontend/src/lib/
|
||||||
|
!frontend/src/lib/**
|
||||||
parts/
|
parts/
|
||||||
sdist/
|
sdist/
|
||||||
var/
|
var/
|
||||||
@@ -210,4 +213,4 @@ cost.json
|
|||||||
graphify-out/cache/
|
graphify-out/cache/
|
||||||
|
|
||||||
examples/
|
examples/
|
||||||
.playwright-mcp
|
.playwright-mcp
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -22,8 +22,8 @@ def category_spend(
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"categoryId": row["category_id"],
|
"categoryId": row["category_id"],
|
||||||
"name": row["category"] if not row["category"].startswith("category.") else None,
|
"name": row["name"],
|
||||||
"nameKey": row["category"] if row["category"].startswith("category.") else None,
|
"nameKey": row["name_key"],
|
||||||
"amountKopecks": row["amount_kopecks"],
|
"amountKopecks": row["amount_kopecks"],
|
||||||
}
|
}
|
||||||
for row in rows
|
for row in rows
|
||||||
@@ -75,8 +75,8 @@ def plan_fact(month: str, db: Db, context: Context) -> dict:
|
|||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"categoryId": row["category_id"],
|
"categoryId": row["category_id"],
|
||||||
"name": row["name"] if not str(row["name"]).startswith("category.") else None,
|
"name": row.get("name"),
|
||||||
"nameKey": row["name"] if str(row["name"]).startswith("category.") else None,
|
"nameKey": row.get("name_key"),
|
||||||
"plannedKopecks": row["planned"],
|
"plannedKopecks": row["planned"],
|
||||||
"actualKopecks": row["spent"],
|
"actualKopecks": row["spent"],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.budgeting.models import PeriodClosure
|
from app.budgeting.models import PeriodClosure
|
||||||
from app.budgeting.service import compute_period_state, get_or_create_period
|
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.models import Account, Category, Operation
|
||||||
from app.ledger.service import account_balance
|
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)
|
totals[expense.category_id] += expense.amount_kopecks - int(refunded or 0)
|
||||||
categories = {
|
categories = {
|
||||||
item.id: resolve_category_name(item)
|
item.id: item
|
||||||
for item in db.query(Category).filter_by(space_id=space_id).all()
|
for item in db.query(Category).filter_by(space_id=space_id).all()
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"category_id": category_id,
|
"category_id": category_id,
|
||||||
"category": (
|
"name": (
|
||||||
categories.get(category_id, "category.unnamed")
|
resolve_category_name(categories[category_id])
|
||||||
if category_id is not None
|
if category_id is not None and category_id in categories
|
||||||
else "category.uncategorized"
|
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,
|
"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)
|
totals: dict[tuple[date, str], int] = defaultdict(int)
|
||||||
for operation in operations:
|
for operation in operations:
|
||||||
effective = operation.posted_on or operation.occurred_on
|
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
|
continue
|
||||||
signed = (
|
signed = (
|
||||||
operation.amount_kopecks
|
operation.amount_kopecks
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import date
|
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 pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.budgeting import service
|
from app.budgeting import service
|
||||||
|
from app.categorization import service as categorization
|
||||||
from app.dependencies import Context, Csrf, Db
|
from app.dependencies import Context, Csrf, Db
|
||||||
|
|
||||||
router = APIRouter(prefix="/budget", tags=["budget"])
|
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)
|
year, month_number = parse_month(month)
|
||||||
period = period_for(db, context, year, month_number)
|
period = period_for(db, context, year, month_number)
|
||||||
state = service.compute_period_state(db, space_id=context.space.id, period=period)
|
state = service.compute_period_state(db, space_id=context.space.id, period=period)
|
||||||
rows = state["monthly_categories"] + [
|
rows = state["monthly_categories"] + state["envelopes"]
|
||||||
{
|
|
||||||
"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"]
|
|
||||||
]
|
|
||||||
category_by_id = {
|
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:
|
||||||
"id": row["category_id"],
|
category = category_by_id[row["category_id"]]
|
||||||
"name": row["name"] if not str(row["name"]).startswith("category.") else None,
|
result.append(
|
||||||
"nameKey": row["name"] if str(row["name"]).startswith("category.") else None,
|
{
|
||||||
"kind": category_by_id[row["category_id"]].kind,
|
"id": row["category_id"],
|
||||||
"archived": False,
|
"name": row["name"],
|
||||||
"creditAccountId": category_by_id[row["category_id"]].linked_account_id,
|
"nameKey": row["name_key"],
|
||||||
"plannedKopecks": row["planned"],
|
"kind": category.kind,
|
||||||
"fundedKopecks": row["funded"],
|
"system": row["system"],
|
||||||
"spentKopecks": row["spent"],
|
"archived": category.archived,
|
||||||
"availableKopecks": row["available"],
|
"creditAccountId": category.linked_account_id,
|
||||||
}
|
"plannedKopecks": row["planned"],
|
||||||
for row in rows
|
"fundedKopecks": row["funded"],
|
||||||
]
|
"spentKopecks": row["spent"],
|
||||||
|
"availableKopecks": row["available"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
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)
|
@router.post("/categories", status_code=201)
|
||||||
@@ -157,7 +201,7 @@ def create_category(body: CategoryBody, db: Db, context: Context, _csrf: Csrf) -
|
|||||||
kind=body.kind,
|
kind=body.kind,
|
||||||
name=body.name,
|
name=body.name,
|
||||||
)
|
)
|
||||||
return {"id": category.id, "kind": category.kind, "name": body.name}
|
return category_definition_json(category)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/categories/{category_id}")
|
@router.patch("/categories/{category_id}")
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from app.budgeting.models import (
|
|||||||
from app.categorization.service import resolve_category_name
|
from app.categorization.service import resolve_category_name
|
||||||
from app.errors import bad_request, conflict, not_found
|
from app.errors import bad_request, conflict, not_found
|
||||||
from app.identity.models import User
|
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
|
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:
|
def _balance_until(db: Session, account: Account, end: date) -> int:
|
||||||
from app.ledger.models import Posting
|
|
||||||
|
|
||||||
total = (
|
total = (
|
||||||
db.query(func.coalesce(func.sum(Posting.amount_kopecks), 0))
|
db.query(func.coalesce(func.sum(Posting.amount_kopecks), 0))
|
||||||
.join(Operation, Operation.id == Posting.operation_id)
|
.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 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
|
- a same-period refund restores the parent purchase's category; a refund on
|
||||||
an uncategorized expense restores the pool;
|
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
|
- monthly categories fold their residual into the pool at close; savings
|
||||||
envelopes and credit reserves persist via all-time/ snapshot carries.
|
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()
|
accounts = db.query(Account).filter_by(space_id=space_id).all()
|
||||||
account_by_id = {a.id: a for a in accounts}
|
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 = (
|
ops = (
|
||||||
db.query(Operation)
|
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)
|
payments_by_account: dict[Any, int] = defaultdict(int)
|
||||||
refunds_to_credit_by_account: dict[Any, int] = defaultdict(int)
|
refunds_to_credit_by_account: dict[Any, int] = defaultdict(int)
|
||||||
refund_back_uncategorized_period = 0
|
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:
|
for op in ops:
|
||||||
if op.type == "income":
|
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":
|
if counter is not None and counter.kind == "credit":
|
||||||
payments_by_account[counter.id] += op.amount_kopecks
|
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":
|
elif op.type == "refund":
|
||||||
parent_account = account_by_id.get(op.account_id)
|
parent_account = account_by_id.get(op.account_id)
|
||||||
parent = db.get(Operation, op.parent_operation_id) if op.parent_operation_id else None
|
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.
|
# All-time envelope consumption up to period end.
|
||||||
for cat in categories:
|
for cat in categories:
|
||||||
if cat.kind != "savings_envelope":
|
if cat.kind != "savings_envelope" or cat.system_key == "savings":
|
||||||
continue
|
continue
|
||||||
consumed = (
|
consumed = (
|
||||||
db.query(func.coalesce(func.sum(Operation.amount_kopecks), 0))
|
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)
|
- (payments_total - releases_payment_total)
|
||||||
+ surplus_release_total
|
+ surplus_release_total
|
||||||
- funding_total
|
- funding_total
|
||||||
|
- savings_net_period
|
||||||
)
|
)
|
||||||
|
|
||||||
monthly_rows: list[dict[str, Any]] = []
|
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),
|
"category_id": str(cat.id),
|
||||||
"name": resolve_category_name(cat),
|
"name": resolve_category_name(cat),
|
||||||
|
"name_key": cat.system_key,
|
||||||
|
"archived": cat.archived,
|
||||||
"planned": 0,
|
"planned": 0,
|
||||||
"funded": funded_period[cat.id],
|
"funded": funded_period[cat.id],
|
||||||
"spent": monthly_consumption[cat.id],
|
"spent": monthly_consumption[cat.id],
|
||||||
"available": available,
|
"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]] = []
|
envelope_rows: list[dict[str, Any]] = []
|
||||||
for cat in categories:
|
for cat in categories:
|
||||||
if cat.kind != "savings_envelope" or cat.system_key == "credit_reserve":
|
if cat.kind != "savings_envelope" or cat.system_key == "credit_reserve":
|
||||||
continue
|
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]
|
available = funded_all_time[cat.id] - envelope_consumption_total[cat.id]
|
||||||
envelope_rows.append(
|
envelope_rows.append(
|
||||||
{
|
{
|
||||||
"category_id": str(cat.id),
|
"category_id": str(cat.id),
|
||||||
"name": resolve_category_name(cat),
|
"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],
|
"funded_total": funded_all_time[cat.id],
|
||||||
|
"planned": 0,
|
||||||
|
"funded": funded_period[cat.id],
|
||||||
|
"spent": funded_all_time[cat.id] - available,
|
||||||
"available": 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
|
matched_by_expected[m.expected_income_id] += m.amount_kopecks
|
||||||
|
|
||||||
uncategorized_count = sum(1 for op in ops if op.type == "expense" and op.category_id is None)
|
uncategorized_count = sum(1 for op in ops if op.type == "expense" and op.category_id is None)
|
||||||
negative_envelopes = [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 = []
|
warnings = []
|
||||||
if planned_total > carry_pool + expected_total:
|
if planned_total > carry_pool + expected_total:
|
||||||
@@ -508,7 +569,10 @@ def create_category(
|
|||||||
) -> Category:
|
) -> Category:
|
||||||
if kind not in ("monthly", "savings_envelope"):
|
if kind not in ("monthly", "savings_envelope"):
|
||||||
raise bad_request("BUDGET_CATEGORY_KIND_INVALID", "Unknown category kind")
|
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.add(category)
|
||||||
db.flush()
|
db.flush()
|
||||||
audit.record(
|
audit.record(
|
||||||
@@ -554,7 +618,7 @@ def archive_category(
|
|||||||
db: Session, *, space_id: uuid.UUID, user: User, category_id: uuid.UUID
|
db: Session, *, space_id: uuid.UUID, user: User, category_id: uuid.UUID
|
||||||
) -> Category:
|
) -> Category:
|
||||||
category = 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 == "credit_reserve":
|
if category.system_key is not None:
|
||||||
raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be archived")
|
raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be archived")
|
||||||
category.archived = True
|
category.archived = True
|
||||||
audit.record(
|
audit.record(
|
||||||
@@ -567,21 +631,41 @@ def archive_category(
|
|||||||
return category
|
return category
|
||||||
|
|
||||||
|
|
||||||
def rename_category(
|
def update_category_definition(
|
||||||
db: Session, *, space_id: uuid.UUID, user: User, category_id: uuid.UUID, name: str
|
db: Session,
|
||||||
|
*,
|
||||||
|
space_id: uuid.UUID,
|
||||||
|
user: User,
|
||||||
|
category_id: uuid.UUID,
|
||||||
|
name: str | None = None,
|
||||||
|
archived: bool | None = None,
|
||||||
) -> Category:
|
) -> 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)
|
category = get_category(db, space_id=space_id, category_id=category_id)
|
||||||
if category.system_key is not None:
|
if category.system_key is not None:
|
||||||
category.name_override = name
|
raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be renamed or archived")
|
||||||
else:
|
changes: dict[str, bool | str] = {}
|
||||||
category.custom_name = name
|
if name is not None:
|
||||||
audit.record(
|
trimmed = name.strip()
|
||||||
db,
|
if not trimmed:
|
||||||
event_type="category.renamed",
|
raise bad_request("BUDGET_CATEGORY_NAME_REQUIRED", "Category name is required")
|
||||||
space_id=space_id,
|
category.custom_name = trimmed[:200]
|
||||||
user_id=user.id,
|
changes["renamed"] = True
|
||||||
detail={"category_id": str(category.id)},
|
if archived is not None:
|
||||||
)
|
category.archived = archived
|
||||||
|
changes["archived"] = archived
|
||||||
|
if changes:
|
||||||
|
audit.record(
|
||||||
|
db,
|
||||||
|
event_type="category.updated",
|
||||||
|
space_id=space_id,
|
||||||
|
user_id=user.id,
|
||||||
|
detail={"category_id": str(category.id), **changes},
|
||||||
|
)
|
||||||
return category
|
return category
|
||||||
|
|
||||||
|
|
||||||
@@ -597,6 +681,8 @@ def set_assignments(
|
|||||||
result = []
|
result = []
|
||||||
for item in items:
|
for item in items:
|
||||||
category = get_category(db, space_id=space_id, category_id=item["category_id"])
|
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:
|
if category.archived:
|
||||||
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived")
|
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived")
|
||||||
planned = item["planned_kopecks"]
|
planned = item["planned_kopecks"]
|
||||||
@@ -713,7 +799,11 @@ def fund(
|
|||||||
raise bad_request("BUDGET_FUNDING_KIND_INVALID", "Unknown funding kind")
|
raise bad_request("BUDGET_FUNDING_KIND_INVALID", "Unknown funding kind")
|
||||||
if amount_kopecks <= 0:
|
if amount_kopecks <= 0:
|
||||||
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
|
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
|
||||||
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(
|
movement = FundingMovement(
|
||||||
space_id=space_id,
|
space_id=space_id,
|
||||||
period_id=period.id,
|
period_id=period.id,
|
||||||
|
|||||||
@@ -5,15 +5,22 @@ from app.errors import conflict, not_found
|
|||||||
from app.ledger.models import Category
|
from app.ledger.models import Category
|
||||||
|
|
||||||
|
|
||||||
def resolve_category_name(category: Category) -> str:
|
def resolve_category_name(category: Category) -> str | None:
|
||||||
"""Standard categories resolve to a translation key; overrides/customs are literal."""
|
"""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:
|
if category.custom_name is not None:
|
||||||
return category.custom_name
|
return category.custom_name
|
||||||
if category.name_override is not None:
|
if category.name_override is not None:
|
||||||
return category.name_override
|
return category.name_override
|
||||||
if category.system_key is not None:
|
return None
|
||||||
return f"category.{category.system_key}"
|
|
||||||
return "category.unnamed"
|
|
||||||
|
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:
|
def get_income_source(db: Session, *, space_id, income_source_id) -> IncomeSource:
|
||||||
|
|||||||
@@ -29,6 +29,40 @@ DESCRIPTION_HEADERS = ("описание", "назначение", "коммен
|
|||||||
CATEGORY_HEADERS = ("категория",)
|
CATEGORY_HEADERS = ("категория",)
|
||||||
ACCOUNT_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:
|
class AlfaXlsxAdapterBase:
|
||||||
"""Alfa statement contract: XLSX with a header row and bank marker 'Альфа'."""
|
"""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)
|
header_idx, columns = _debit_find_header(self, rows)
|
||||||
meta = _debit_header_metadata(self, rows, header_idx)
|
meta = _debit_header_metadata(self, rows, header_idx)
|
||||||
period_start, period_end = _debit_statement_period(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:
|
if not parsed:
|
||||||
raise UnsupportedStatementLayout("no operation rows found in Alfa statement")
|
raise UnsupportedStatementLayout("no operation rows found in Alfa statement")
|
||||||
return StatementDraft(
|
return StatementDraft(
|
||||||
@@ -413,6 +449,7 @@ def _debit_operation_rows(
|
|||||||
rows: list[tuple],
|
rows: list[tuple],
|
||||||
header_idx: int,
|
header_idx: int,
|
||||||
columns: dict[str, int],
|
columns: dict[str, int],
|
||||||
|
statement_account: str = "",
|
||||||
) -> list[SourceRow]:
|
) -> list[SourceRow]:
|
||||||
parsed: list[SourceRow] = []
|
parsed: list[SourceRow] = []
|
||||||
separated_from_operations = False
|
separated_from_operations = False
|
||||||
@@ -475,6 +512,12 @@ def _debit_operation_rows(
|
|||||||
row_ref=f"sheet0:row{offset}",
|
row_ref=f"sheet0:row{offset}",
|
||||||
warnings=tuple(warnings),
|
warnings=tuple(warnings),
|
||||||
bank_status=bank_status,
|
bank_status=bank_status,
|
||||||
|
savings_transfer=detect_savings_transfer(
|
||||||
|
description,
|
||||||
|
direction=direction,
|
||||||
|
bank_status=bank_status,
|
||||||
|
statement_account=statement_account,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
separated_from_operations = False
|
separated_from_operations = False
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ BANK_STATUSES = (BANK_STATUS_POSTED, BANK_STATUS_HOLD)
|
|||||||
PARSER_VERSION = "1"
|
PARSER_VERSION = "1"
|
||||||
# Per-contract versions: bumped for the rewritten evidenced contracts so old
|
# Per-contract versions: bumped for the rewritten evidenced contracts so old
|
||||||
# parse runs can never be confused with the new canonical output.
|
# 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"
|
OZON_DEBIT_PDF_VERSION = "2"
|
||||||
ROCKETBANK_DEBIT_PDF_VERSION = "2"
|
ROCKETBANK_DEBIT_PDF_VERSION = "2"
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ class SourceRow:
|
|||||||
row_ref: str
|
row_ref: str
|
||||||
warnings: tuple[str, ...] = ()
|
warnings: tuple[str, ...] = ()
|
||||||
bank_status: str = BANK_STATUS_POSTED
|
bank_status: str = BANK_STATUS_POSTED
|
||||||
|
savings_transfer: bool = False
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if self.amount_kopecks < 0:
|
if self.amount_kopecks < 0:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Annotated, Any
|
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 pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from app.dependencies import Context, Csrf, Db
|
from app.dependencies import Context, Csrf, Db
|
||||||
@@ -41,7 +41,7 @@ class HoldResolveBody(BaseModel):
|
|||||||
account_id: uuid.UUID | None = Field(default=None, alias="accountId")
|
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 {
|
return {
|
||||||
"id": document.id,
|
"id": document.id,
|
||||||
"fileName": document.original_filename,
|
"fileName": document.original_filename,
|
||||||
@@ -52,14 +52,33 @@ def summary(document: Any) -> dict:
|
|||||||
"periodFrom": None,
|
"periodFrom": None,
|
||||||
"periodTo": None,
|
"periodTo": None,
|
||||||
"needsAccountMapping": document.needs_account_mapping,
|
"needsAccountMapping": document.needs_account_mapping,
|
||||||
|
"unresolvedHoldCount": unresolved_hold_count,
|
||||||
"errorCode": document.error_code,
|
"errorCode": document.error_code,
|
||||||
"createdAt": document.created_at,
|
"createdAt": document.created_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def imports(db: Db, context: Context) -> list[dict]:
|
def imports(
|
||||||
return [summary(item) for item in service.list_documents(db, space_id=context.space.id)]
|
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)
|
@router.post("", status_code=202)
|
||||||
@@ -106,7 +125,12 @@ async def upload(
|
|||||||
def detail(document_id: uuid.UUID, db: Db, context: Context) -> dict:
|
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)
|
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)
|
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 {}
|
reconciliation = raw.get("reconciliation") or {}
|
||||||
rows = []
|
rows = []
|
||||||
pending_candidate_row_ids: set[uuid.UUID] = set()
|
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),
|
"discard": sum(row["decision"] == "discard" for row in rows),
|
||||||
"duplicate": sum(row["decision"] == "duplicate" for row in rows),
|
"duplicate": sum(row["decision"] == "duplicate" for row in rows),
|
||||||
"transfer": sum(row["decision"] == "transfer" 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),
|
"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,
|
document_id=document_id,
|
||||||
**body.model_dump(),
|
**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}")
|
@router.patch("/{document_id}/rows/{row_id}")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from datetime import date, timedelta
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.audit import service as audit
|
from app.audit import service as audit
|
||||||
@@ -164,6 +165,7 @@ def _source_from_row(
|
|||||||
or row.direction not in {"in", "out"}
|
or row.direction not in {"in", "out"}
|
||||||
),
|
),
|
||||||
record_fp=record_fingerprint(row.occurred_on, abs(row.amount_kopecks), row.description),
|
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:
|
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)
|
document = _document(db, space_id=space_id, document_id=document_id)
|
||||||
records = []
|
records = []
|
||||||
@@ -622,8 +690,17 @@ def decide_source_record(
|
|||||||
)
|
)
|
||||||
if source is None:
|
if source is None:
|
||||||
raise not_found("IMPORT_SOURCE_RECORD_NOT_FOUND", "Import row not found")
|
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")
|
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()
|
candidates = db.query(DedupCandidate).filter_by(source_record_id=source.id).all()
|
||||||
matching = [item for item in candidates if item.candidate_kind == decision]
|
matching = [item for item in candidates if item.candidate_kind == decision]
|
||||||
if decision == "duplicate" and duplicate_operation_id is None and len(matching) == 1:
|
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()
|
account = db.query(Account).filter_by(id=account_id, space_id=space_id).one_or_none()
|
||||||
if account is None:
|
if account is None:
|
||||||
raise not_found("LEDGER_ACCOUNT_NOT_FOUND", "Account not found")
|
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:
|
if confirmed_without_full_reconciliation and not reason:
|
||||||
raise conflict("IMPORT_RECONCILIATION_REASON_REQUIRED", "A confirmation reason is required")
|
raise conflict("IMPORT_RECONCILIATION_REASON_REQUIRED", "A confirmation reason is required")
|
||||||
document.account_id = account.id
|
document.account_id = account.id
|
||||||
@@ -988,6 +1070,39 @@ def confirm_document(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
continue
|
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:
|
if source.review_decision == "duplicate" and source.duplicate_operation_id:
|
||||||
duplicate_operation = (
|
duplicate_operation = (
|
||||||
db.query(Operation)
|
db.query(Operation)
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ from sqlalchemy.orm import Mapped, mapped_column
|
|||||||
|
|
||||||
from app.db.base import Base, PkMixin, TimestampMixin
|
from app.db.base import Base, PkMixin, TimestampMixin
|
||||||
|
|
||||||
ACCOUNT_KINDS = ("cash", "debit", "credit")
|
ACCOUNT_KINDS = ("cash", "debit", "credit", "savings")
|
||||||
ACCOUNT_STATUSES = ("active", "archived")
|
ACCOUNT_STATUSES = ("active", "archived")
|
||||||
OPERATION_TYPES = ("expense", "income", "transfer", "refund")
|
OPERATION_TYPES = ("expense", "income", "transfer", "refund", "adjustment")
|
||||||
OPERATION_STATUSES = ("confirmed", "voided")
|
OPERATION_STATUSES = ("confirmed", "voided")
|
||||||
|
|
||||||
|
|
||||||
@@ -28,10 +28,13 @@ class Account(Base, PkMixin, TimestampMixin):
|
|||||||
|
|
||||||
__tablename__ = "accounts"
|
__tablename__ = "accounts"
|
||||||
__table_args__ = (
|
__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("status IN ('active', 'archived')", name="status_valid"),
|
||||||
CheckConstraint("currency = 'RUB'", name="currency_rub_only"),
|
CheckConstraint("currency = 'RUB'", name="currency_rub_only"),
|
||||||
UniqueConstraint("space_id", "id", name="uq_accounts_space_id"),
|
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(
|
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)
|
bank_code: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
masked_identifier: 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)
|
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")
|
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
||||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
|
||||||
@@ -56,7 +60,10 @@ class Operation(Base, PkMixin, TimestampMixin):
|
|||||||
|
|
||||||
__tablename__ = "operations"
|
__tablename__ = "operations"
|
||||||
__table_args__ = (
|
__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("status IN ('confirmed', 'voided')", name="status_valid"),
|
||||||
CheckConstraint("amount_kopecks > 0", name="amount_positive"),
|
CheckConstraint("amount_kopecks > 0", name="amount_positive"),
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
@@ -114,6 +121,8 @@ class Operation(Base, PkMixin, TimestampMixin):
|
|||||||
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
void_reason: 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)
|
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(
|
created_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
@@ -168,6 +177,9 @@ class Category(Base, PkMixin, TimestampMixin):
|
|||||||
name="fk_categories_linked_account_tenant",
|
name="fk_categories_linked_account_tenant",
|
||||||
),
|
),
|
||||||
UniqueConstraint("space_id", "id", name="uq_categories_space_id"),
|
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(
|
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ class OperationPatchBody(BaseModel):
|
|||||||
category_id: uuid.UUID | None = Field(default=None, alias="categoryId")
|
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:
|
def account_json(db: Db, account: Any) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": account.id,
|
"id": account.id,
|
||||||
@@ -62,6 +70,8 @@ def account_json(db: Db, account: Any) -> dict:
|
|||||||
"openedOn": account.opened_on,
|
"openedOn": account.opened_on,
|
||||||
"bankCode": account.bank_code,
|
"bankCode": account.bank_code,
|
||||||
"maskedNumber": account.masked_identifier,
|
"maskedNumber": account.masked_identifier,
|
||||||
|
"systemKey": account.system_key,
|
||||||
|
"nameKey": f"account.{account.system_key}" if account.system_key else None,
|
||||||
"status": account.status,
|
"status": account.status,
|
||||||
"balanceKopecks": service.account_balance(db, account),
|
"balanceKopecks": service.account_balance(db, account),
|
||||||
"version": account.version,
|
"version": account.version,
|
||||||
@@ -91,6 +101,7 @@ def operation_json(db: Db, operation: Any) -> dict:
|
|||||||
"postedOn": operation.posted_on,
|
"postedOn": operation.posted_on,
|
||||||
"description": operation.description,
|
"description": operation.description,
|
||||||
"voidReason": operation.void_reason,
|
"voidReason": operation.void_reason,
|
||||||
|
"convertedFromExpense": operation.converted_from_expense,
|
||||||
"version": operation.version,
|
"version": operation.version,
|
||||||
"createdAt": operation.created_at,
|
"createdAt": operation.created_at,
|
||||||
}
|
}
|
||||||
@@ -276,3 +287,46 @@ def patch_operation(
|
|||||||
category_id=body.category_id,
|
category_id=body.category_id,
|
||||||
)
|
)
|
||||||
return operation_json(db, operation)
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -48,6 +48,15 @@ def _require_active_account(db: Session, *, space_id: uuid.UUID, account_id: uui
|
|||||||
return account
|
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:
|
def _validate_category(db: Session, *, space_id: uuid.UUID, category_id: uuid.UUID | None) -> None:
|
||||||
if category_id is None:
|
if category_id is None:
|
||||||
return
|
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")
|
raise not_found("BUDGET_CATEGORY_NOT_FOUND", "Category not found")
|
||||||
if category.archived:
|
if category.archived:
|
||||||
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is 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:
|
def account_balance(db: Session, account: Account) -> int:
|
||||||
@@ -155,6 +169,7 @@ def create_expense(
|
|||||||
if existing is not None:
|
if existing is not None:
|
||||||
return existing
|
return existing
|
||||||
account = _require_active_account(db, space_id=space_id, account_id=account_id)
|
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)
|
_validate_category(db, space_id=space_id, category_id=category_id)
|
||||||
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
|
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
|
||||||
operation = Operation(
|
operation = Operation(
|
||||||
@@ -210,6 +225,7 @@ def create_income(
|
|||||||
if existing is not None:
|
if existing is not None:
|
||||||
return existing
|
return existing
|
||||||
account = _require_active_account(db, space_id=space_id, account_id=account_id)
|
account = _require_active_account(db, space_id=space_id, account_id=account_id)
|
||||||
|
_reject_system_account(account)
|
||||||
if income_source_id is not None:
|
if income_source_id is not None:
|
||||||
categorization.get_income_source(db, space_id=space_id, income_source_id=income_source_id)
|
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)
|
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)
|
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)
|
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)
|
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(
|
operation = Operation(
|
||||||
space_id=space_id,
|
space_id=space_id,
|
||||||
type="transfer",
|
type="transfer",
|
||||||
amount_kopecks=amount_kopecks,
|
amount_kopecks=amount_kopecks,
|
||||||
account_id=source.id,
|
account_id=source.id,
|
||||||
counter_account_id=target.id,
|
counter_account_id=target.id,
|
||||||
|
category_id=category.id if category is not None else None,
|
||||||
occurred_on=occurred_on,
|
occurred_on=occurred_on,
|
||||||
posted_on=posted_on,
|
posted_on=posted_on,
|
||||||
description=description,
|
description=description,
|
||||||
@@ -463,6 +483,8 @@ def archive_account(
|
|||||||
db: Session, *, space_id: uuid.UUID, user: User, account_id: uuid.UUID
|
db: Session, *, space_id: uuid.UUID, user: User, account_id: uuid.UUID
|
||||||
) -> Account:
|
) -> Account:
|
||||||
account = get_account(db, space_id=space_id, account_id=account_id)
|
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":
|
if account.status == "archived":
|
||||||
return account
|
return account
|
||||||
if account_balance(db, account) != 0:
|
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:
|
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)
|
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 = (
|
used = (
|
||||||
db.query(Operation.id)
|
db.query(Operation.id)
|
||||||
.filter((Operation.account_id == account.id) | (Operation.counter_account_id == account.id))
|
.filter((Operation.account_id == account.id) | (Operation.counter_account_id == account.id))
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ def create_space(db: Session, *, user: User, name: str) -> FinancialSpace:
|
|||||||
db.flush()
|
db.flush()
|
||||||
set_space_context(db, space.id)
|
set_space_context(db, space.id)
|
||||||
db.add(Membership(space_id=space.id, user_id=user.id, role="owner"))
|
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)
|
audit.record(db, event_type="space.created", space_id=space.id, user_id=user.id)
|
||||||
return space
|
return space
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ from openpyxl import Workbook
|
|||||||
from app.errors import AppError
|
from app.errors import AppError
|
||||||
from app.imports.adapters import ADAPTERS, detect_adapter, magic_kind
|
from app.imports.adapters import ADAPTERS, detect_adapter, magic_kind
|
||||||
from app.imports.adapters import alfa_xlsx as alfa_module
|
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 (
|
from app.imports.adapters.base import (
|
||||||
ALFA_DEBIT_XLSX_VERSION,
|
ALFA_DEBIT_XLSX_VERSION,
|
||||||
BANK_STATUS_HOLD,
|
BANK_STATUS_HOLD,
|
||||||
@@ -89,7 +93,7 @@ class TestBaseContract:
|
|||||||
|
|
||||||
def test_per_contract_parser_versions(self) -> None:
|
def test_per_contract_parser_versions(self) -> None:
|
||||||
assert PARSER_VERSION == "1"
|
assert PARSER_VERSION == "1"
|
||||||
assert ALFA_DEBIT_XLSX_VERSION == "2"
|
assert ALFA_DEBIT_XLSX_VERSION == "3"
|
||||||
assert OZON_DEBIT_PDF_VERSION == "2"
|
assert OZON_DEBIT_PDF_VERSION == "2"
|
||||||
assert ROCKETBANK_DEBIT_PDF_VERSION == "2"
|
assert ROCKETBANK_DEBIT_PDF_VERSION == "2"
|
||||||
assert AlfaDebitXlsxAdapter.parser_version == ALFA_DEBIT_XLSX_VERSION
|
assert AlfaDebitXlsxAdapter.parser_version == ALFA_DEBIT_XLSX_VERSION
|
||||||
@@ -472,6 +476,122 @@ class TestAlfaDebitContract:
|
|||||||
assert "quota" in exc.value.message
|
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]]]]:
|
def ozon_extracted() -> tuple[list[str], list[list[list[object]]]]:
|
||||||
pages_text = [
|
pages_text = [
|
||||||
"\n".join(
|
"\n".join(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -10,10 +10,13 @@ The product needs trustworthy account balances including credit-card debt, zero-
|
|||||||
|
|
||||||
## Decision
|
## 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
## Consequences
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
|
packages:
|
||||||
|
- "."
|
||||||
|
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild@0.25.12: true
|
esbuild@0.25.12: true
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ import { apiClient } from './client'
|
|||||||
import type {
|
import type {
|
||||||
AcceptInvitePayload,
|
AcceptInvitePayload,
|
||||||
Account,
|
Account,
|
||||||
|
AdjustBalancePayload,
|
||||||
|
AdjustBalanceResult,
|
||||||
BalancesReport,
|
BalancesReport,
|
||||||
BudgetCategory,
|
BudgetCategory,
|
||||||
BudgetSummary,
|
BudgetSummary,
|
||||||
CashFlowMonth,
|
CashFlowMonth,
|
||||||
|
CategoryDefinition,
|
||||||
CategorySpendItem,
|
CategorySpendItem,
|
||||||
ConfirmImportPayload,
|
ConfirmImportPayload,
|
||||||
CreateAccountPayload,
|
CreateAccountPayload,
|
||||||
|
CreateCategoryPayload,
|
||||||
CreateOperationPayload,
|
CreateOperationPayload,
|
||||||
ExportInfo,
|
ExportInfo,
|
||||||
ImportDetail,
|
ImportDetail,
|
||||||
@@ -17,8 +21,10 @@ import type {
|
|||||||
Operation,
|
Operation,
|
||||||
OperationFilters,
|
OperationFilters,
|
||||||
Page,
|
Page,
|
||||||
|
PatchCategoryDefinitionPayload,
|
||||||
PlanFactReport,
|
PlanFactReport,
|
||||||
PasswordResetPayload,
|
PasswordResetPayload,
|
||||||
|
ResolveHoldPayload,
|
||||||
SaveRowDecisionPayload,
|
SaveRowDecisionPayload,
|
||||||
SpaceDeletionStatus,
|
SpaceDeletionStatus,
|
||||||
User,
|
User,
|
||||||
@@ -42,6 +48,8 @@ export const api = {
|
|||||||
apiClient.post<Account>('/accounts', { json: payload }),
|
apiClient.post<Account>('/accounts', { json: payload }),
|
||||||
archive: (id: string) =>
|
archive: (id: string) =>
|
||||||
apiClient.patch<Account>(`/accounts/${id}`, { json: { status: 'archived' } }),
|
apiClient.patch<Account>(`/accounts/${id}`, { json: { status: 'archived' } }),
|
||||||
|
adjustBalance: (id: string, payload: AdjustBalancePayload) =>
|
||||||
|
apiClient.post<AdjustBalanceResult>(`/accounts/${id}/adjust-balance`, { json: payload }),
|
||||||
},
|
},
|
||||||
operations: {
|
operations: {
|
||||||
list: (filters: OperationFilters) =>
|
list: (filters: OperationFilters) =>
|
||||||
@@ -52,6 +60,10 @@ export const api = {
|
|||||||
apiClient.post<Operation>(`/operations/${id}/void`, { json: { reason } }),
|
apiClient.post<Operation>(`/operations/${id}/void`, { json: { reason } }),
|
||||||
categorize: (id: string, categoryId: string) =>
|
categorize: (id: string, categoryId: string) =>
|
||||||
apiClient.patch<Operation>(`/operations/${id}`, { json: { categoryId } }),
|
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: {
|
budget: {
|
||||||
summary: (month: string) =>
|
summary: (month: string) =>
|
||||||
@@ -70,9 +82,18 @@ export const api = {
|
|||||||
reopen: (month: string) => apiClient.post<BudgetSummary>('/budget/reopen', { json: { month } }),
|
reopen: (month: string) => apiClient.post<BudgetSummary>('/budget/reopen', { json: { month } }),
|
||||||
copyPrevious: (month: string) =>
|
copyPrevious: (month: string) =>
|
||||||
apiClient.post<BudgetSummary>('/budget/copy-previous', { json: { month } }),
|
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: {
|
imports: {
|
||||||
list: () => apiClient.get<ImportSummary[]>('/imports'),
|
list: (month?: string) =>
|
||||||
|
apiClient.get<ImportSummary[]>('/imports', { query: { month } }),
|
||||||
get: (id: string) => apiClient.get<ImportDetail>(`/imports/${id}`),
|
get: (id: string) => apiClient.get<ImportDetail>(`/imports/${id}`),
|
||||||
upload: (file: File, accountId: string) => {
|
upload: (file: File, accountId: string) => {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
@@ -82,6 +103,8 @@ export const api = {
|
|||||||
},
|
},
|
||||||
saveRowDecision: (id: string, rowId: string, payload: SaveRowDecisionPayload) =>
|
saveRowDecision: (id: string, rowId: string, payload: SaveRowDecisionPayload) =>
|
||||||
apiClient.patch<ImportDetail>(`/imports/${id}/rows/${rowId}`, { json: payload }),
|
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) =>
|
confirm: (id: string, payload: ConfirmImportPayload) =>
|
||||||
apiClient.post<ImportDetail>(`/imports/${id}/confirm`, { json: payload }),
|
apiClient.post<ImportDetail>(`/imports/${id}/confirm`, { json: payload }),
|
||||||
reparse: (id: string) => apiClient.post<ImportDetail>(`/imports/${id}/reparse`),
|
reparse: (id: string) => apiClient.post<ImportDetail>(`/imports/${id}/reparse`),
|
||||||
|
|||||||
+555
-8
@@ -89,6 +89,40 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/v1/auth/me": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -140,6 +174,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/v1/spaces/current/deletion": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -264,6 +315,57 @@ export interface paths {
|
|||||||
patch: operations["patch_operation_api_v1_operations__operation_id__patch"];
|
patch: operations["patch_operation_api_v1_operations__operation_id__patch"];
|
||||||
trace?: never;
|
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": {
|
"/api/v1/budget/summary": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -299,6 +401,40 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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}": {
|
"/api/v1/budget/categories/{category_id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -555,6 +691,23 @@ export interface paths {
|
|||||||
patch: operations["decide_row_api_v1_imports__document_id__rows__row_id__patch"];
|
patch: operations["decide_row_api_v1_imports__document_id__rows__row_id__patch"];
|
||||||
trace?: never;
|
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": {
|
"/api/v1/imports/{document_id}/confirm": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -732,10 +885,10 @@ export interface paths {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
get?: never;
|
|
||||||
put?: never;
|
|
||||||
/** Download */
|
/** 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;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
@@ -790,6 +943,18 @@ export interface components {
|
|||||||
/** Maskednumber */
|
/** Maskednumber */
|
||||||
maskedNumber?: string | null;
|
maskedNumber?: string | null;
|
||||||
};
|
};
|
||||||
|
/** AdjustBalanceBody */
|
||||||
|
AdjustBalanceBody: {
|
||||||
|
/** Targetbalancekopecks */
|
||||||
|
targetBalanceKopecks: number;
|
||||||
|
/**
|
||||||
|
* Occurredon
|
||||||
|
* Format: date
|
||||||
|
*/
|
||||||
|
occurredOn: string;
|
||||||
|
/** Reason */
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
/** AssignmentItem */
|
/** AssignmentItem */
|
||||||
AssignmentItem: {
|
AssignmentItem: {
|
||||||
/**
|
/**
|
||||||
@@ -823,6 +988,13 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
kind: "monthly" | "savings_envelope";
|
kind: "monthly" | "savings_envelope";
|
||||||
};
|
};
|
||||||
|
/** CategoryDefinitionPatchBody */
|
||||||
|
CategoryDefinitionPatchBody: {
|
||||||
|
/** Name */
|
||||||
|
name?: string | null;
|
||||||
|
/** Archived */
|
||||||
|
archived?: boolean | null;
|
||||||
|
};
|
||||||
/** CategoryFundingBody */
|
/** CategoryFundingBody */
|
||||||
CategoryFundingBody: {
|
CategoryFundingBody: {
|
||||||
/** Month */
|
/** Month */
|
||||||
@@ -889,6 +1061,15 @@ export interface components {
|
|||||||
/** Detail */
|
/** Detail */
|
||||||
detail?: components["schemas"]["ValidationError"][];
|
detail?: components["schemas"]["ValidationError"][];
|
||||||
};
|
};
|
||||||
|
/** HoldResolveBody */
|
||||||
|
HoldResolveBody: {
|
||||||
|
/** Action */
|
||||||
|
action: string;
|
||||||
|
/** Reason */
|
||||||
|
reason: string;
|
||||||
|
/** Accountid */
|
||||||
|
accountId?: string | null;
|
||||||
|
};
|
||||||
/** InviteBody */
|
/** InviteBody */
|
||||||
InviteBody: {
|
InviteBody: {
|
||||||
/**
|
/**
|
||||||
@@ -907,6 +1088,14 @@ export interface components {
|
|||||||
/** Password */
|
/** Password */
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
/** MemberInviteBody */
|
||||||
|
MemberInviteBody: {
|
||||||
|
/**
|
||||||
|
* Email
|
||||||
|
* Format: email
|
||||||
|
*/
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
/** MonthBody */
|
/** MonthBody */
|
||||||
MonthBody: {
|
MonthBody: {
|
||||||
/** Month */
|
/** Month */
|
||||||
@@ -957,6 +1146,21 @@ export interface components {
|
|||||||
/** Plannedkopecks */
|
/** Plannedkopecks */
|
||||||
plannedKopecks: number;
|
plannedKopecks: number;
|
||||||
};
|
};
|
||||||
|
/** ResetBody */
|
||||||
|
ResetBody: {
|
||||||
|
/** Token */
|
||||||
|
token: string;
|
||||||
|
/** Password */
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
/** ResetRequestBody */
|
||||||
|
ResetRequestBody: {
|
||||||
|
/**
|
||||||
|
* Email
|
||||||
|
* Format: email
|
||||||
|
*/
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
/** ReviewBody */
|
/** ReviewBody */
|
||||||
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: {
|
me_api_v1_auth_me_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
deletion_status_api_v1_spaces_current_deletion_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1541,6 +1854,7 @@ export interface operations {
|
|||||||
occurredFrom?: string | null;
|
occurredFrom?: string | null;
|
||||||
occurredTo?: string | null;
|
occurredTo?: string | null;
|
||||||
search?: string | null;
|
search?: string | null;
|
||||||
|
uncategorized?: boolean;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
cursor?: string | null;
|
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: {
|
summary_api_v1_budget_summary_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
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: {
|
update_plan_api_v1_budget_categories__category_id__patch: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2246,7 +2751,9 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
imports_api_v1_imports_get: {
|
imports_api_v1_imports_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: {
|
||||||
|
month?: string | null;
|
||||||
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
cookie?: {
|
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: {
|
confirm_api_v1_imports__document_id__confirm_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: {
|
header?: never;
|
||||||
"X-CSRF-Token"?: string | null;
|
|
||||||
};
|
|
||||||
path: {
|
path: {
|
||||||
export_id: string;
|
export_id: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ export const queryKeys = {
|
|||||||
operations: (filters: OperationFilters) => [SPACE_ROOT, 'operations', filters] as const,
|
operations: (filters: OperationFilters) => [SPACE_ROOT, 'operations', filters] as const,
|
||||||
budgetSummary: (month: string) => [SPACE_ROOT, 'budget', 'summary', month] as const,
|
budgetSummary: (month: string) => [SPACE_ROOT, 'budget', 'summary', month] as const,
|
||||||
budgetCategories: (month: string) => [SPACE_ROOT, 'budget', 'categories', 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,
|
import: (id: string) => [SPACE_ROOT, 'imports', id] as const,
|
||||||
|
budgetCategoryDefinitions: () => [SPACE_ROOT, 'budget', 'category-definitions'] as const,
|
||||||
analyticsCategorySpend: (from: string, to: string) =>
|
analyticsCategorySpend: (from: string, to: string) =>
|
||||||
[SPACE_ROOT, 'analytics', 'category-spend', from, to] as const,
|
[SPACE_ROOT, 'analytics', 'category-spend', from, to] as const,
|
||||||
analyticsPlanFact: (month: string) => [SPACE_ROOT, 'analytics', 'plan-fact', month] as const,
|
analyticsPlanFact: (month: string) => [SPACE_ROOT, 'analytics', 'plan-fact', month] as const,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface User {
|
|||||||
timezone: string
|
timezone: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AccountType = 'cash' | 'debit' | 'credit'
|
export type AccountType = 'cash' | 'debit' | 'credit' | 'savings'
|
||||||
export type AccountStatus = 'active' | 'archived'
|
export type AccountStatus = 'active' | 'archived'
|
||||||
|
|
||||||
export interface Account {
|
export interface Account {
|
||||||
@@ -20,9 +20,15 @@ export interface Account {
|
|||||||
maskedNumber: string | null
|
maskedNumber: string | null
|
||||||
openedOn: string
|
openedOn: string
|
||||||
currency: 'RUB'
|
currency: 'RUB'
|
||||||
|
systemKey: string | null
|
||||||
|
nameKey: string | null
|
||||||
balanceKopecks: number
|
balanceKopecks: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isSystemAccount(account: Account): boolean {
|
||||||
|
return account.systemKey !== null
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateAccountPayload {
|
export interface CreateAccountPayload {
|
||||||
name: string
|
name: string
|
||||||
type: AccountType
|
type: AccountType
|
||||||
@@ -32,7 +38,7 @@ export interface CreateAccountPayload {
|
|||||||
maskedNumber?: string
|
maskedNumber?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OperationKind = 'expense' | 'income' | 'transfer' | 'refund'
|
export type OperationKind = 'expense' | 'income' | 'transfer' | 'refund' | 'adjustment'
|
||||||
export type OperationStatus = 'confirmed' | 'voided'
|
export type OperationStatus = 'confirmed' | 'voided'
|
||||||
|
|
||||||
export interface Operation {
|
export interface Operation {
|
||||||
@@ -51,10 +57,22 @@ export interface Operation {
|
|||||||
incomeSourceName: string | null
|
incomeSourceName: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
parentOperationId: string | null
|
parentOperationId: string | null
|
||||||
|
convertedFromExpense: boolean
|
||||||
version: number
|
version: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdjustBalancePayload {
|
||||||
|
targetBalanceKopecks: number
|
||||||
|
occurredOn: string
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdjustBalanceResult extends Operation {
|
||||||
|
oldBalanceKopecks: number
|
||||||
|
newBalanceKopecks: number
|
||||||
|
}
|
||||||
|
|
||||||
export type CreateOperationPayload =
|
export type CreateOperationPayload =
|
||||||
| {
|
| {
|
||||||
kind: 'expense'
|
kind: 'expense'
|
||||||
@@ -131,11 +149,25 @@ export interface BudgetWarning {
|
|||||||
message?: string
|
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
|
code: string
|
||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BudgetBlocker = CountBlocker | CategoriesBlocker | UnknownBlocker
|
||||||
|
|
||||||
export type ExpectedIncomeStatus = 'expected' | 'partially_matched' | 'matched'
|
export type ExpectedIncomeStatus = 'expected' | 'partially_matched' | 'matched'
|
||||||
|
|
||||||
export interface ExpectedIncome {
|
export interface ExpectedIncome {
|
||||||
@@ -162,12 +194,14 @@ export interface BudgetSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CategoryKind = 'monthly' | 'savings_envelope' | 'credit_reserve'
|
export type CategoryKind = 'monthly' | 'savings_envelope' | 'credit_reserve'
|
||||||
|
export type CreatableCategoryKind = 'monthly' | 'savings_envelope'
|
||||||
|
|
||||||
export interface BudgetCategory {
|
export interface BudgetCategory {
|
||||||
id: string
|
id: string
|
||||||
name: string | null
|
name: string | null
|
||||||
nameKey: string | null
|
nameKey: string | null
|
||||||
kind: CategoryKind
|
kind: CategoryKind
|
||||||
|
system: boolean
|
||||||
archived: boolean
|
archived: boolean
|
||||||
creditAccountId: string | null
|
creditAccountId: string | null
|
||||||
plannedKopecks: number
|
plannedKopecks: number
|
||||||
@@ -176,6 +210,26 @@ export interface BudgetCategory {
|
|||||||
availableKopecks: number
|
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 =
|
export type ImportStatus =
|
||||||
| 'uploaded'
|
| 'uploaded'
|
||||||
| 'queued'
|
| 'queued'
|
||||||
@@ -194,6 +248,7 @@ export interface ImportSummary {
|
|||||||
accountName: string | null
|
accountName: string | null
|
||||||
periodFrom: string | null
|
periodFrom: string | null
|
||||||
periodTo: string | null
|
periodTo: string | null
|
||||||
|
unresolvedHoldCount: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +268,20 @@ export interface Reconciliation {
|
|||||||
reason: string | null
|
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 {
|
export interface ImportRow {
|
||||||
id: string
|
id: string
|
||||||
@@ -221,11 +289,13 @@ export interface ImportRow {
|
|||||||
occurredOn: string
|
occurredOn: string
|
||||||
amountKopecks: number
|
amountKopecks: number
|
||||||
direction: 'in' | 'out'
|
direction: 'in' | 'out'
|
||||||
|
bankStatus: RowBankStatus
|
||||||
description: string | null
|
description: string | null
|
||||||
payeeHint: string | null
|
payeeHint: string | null
|
||||||
warnings: string[]
|
warnings: string[]
|
||||||
decision: RowDecision
|
decision: RowDecision
|
||||||
duplicateOfOperationId: string | null
|
duplicateOfOperationId: string | null
|
||||||
|
holdResolution: HoldResolution | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportDetail extends ImportSummary {
|
export interface ImportDetail extends ImportSummary {
|
||||||
@@ -240,6 +310,8 @@ export interface ImportDetail extends ImportSummary {
|
|||||||
discard: number
|
discard: number
|
||||||
duplicate: number
|
duplicate: number
|
||||||
transfer: number
|
transfer: number
|
||||||
|
savingsTransfer: number
|
||||||
|
pending: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +320,12 @@ export interface SaveRowDecisionPayload {
|
|||||||
duplicateOfOperationId?: string
|
duplicateOfOperationId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResolveHoldPayload {
|
||||||
|
action: HoldResolutionAction
|
||||||
|
reason: string
|
||||||
|
accountId?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConfirmImportPayload {
|
export interface ConfirmImportPayload {
|
||||||
confirmedWithoutFullReconciliation?: boolean
|
confirmedWithoutFullReconciliation?: boolean
|
||||||
reason?: string
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ const NAV_ITEMS = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
const MOBILE_NAV_ITEMS = NAV_ITEMS.filter((item) =>
|
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() {
|
export function AppLayout() {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { api } from '@/api/endpoints'
|
import { api } from '@/api/endpoints'
|
||||||
import { invalidationRoots, queryKeys } from '@/api/queryKeys'
|
import { invalidationRoots, queryKeys } from '@/api/queryKeys'
|
||||||
import type { CreateAccountPayload } from '@/api/types'
|
import type { AdjustBalancePayload, CreateAccountPayload } from '@/api/types'
|
||||||
|
import { isSystemAccount } from '@/api/types'
|
||||||
import { useToast } from '@/components/ui/Toaster'
|
import { useToast } from '@/components/ui/Toaster'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { describeApiError } from '@/lib/apiErrors'
|
import { describeApiError } from '@/lib/apiErrors'
|
||||||
@@ -15,6 +16,13 @@ export function useActiveAccounts() {
|
|||||||
return { ...accounts, data: accounts.data?.filter((account) => account.status === 'active') }
|
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() {
|
export function useCreateAccount() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -44,3 +52,21 @@ export function useArchiveAccount() {
|
|||||||
onError: (error) => toast.push('error', describeApiError(error, t)),
|
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)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { api } from '@/api/endpoints'
|
import { api } from '@/api/endpoints'
|
||||||
import { invalidationRoots, queryKeys } from '@/api/queryKeys'
|
import { invalidationRoots, queryKeys } from '@/api/queryKeys'
|
||||||
|
import type { CreateCategoryPayload, PatchCategoryDefinitionPayload } from '@/api/types'
|
||||||
import { useToast } from '@/components/ui/Toaster'
|
import { useToast } from '@/components/ui/Toaster'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { describeApiError } from '@/lib/apiErrors'
|
import { describeApiError } from '@/lib/apiErrors'
|
||||||
@@ -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() {
|
function useBudgetErrorToaster() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { invalidationRoots, queryKeys } from '@/api/queryKeys'
|
|||||||
import type {
|
import type {
|
||||||
ConfirmImportPayload,
|
ConfirmImportPayload,
|
||||||
ImportStatus,
|
ImportStatus,
|
||||||
|
ResolveHoldPayload,
|
||||||
SaveRowDecisionPayload,
|
SaveRowDecisionPayload,
|
||||||
} from '@/api/types'
|
} from '@/api/types'
|
||||||
import { useToast } from '@/components/ui/Toaster'
|
import { useToast } from '@/components/ui/Toaster'
|
||||||
@@ -11,10 +12,10 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { describeApiError } from '@/lib/apiErrors'
|
import { describeApiError } from '@/lib/apiErrors'
|
||||||
import { importListPollInterval, importPollInterval } from '@/lib/importStatus'
|
import { importListPollInterval, importPollInterval } from '@/lib/importStatus'
|
||||||
|
|
||||||
export function useImports() {
|
export function useImports(month?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.imports(),
|
queryKey: queryKeys.imports(month),
|
||||||
queryFn: api.imports.list,
|
queryFn: () => api.imports.list(month),
|
||||||
refetchInterval: (query) => {
|
refetchInterval: (query) => {
|
||||||
const statuses = query.state.data?.map((item) => item.status as ImportStatus)
|
const statuses = query.state.data?.map((item) => item.status as ImportStatus)
|
||||||
return importListPollInterval(statuses)
|
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) {
|
export function useConfirmImport(importId: string) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|||||||
@@ -55,3 +55,17 @@ export function useCategorizeOperation() {
|
|||||||
'toast.categoryAssigned',
|
'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',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,7 +88,23 @@
|
|||||||
"validation_error": "Please check the form fields.",
|
"validation_error": "Please check the form fields.",
|
||||||
"conflict": "Data was changed elsewhere. Refresh and try again.",
|
"conflict": "Data was changed elsewhere. Refresh and try again.",
|
||||||
"rate_limited": "Too many attempts. Please wait a moment.",
|
"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": {
|
"auth": {
|
||||||
"loginTitle": "Sign in",
|
"loginTitle": "Sign in",
|
||||||
@@ -120,7 +136,7 @@
|
|||||||
},
|
},
|
||||||
"accounts": {
|
"accounts": {
|
||||||
"title": "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",
|
"add": "Add account",
|
||||||
"empty": "No accounts yet. Create your first account to start tracking.",
|
"empty": "No accounts yet. Create your first account to start tracking.",
|
||||||
"formTitle": "New account",
|
"formTitle": "New account",
|
||||||
@@ -128,6 +144,8 @@
|
|||||||
"type.cash": "Cash",
|
"type.cash": "Cash",
|
||||||
"type.debit": "Debit",
|
"type.debit": "Debit",
|
||||||
"type.credit": "Credit card",
|
"type.credit": "Credit card",
|
||||||
|
"type.savings": "Savings",
|
||||||
|
"systemBadge": "System",
|
||||||
"bank": "Bank",
|
"bank": "Bank",
|
||||||
"bankHint": "Optional, e.g. “alfa”",
|
"bankHint": "Optional, e.g. “alfa”",
|
||||||
"maskedNumber": "Masked number",
|
"maskedNumber": "Masked number",
|
||||||
@@ -143,7 +161,15 @@
|
|||||||
"archive": "Archive",
|
"archive": "Archive",
|
||||||
"archiveBlockedHint": "Only an account with zero balance can be archived",
|
"archiveBlockedHint": "Only an account with zero balance can be archived",
|
||||||
"archiveConfirmTitle": "Archive account",
|
"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": {
|
"operations": {
|
||||||
"title": "Ledger",
|
"title": "Ledger",
|
||||||
@@ -162,6 +188,7 @@
|
|||||||
"kind.income": "Income",
|
"kind.income": "Income",
|
||||||
"kind.transfer": "Transfer",
|
"kind.transfer": "Transfer",
|
||||||
"kind.refund": "Refund",
|
"kind.refund": "Refund",
|
||||||
|
"kind.adjustment": "Adjustment",
|
||||||
"status.confirmed": "Confirmed",
|
"status.confirmed": "Confirmed",
|
||||||
"status.voided": "Voided",
|
"status.voided": "Voided",
|
||||||
"noCategory": "Uncategorized",
|
"noCategory": "Uncategorized",
|
||||||
@@ -183,6 +210,12 @@
|
|||||||
"voidBody": "Operation of {{date}} for {{amount}} will be marked voided.",
|
"voidBody": "Operation of {{date}} for {{amount}} will be marked voided.",
|
||||||
"voidReason": "Void reason",
|
"voidReason": "Void reason",
|
||||||
"voidReasonHint": "At least 3 characters",
|
"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",
|
"prevPage": "Back",
|
||||||
"nextPage": "Next"
|
"nextPage": "Next"
|
||||||
},
|
},
|
||||||
@@ -208,9 +241,27 @@
|
|||||||
"categories": "Categories",
|
"categories": "Categories",
|
||||||
"reserves": "Credit reserves",
|
"reserves": "Credit reserves",
|
||||||
"noCategories": "No categories this month yet. Copy the previous month or create a plan.",
|
"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.monthly": "Monthly",
|
||||||
"category.kind.savings_envelope": "Envelope",
|
"category.kind.savings_envelope": "Envelope",
|
||||||
"category.kind.credit_reserve": "Credit reserve",
|
"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}}",
|
"planTitle": "Plan: {{name}}",
|
||||||
"fundTitle": "Fund: {{name}}",
|
"fundTitle": "Fund: {{name}}",
|
||||||
"fund": "Fund",
|
"fund": "Fund",
|
||||||
@@ -223,13 +274,25 @@
|
|||||||
"income.status.partially_matched": "Partial",
|
"income.status.partially_matched": "Partial",
|
||||||
"income.status.matched": "Received",
|
"income.status.matched": "Received",
|
||||||
"blockersTitle": "Close is blocked",
|
"blockersTitle": "Close is blocked",
|
||||||
"blockers.uncategorized_expenses": "There are expenses without a category.",
|
"blockers": {
|
||||||
"blockers.negative_envelope": "A savings envelope balance is negative.",
|
"IMPORTS_UNRESOLVED": "Statement imports are unfinished: {{count}}",
|
||||||
"blockers.unresolved_imports": "There are unfinished imports.",
|
"IMPORT_UNRESOLVED_HOLDS": "Unconfirmed operations without a resolution: {{count}}",
|
||||||
"blockers.open_decisions": "There are unresolved import decisions.",
|
"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.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.underfunded_credit": "Credit card debt is not fully funded.",
|
||||||
"warnings.expected_income_variance": "Expected income deviates from actual.",
|
"warnings.expected_income_variance": "Expected income deviates from actual.",
|
||||||
|
"warnings.unknown": "The month has another issue that needs review.",
|
||||||
"close": "Close month",
|
"close": "Close month",
|
||||||
"closeConfirmTitle": "Close month",
|
"closeConfirmTitle": "Close month",
|
||||||
"closeConfirmBody": "{{month}} will be closed and monthly category leftovers will roll into the general pool.",
|
"closeConfirmBody": "{{month}} will be closed and monthly category leftovers will roll into the general pool.",
|
||||||
@@ -256,7 +319,13 @@
|
|||||||
"uploading": "Uploading…",
|
"uploading": "Uploading…",
|
||||||
"adapterHint": "Supported: Alfa debit/credit XLSX, Ozon debit/credit PDF, Rocketbank debit PDF.",
|
"adapterHint": "Supported: Alfa debit/credit XLSX, Ozon debit/credit PDF, Rocketbank debit PDF.",
|
||||||
"file": "Statement file",
|
"file": "Statement file",
|
||||||
|
"fileName": "Statement file",
|
||||||
"targetAccount": "Target account",
|
"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",
|
"unassigned": "Unassigned",
|
||||||
"empty": "No imports yet.",
|
"empty": "No imports yet.",
|
||||||
"bank": "Bank / contract",
|
"bank": "Bank / contract",
|
||||||
@@ -289,6 +358,19 @@
|
|||||||
"blockers.unresolved_duplicate": "There are unresolved duplicates.",
|
"blockers.unresolved_duplicate": "There are unresolved duplicates.",
|
||||||
"blockers.unresolved_transfer": "There are unresolved transfers.",
|
"blockers.unresolved_transfer": "There are unresolved transfers.",
|
||||||
"blockers.account_conflict": "Account fingerprint conflicts with an existing one.",
|
"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": "Warnings ({{count}})",
|
||||||
"warnings.amount_warning": "Unusual amount.",
|
"warnings.amount_warning": "Unusual amount.",
|
||||||
"warnings.date_warning": "Unusual date.",
|
"warnings.date_warning": "Unusual date.",
|
||||||
@@ -303,7 +385,7 @@
|
|||||||
"reconciliation.mismatched": "Mismatched",
|
"reconciliation.mismatched": "Mismatched",
|
||||||
"reconciliation.not_available": "Not available",
|
"reconciliation.not_available": "Not available",
|
||||||
"reconciliation.confirmed_without": "Confirmed without reconciliation",
|
"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",
|
"rowsTitle": "Statement rows",
|
||||||
"noRows": "No rows.",
|
"noRows": "No rows.",
|
||||||
"rowWarnings": "Notes",
|
"rowWarnings": "Notes",
|
||||||
@@ -318,6 +400,7 @@
|
|||||||
"decision.discard": "Discard",
|
"decision.discard": "Discard",
|
||||||
"decision.duplicate": "Duplicate",
|
"decision.duplicate": "Duplicate",
|
||||||
"decision.transfer": "Transfer",
|
"decision.transfer": "Transfer",
|
||||||
|
"decision.savings_transfer": "Savings transfer",
|
||||||
"confirm": "Apply import",
|
"confirm": "Apply import",
|
||||||
"confirmTitle": "Apply import",
|
"confirmTitle": "Apply import",
|
||||||
"confirmBody": "Operations will be created from the selected rows. Applying is atomic and can be reverted.",
|
"confirmBody": "Operations will be created from the selected rows. Applying is atomic and can be reverted.",
|
||||||
@@ -372,9 +455,16 @@
|
|||||||
"toast": {
|
"toast": {
|
||||||
"accountCreated": "Account “{{name}}” created",
|
"accountCreated": "Account “{{name}}” created",
|
||||||
"accountArchived": "Account archived",
|
"accountArchived": "Account archived",
|
||||||
|
"balanceAdjusted": "Account balance adjusted",
|
||||||
"operationCreated": "Operation created",
|
"operationCreated": "Operation created",
|
||||||
"operationVoided": "Operation voided",
|
"operationVoided": "Operation voided",
|
||||||
"categoryAssigned": "Category assigned",
|
"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",
|
"planUpdated": "Plan updated",
|
||||||
"categoryFunded": "Category funded",
|
"categoryFunded": "Category funded",
|
||||||
"monthClosed": "Month closed",
|
"monthClosed": "Month closed",
|
||||||
@@ -383,6 +473,7 @@
|
|||||||
"importUploaded": "Statement uploaded, parsing started",
|
"importUploaded": "Statement uploaded, parsing started",
|
||||||
"importConfirmed": "Import applied",
|
"importConfirmed": "Import applied",
|
||||||
"importReverted": "Import reverted",
|
"importReverted": "Import reverted",
|
||||||
|
"holdResolved": "Hold resolution saved",
|
||||||
"reparseQueued": "Reparse queued",
|
"reparseQueued": "Reparse queued",
|
||||||
"exportQueued": "Export queued",
|
"exportQueued": "Export queued",
|
||||||
"exportReady": "Export is ready to download",
|
"exportReady": "Export is ready to download",
|
||||||
@@ -394,7 +485,11 @@
|
|||||||
"subtitle": "This page does not exist. Check the address or go back to accounts.",
|
"subtitle": "This page does not exist. Check the address or go back to accounts.",
|
||||||
"back": "To accounts"
|
"back": "To accounts"
|
||||||
},
|
},
|
||||||
|
"account": {
|
||||||
|
"savings": "Savings"
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
|
"uncategorized": "Uncategorized",
|
||||||
"groceries": "Groceries",
|
"groceries": "Groceries",
|
||||||
"transport": "Transport",
|
"transport": "Transport",
|
||||||
"housing": "Housing",
|
"housing": "Housing",
|
||||||
|
|||||||
@@ -88,7 +88,23 @@
|
|||||||
"validation_error": "Проверьте правильность заполнения полей.",
|
"validation_error": "Проверьте правильность заполнения полей.",
|
||||||
"conflict": "Данные изменены кем-то другим. Обновите страницу и повторите.",
|
"conflict": "Данные изменены кем-то другим. Обновите страницу и повторите.",
|
||||||
"rate_limited": "Слишком много попыток. Подождите немного.",
|
"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": {
|
"auth": {
|
||||||
"loginTitle": "Вход",
|
"loginTitle": "Вход",
|
||||||
@@ -120,7 +136,7 @@
|
|||||||
},
|
},
|
||||||
"accounts": {
|
"accounts": {
|
||||||
"title": "Счета",
|
"title": "Счета",
|
||||||
"subtitle": "Дебетовые, наличные и кредитные счета пространства.",
|
"subtitle": "Дебетовые, наличные, кредитные и системный счёт накоплений.",
|
||||||
"add": "Добавить счёт",
|
"add": "Добавить счёт",
|
||||||
"empty": "Счетов пока нет. Создайте первый счёт, чтобы начать учёт.",
|
"empty": "Счетов пока нет. Создайте первый счёт, чтобы начать учёт.",
|
||||||
"formTitle": "Новый счёт",
|
"formTitle": "Новый счёт",
|
||||||
@@ -128,6 +144,8 @@
|
|||||||
"type.cash": "Наличные",
|
"type.cash": "Наличные",
|
||||||
"type.debit": "Дебетовый",
|
"type.debit": "Дебетовый",
|
||||||
"type.credit": "Кредитная карта",
|
"type.credit": "Кредитная карта",
|
||||||
|
"type.savings": "Накопительный",
|
||||||
|
"systemBadge": "Системный",
|
||||||
"bank": "Банк",
|
"bank": "Банк",
|
||||||
"bankHint": "Необязательно, например «alfa»",
|
"bankHint": "Необязательно, например «alfa»",
|
||||||
"maskedNumber": "Маскированный номер",
|
"maskedNumber": "Маскированный номер",
|
||||||
@@ -143,7 +161,15 @@
|
|||||||
"archive": "В архив",
|
"archive": "В архив",
|
||||||
"archiveBlockedHint": "Архивировать можно только счёт с нулевым остатком",
|
"archiveBlockedHint": "Архивировать можно только счёт с нулевым остатком",
|
||||||
"archiveConfirmTitle": "Архивировать счёт",
|
"archiveConfirmTitle": "Архивировать счёт",
|
||||||
"archiveConfirmBody": "Счёт «{{name}}» скроется из рабочих списков, но останется в истории."
|
"archiveConfirmBody": "Счёт «{{name}}» скроется из рабочих списков, но останется в истории.",
|
||||||
|
"adjust": "Скорректировать",
|
||||||
|
"adjustTitle": "Корректировка остатка",
|
||||||
|
"adjustCurrentBalance": "Текущий остаток",
|
||||||
|
"adjustTargetBalance": "Новый остаток",
|
||||||
|
"adjustDate": "Дата корректировки",
|
||||||
|
"adjustReason": "Причина",
|
||||||
|
"adjustReasonHint": "Обязательно; попадёт в журнал аудита",
|
||||||
|
"adjustNotice": "Корректировка меняет остаток счёта напрямую: это не доход и не расход. Она не попадёт в доходы, расходы, денежный поток и общий пул бюджета."
|
||||||
},
|
},
|
||||||
"operations": {
|
"operations": {
|
||||||
"title": "Гроссбух",
|
"title": "Гроссбух",
|
||||||
@@ -162,6 +188,7 @@
|
|||||||
"kind.income": "Доход",
|
"kind.income": "Доход",
|
||||||
"kind.transfer": "Перевод",
|
"kind.transfer": "Перевод",
|
||||||
"kind.refund": "Возврат",
|
"kind.refund": "Возврат",
|
||||||
|
"kind.adjustment": "Корректировка",
|
||||||
"status.confirmed": "Подтверждена",
|
"status.confirmed": "Подтверждена",
|
||||||
"status.voided": "Отменена",
|
"status.voided": "Отменена",
|
||||||
"noCategory": "Без категории",
|
"noCategory": "Без категории",
|
||||||
@@ -183,6 +210,12 @@
|
|||||||
"voidBody": "Операция от {{date}} на {{amount}} будет помечена отменённой.",
|
"voidBody": "Операция от {{date}} на {{amount}} будет помечена отменённой.",
|
||||||
"voidReason": "Причина отмены",
|
"voidReason": "Причина отмены",
|
||||||
"voidReasonHint": "Минимум 3 символа",
|
"voidReasonHint": "Минимум 3 символа",
|
||||||
|
"convert": "В накопления",
|
||||||
|
"convertTitle": "Перевести в накопления",
|
||||||
|
"convertBody": "Расход от {{date}} на {{amount}} станет переводом в «Накопления»: он уйдёт из расходов месяца, увеличит счёт «Накопления» и уменьшит общий бюджетный пул. Позже это можно вернуть.",
|
||||||
|
"revert": "Вернуть в расходы",
|
||||||
|
"revertTitle": "Вернуть в расходы",
|
||||||
|
"revertBody": "Перевод от {{date}} на {{amount}} снова станет обычным расходом исходной категории; общий бюджетный пул пересчитается.",
|
||||||
"prevPage": "Назад",
|
"prevPage": "Назад",
|
||||||
"nextPage": "Дальше"
|
"nextPage": "Дальше"
|
||||||
},
|
},
|
||||||
@@ -208,9 +241,27 @@
|
|||||||
"categories": "Категории",
|
"categories": "Категории",
|
||||||
"reserves": "Резервы кредиток",
|
"reserves": "Резервы кредиток",
|
||||||
"noCategories": "В этом месяце ещё нет категорий. Скопируйте прошлый месяц или создайте план.",
|
"noCategories": "В этом месяце ещё нет категорий. Скопируйте прошлый месяц или создайте план.",
|
||||||
|
"manageCategories": "Управлять категориями",
|
||||||
|
"savingsEnvelopeHint": "Конверт «Накопления» наполняется переводами и корректировками счёта; план не задаётся.",
|
||||||
"category.kind.monthly": "Месячная",
|
"category.kind.monthly": "Месячная",
|
||||||
"category.kind.savings_envelope": "Конверт",
|
"category.kind.savings_envelope": "Конверт",
|
||||||
"category.kind.credit_reserve": "Резерв кредитки",
|
"category.kind.credit_reserve": "Резерв кредитки",
|
||||||
|
"manager": {
|
||||||
|
"title": "Управление категориями",
|
||||||
|
"tabsLabel": "Списки категорий",
|
||||||
|
"tabActive": "Активные",
|
||||||
|
"tabArchive": "Архив",
|
||||||
|
"systemBadge": "Системная",
|
||||||
|
"systemProtected": "переименование и архивирование недоступны",
|
||||||
|
"rename": "Переименовать",
|
||||||
|
"archive": "В архив",
|
||||||
|
"restore": "Восстановить",
|
||||||
|
"renameTitle": "Переименовать категорию",
|
||||||
|
"archiveTitle": "Архивировать категорию",
|
||||||
|
"archiveBody": "Категория «{{name}}» скроется из новых планов и разметки, но история останется.",
|
||||||
|
"emptyActive": "Пользовательских категорий пока нет — создайте первую в форме выше.",
|
||||||
|
"emptyArchive": "Архив категорий пуст."
|
||||||
|
},
|
||||||
"planTitle": "План: {{name}}",
|
"planTitle": "План: {{name}}",
|
||||||
"fundTitle": "Обеспечение: {{name}}",
|
"fundTitle": "Обеспечение: {{name}}",
|
||||||
"fund": "Обеспечить",
|
"fund": "Обеспечить",
|
||||||
@@ -223,13 +274,25 @@
|
|||||||
"income.status.partially_matched": "Частично",
|
"income.status.partially_matched": "Частично",
|
||||||
"income.status.matched": "Поступил",
|
"income.status.matched": "Поступил",
|
||||||
"blockersTitle": "Закрытие заблокировано",
|
"blockersTitle": "Закрытие заблокировано",
|
||||||
"blockers.uncategorized_expenses": "Есть расходы без категории.",
|
"blockers": {
|
||||||
"blockers.negative_envelope": "Отрицательный баланс накопительного конверта.",
|
"IMPORTS_UNRESOLVED": "Не завершены импорты выписок: {{count}}",
|
||||||
"blockers.unresolved_imports": "Есть незавершённые импорты.",
|
"IMPORT_UNRESOLVED_HOLDS": "Неподтверждённые операции без решения: {{count}}",
|
||||||
"blockers.open_decisions": "Есть неразобранные решения по импорту.",
|
"BUDGET_UNCATEGORIZED_EXPENSES": "Расходы без категории: {{count}}",
|
||||||
|
"BUDGET_NEGATIVE_ENVELOPE": "Отрицательный остаток конверта: {{categories}}",
|
||||||
|
"unknown": "Закрытие заблокировано необработанной причиной.",
|
||||||
|
"cta": {
|
||||||
|
"imports": "Открыть очередь разбора",
|
||||||
|
"holds": "Разобрать операции",
|
||||||
|
"uncategorized": "Назначить категории",
|
||||||
|
"envelope": "Открыть категории"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"warnings.plan_imbalance": "План превышает прогнозный пул.",
|
||||||
"warnings.plan_exceeds_pool": "План превышает прогнозный пул.",
|
"warnings.plan_exceeds_pool": "План превышает прогнозный пул.",
|
||||||
|
"warnings.underfunded_credit_debt": "Долг по кредитке обеспечен не полностью.",
|
||||||
"warnings.underfunded_credit": "Долг по кредитке обеспечен не полностью.",
|
"warnings.underfunded_credit": "Долг по кредитке обеспечен не полностью.",
|
||||||
"warnings.expected_income_variance": "Ожидаемый доход расходится с фактическим.",
|
"warnings.expected_income_variance": "Ожидаемый доход расходится с фактическим.",
|
||||||
|
"warnings.unknown": "В месяце есть другая проблема, требующая проверки.",
|
||||||
"close": "Закрыть месяц",
|
"close": "Закрыть месяц",
|
||||||
"closeConfirmTitle": "Закрыть месяц",
|
"closeConfirmTitle": "Закрыть месяц",
|
||||||
"closeConfirmBody": "Месяц {{month}} будет закрыт, остатки месячных категорий свернутся в общий пул.",
|
"closeConfirmBody": "Месяц {{month}} будет закрыт, остатки месячных категорий свернутся в общий пул.",
|
||||||
@@ -256,7 +319,13 @@
|
|||||||
"uploading": "Загрузка…",
|
"uploading": "Загрузка…",
|
||||||
"adapterHint": "Поддерживаются: Альфа debit/credit XLSX, Ozon debit/credit PDF, Рокетбанк debit PDF.",
|
"adapterHint": "Поддерживаются: Альфа debit/credit XLSX, Ozon debit/credit PDF, Рокетбанк debit PDF.",
|
||||||
"file": "Файл выписки",
|
"file": "Файл выписки",
|
||||||
|
"fileName": "Файл выписки",
|
||||||
"targetAccount": "Счёт привязки",
|
"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": "Не привязан",
|
"unassigned": "Не привязан",
|
||||||
"empty": "Импортов пока нет.",
|
"empty": "Импортов пока нет.",
|
||||||
"bank": "Банк / контракт",
|
"bank": "Банк / контракт",
|
||||||
@@ -289,6 +358,19 @@
|
|||||||
"blockers.unresolved_duplicate": "Есть неразобранные дубликаты.",
|
"blockers.unresolved_duplicate": "Есть неразобранные дубликаты.",
|
||||||
"blockers.unresolved_transfer": "Есть неразобранные переводы.",
|
"blockers.unresolved_transfer": "Есть неразобранные переводы.",
|
||||||
"blockers.account_conflict": "Отпечаток счёта конфликтует с существующим.",
|
"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": "Предупреждения ({{count}})",
|
||||||
"warnings.amount_warning": "Нестандартная сумма.",
|
"warnings.amount_warning": "Нестандартная сумма.",
|
||||||
"warnings.date_warning": "Необычная дата.",
|
"warnings.date_warning": "Необычная дата.",
|
||||||
@@ -303,7 +385,7 @@
|
|||||||
"reconciliation.mismatched": "Расхождение",
|
"reconciliation.mismatched": "Расхождение",
|
||||||
"reconciliation.not_available": "Данных для сверки нет",
|
"reconciliation.not_available": "Данных для сверки нет",
|
||||||
"reconciliation.confirmed_without": "Подтверждено без сверки",
|
"reconciliation.confirmed_without": "Подтверждено без сверки",
|
||||||
"totals": "Строк: {{total}} · создать: {{create}} · пропустить: {{discard}} · дубликатов: {{duplicate}} · переводов: {{transfer}}",
|
"totals": "Строк: {{total}} · создать: {{create}} · пропустить: {{discard}} · дубликатов: {{duplicate}} · переводов: {{transfer}} · в накопления: {{savings}} · без решения: {{pending}}",
|
||||||
"rowsTitle": "Строки выписки",
|
"rowsTitle": "Строки выписки",
|
||||||
"noRows": "Строк нет.",
|
"noRows": "Строк нет.",
|
||||||
"rowWarnings": "Замечания",
|
"rowWarnings": "Замечания",
|
||||||
@@ -318,6 +400,7 @@
|
|||||||
"decision.discard": "Пропустить",
|
"decision.discard": "Пропустить",
|
||||||
"decision.duplicate": "Это дубликат",
|
"decision.duplicate": "Это дубликат",
|
||||||
"decision.transfer": "Это перевод",
|
"decision.transfer": "Это перевод",
|
||||||
|
"decision.savings_transfer": "Перевод в накопления",
|
||||||
"confirm": "Применить импорт",
|
"confirm": "Применить импорт",
|
||||||
"confirmTitle": "Применить импорт",
|
"confirmTitle": "Применить импорт",
|
||||||
"confirmBody": "Будут созданы операции по выбранным строкам. Применение атомарно, его можно отменить.",
|
"confirmBody": "Будут созданы операции по выбранным строкам. Применение атомарно, его можно отменить.",
|
||||||
@@ -374,9 +457,16 @@
|
|||||||
"toast": {
|
"toast": {
|
||||||
"accountCreated": "Счёт «{{name}}» создан",
|
"accountCreated": "Счёт «{{name}}» создан",
|
||||||
"accountArchived": "Счёт отправлен в архив",
|
"accountArchived": "Счёт отправлен в архив",
|
||||||
|
"balanceAdjusted": "Остаток счёта скорректирован",
|
||||||
"operationCreated": "Операция создана",
|
"operationCreated": "Операция создана",
|
||||||
"operationVoided": "Операция отменена",
|
"operationVoided": "Операция отменена",
|
||||||
"categoryAssigned": "Категория назначена",
|
"categoryAssigned": "Категория назначена",
|
||||||
|
"convertedToSavings": "Расход переведён в накопления",
|
||||||
|
"savingsConversionReverted": "Перевод возвращён в расходы",
|
||||||
|
"categoryCreated": "Категория «{{name}}» создана",
|
||||||
|
"categoryRenamed": "Категория переименована в «{{name}}»",
|
||||||
|
"categoryArchived": "Категория отправлена в архив",
|
||||||
|
"categoryRestored": "Категория восстановлена",
|
||||||
"planUpdated": "План обновлён",
|
"planUpdated": "План обновлён",
|
||||||
"categoryFunded": "Категория обеспечена",
|
"categoryFunded": "Категория обеспечена",
|
||||||
"monthClosed": "Месяц закрыт",
|
"monthClosed": "Месяц закрыт",
|
||||||
@@ -385,6 +475,7 @@
|
|||||||
"importUploaded": "Выписка загружена, разбор запущен",
|
"importUploaded": "Выписка загружена, разбор запущен",
|
||||||
"importConfirmed": "Импорт применён",
|
"importConfirmed": "Импорт применён",
|
||||||
"importReverted": "Импорт отменён",
|
"importReverted": "Импорт отменён",
|
||||||
|
"holdResolved": "Решение по замороженной операции сохранено",
|
||||||
"reparseQueued": "Переразбор запущен",
|
"reparseQueued": "Переразбор запущен",
|
||||||
"exportQueued": "Экспорт поставлен в очередь",
|
"exportQueued": "Экспорт поставлен в очередь",
|
||||||
"exportReady": "Экспорт готов к скачиванию",
|
"exportReady": "Экспорт готов к скачиванию",
|
||||||
@@ -396,7 +487,11 @@
|
|||||||
"subtitle": "Такой страницы нет. Проверьте адрес или вернитесь к счетам.",
|
"subtitle": "Такой страницы нет. Проверьте адрес или вернитесь к счетам.",
|
||||||
"back": "К счетам"
|
"back": "К счетам"
|
||||||
},
|
},
|
||||||
|
"account": {
|
||||||
|
"savings": "Накопления"
|
||||||
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
|
"uncategorized": "Без категории",
|
||||||
"groceries": "Продукты",
|
"groceries": "Продукты",
|
||||||
"transport": "Транспорт",
|
"transport": "Транспорт",
|
||||||
"housing": "Жильё",
|
"housing": "Жильё",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { Locale } from '@/api/types'
|
||||||
|
|
||||||
|
export function currentLocale(language: string | undefined): Locale {
|
||||||
|
return language?.toLowerCase().startsWith('ru') ? 'ru' : 'en'
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -3,7 +3,8 @@ import { zodResolver } from '@hookform/resolvers/zod'
|
|||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { z } from 'zod'
|
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 { Badge } from '@/components/ui/Badge'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
|
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 { Modal } from '@/components/ui/Modal'
|
||||||
import { PageHeader } from '@/components/ui/PageHeader'
|
import { PageHeader } from '@/components/ui/PageHeader'
|
||||||
import { QueryGate } from '@/components/ui/QueryGate'
|
import { QueryGate } from '@/components/ui/QueryGate'
|
||||||
import { useAccounts, useArchiveAccount, useCreateAccount } from '@/hooks/useAccounts'
|
import {
|
||||||
import { formatDate } from '@/lib/dates'
|
useAccounts,
|
||||||
import { formatMoney, parseAmountToKopecks, sumKopecks } from '@/lib/money'
|
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'
|
import { currentLocale } from '@/lib/locale'
|
||||||
|
|
||||||
interface AccountFormValues {
|
interface AccountFormValues {
|
||||||
name: string
|
name: string
|
||||||
type: AccountType
|
type: 'cash' | 'debit' | 'credit'
|
||||||
openedOn: string
|
openedOn: string
|
||||||
openingBalance: string
|
openingBalance: string
|
||||||
bankCode: 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() {
|
export function AccountsPage() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const locale = currentLocale(i18n.language)
|
const locale = currentLocale(i18n.language)
|
||||||
@@ -126,6 +211,7 @@ export function AccountsPage() {
|
|||||||
const liabilities = sumKopecks(
|
const liabilities = sumKopecks(
|
||||||
active.filter((a) => a.type === 'credit').map((a) => a.balanceKopecks),
|
active.filter((a) => a.type === 'credit').map((a) => a.balanceKopecks),
|
||||||
)
|
)
|
||||||
|
const [adjustTarget, setAdjustTarget] = useState<Account | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -174,52 +260,67 @@ export function AccountsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{list.map((account) => (
|
{list.map((account) => {
|
||||||
<tr key={account.id}>
|
const system = isSystemAccount(account)
|
||||||
<th scope="row" data-label={t('common.name')}>
|
return (
|
||||||
{account.name}
|
<tr key={account.id}>
|
||||||
{account.maskedNumber ? (
|
<th scope="row" data-label={t('common.name')}>
|
||||||
<span className="muted"> · {account.maskedNumber}</span>
|
{accountLabel(account, t)}
|
||||||
) : null}
|
{system ? <Badge tone="info">{t('accounts.systemBadge')}</Badge> : null}
|
||||||
</th>
|
{account.maskedNumber ? (
|
||||||
<td data-label={t('accounts.type')}>
|
<span className="muted"> · {account.maskedNumber}</span>
|
||||||
<Badge tone={account.type === 'credit' ? 'warn' : 'info'}>
|
) : null}
|
||||||
{t(`accounts.type.${account.type}`)}
|
</th>
|
||||||
</Badge>
|
<td data-label={t('accounts.type')}>
|
||||||
</td>
|
<Badge tone={account.type === 'credit' ? 'warn' : 'info'}>
|
||||||
<td data-label={t('accounts.bank')}>{account.bankCode ?? '—'}</td>
|
{t(`accounts.type.${account.type}`)}
|
||||||
<td data-label={t('accounts.openedOn')}>
|
</Badge>
|
||||||
{formatDate(account.openedOn, locale)}
|
</td>
|
||||||
</td>
|
<td data-label={t('accounts.bank')}>{account.bankCode ?? '—'}</td>
|
||||||
<td data-label={t('common.status')}>
|
<td data-label={t('accounts.openedOn')}>
|
||||||
<Badge tone={account.status === 'active' ? 'accent' : 'neutral'}>
|
{formatDate(account.openedOn, locale)}
|
||||||
{t(`accounts.status.${account.status}`)}
|
</td>
|
||||||
</Badge>
|
<td data-label={t('common.status')}>
|
||||||
</td>
|
<Badge tone={account.status === 'active' ? 'accent' : 'neutral'}>
|
||||||
<td
|
{t(`accounts.status.${account.status}`)}
|
||||||
data-label={t('accounts.balance')}
|
</Badge>
|
||||||
className={`num ${account.type === 'credit' ? 'neg' : 'pos'}`}
|
</td>
|
||||||
>
|
<td
|
||||||
{formatMoney(account.balanceKopecks, locale)}
|
data-label={t('accounts.balance')}
|
||||||
</td>
|
className={`num ${account.type === 'credit' ? 'neg' : 'pos'}`}
|
||||||
<td data-label={t('common.actions')}>
|
>
|
||||||
{account.status === 'active' ? (
|
{formatMoney(account.balanceKopecks, locale)}
|
||||||
<Button
|
</td>
|
||||||
variant="subtle"
|
<td data-label={t('common.actions')}>
|
||||||
disabled={account.balanceKopecks !== 0}
|
<span className="inline-actions">
|
||||||
title={
|
{account.status === 'active' ? (
|
||||||
account.balanceKopecks !== 0
|
<>
|
||||||
? t('accounts.archiveBlockedHint')
|
{account.type !== 'credit' ? (
|
||||||
: undefined
|
<Button variant="subtle" onClick={() => setAdjustTarget(account)}>
|
||||||
}
|
{t('accounts.adjust')}
|
||||||
onClick={() => setArchiveTarget(account)}
|
</Button>
|
||||||
>
|
) : null}
|
||||||
{t('accounts.archive')}
|
{!system ? (
|
||||||
</Button>
|
<Button
|
||||||
) : null}
|
variant="subtle"
|
||||||
</td>
|
disabled={account.balanceKopecks !== 0}
|
||||||
</tr>
|
title={
|
||||||
))}
|
account.balanceKopecks !== 0
|
||||||
|
? t('accounts.archiveBlockedHint')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={() => setArchiveTarget(account)}
|
||||||
|
>
|
||||||
|
{t('accounts.archive')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
@@ -228,6 +329,9 @@ export function AccountsPage() {
|
|||||||
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title={t('accounts.formTitle')}>
|
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title={t('accounts.formTitle')}>
|
||||||
<AccountForm onDone={() => setCreateOpen(false)} />
|
<AccountForm onDone={() => setCreateOpen(false)} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
{adjustTarget ? (
|
||||||
|
<AdjustBalanceModal account={adjustTarget} onClose={() => setAdjustTarget(null)} />
|
||||||
|
) : null}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={archiveTarget !== null}
|
open={archiveTarget !== null}
|
||||||
danger
|
danger
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type { BudgetCategory } from '@/api/types'
|
|||||||
import { Badge } from '@/components/ui/Badge'
|
import { Badge } from '@/components/ui/Badge'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
|
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 { MoneyField } from '@/components/ui/fields'
|
||||||
import { Modal } from '@/components/ui/Modal'
|
import { Modal } from '@/components/ui/Modal'
|
||||||
import { MonthSwitcher } from '@/components/ui/MonthSwitcher'
|
import { MonthSwitcher } from '@/components/ui/MonthSwitcher'
|
||||||
@@ -26,6 +28,8 @@ import { formatDate, currentMonth, formatMonth } from '@/lib/dates'
|
|||||||
import { currentLocale } from '@/lib/locale'
|
import { currentLocale } from '@/lib/locale'
|
||||||
import { formatMoney, kopecksToInput, parseAmountToKopecks } from '@/lib/money'
|
import { formatMoney, kopecksToInput, parseAmountToKopecks } from '@/lib/money'
|
||||||
|
|
||||||
|
const BLOCKER_WARNING_CODES = new Set(['uncategorized_expenses', 'negative_envelopes'])
|
||||||
|
|
||||||
function PlanModal({
|
function PlanModal({
|
||||||
category,
|
category,
|
||||||
month,
|
month,
|
||||||
@@ -134,6 +138,7 @@ export function BudgetPage() {
|
|||||||
const [fundTarget, setFundTarget] = useState<BudgetCategory | null>(null)
|
const [fundTarget, setFundTarget] = useState<BudgetCategory | null>(null)
|
||||||
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false)
|
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false)
|
||||||
const [reopenConfirmOpen, setReopenConfirmOpen] = useState(false)
|
const [reopenConfirmOpen, setReopenConfirmOpen] = useState(false)
|
||||||
|
const [managerOpen, setManagerOpen] = useState(false)
|
||||||
|
|
||||||
const status = summary.data?.status
|
const status = summary.data?.status
|
||||||
const allCategories = categories.data ?? []
|
const allCategories = categories.data ?? []
|
||||||
@@ -141,37 +146,62 @@ export function BudgetPage() {
|
|||||||
(category) => category.kind !== 'credit_reserve' && !category.archived,
|
(category) => category.kind !== 'credit_reserve' && !category.archived,
|
||||||
)
|
)
|
||||||
const reserves = allCategories.filter((category) => category.kind === 'credit_reserve')
|
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[]) =>
|
const renderCategoryRows = (rows: BudgetCategory[]) =>
|
||||||
rows.map((category) => (
|
rows.map((category) => {
|
||||||
<tr key={category.id}>
|
const isSystemCategory = category.system
|
||||||
<th scope="row" data-label={t('common.name')}>
|
const systemHint =
|
||||||
{categoryLabel(category, t)}
|
category.nameKey === 'savings'
|
||||||
<Badge tone={category.kind === 'savings_envelope' ? 'info' : 'neutral'}>
|
? t('budget.savingsEnvelopeHint')
|
||||||
{t(`budget.category.kind.${category.kind}`)}
|
: t('budget.manager.systemNote')
|
||||||
</Badge>
|
return (
|
||||||
</th>
|
<tr key={category.id}>
|
||||||
<td data-label={t('budget.planned')}>
|
<th scope="row" data-label={t('common.name')}>
|
||||||
<Button variant="subtle" onClick={() => setPlanTarget(category)}>
|
{categoryLabel(category, t)}
|
||||||
{formatMoney(category.plannedKopecks, locale)}
|
{isSystemCategory ? (
|
||||||
</Button>
|
<Badge tone="info">{t('budget.manager.systemBadge')}</Badge>
|
||||||
</td>
|
) : (
|
||||||
<td data-label={t('budget.funded')}>
|
<Badge tone={category.kind === 'savings_envelope' ? 'info' : 'neutral'}>
|
||||||
<Button variant="subtle" onClick={() => setFundTarget(category)}>
|
{t(`budget.category.kind.${category.kind}`)}
|
||||||
{formatMoney(category.fundedKopecks, locale)}
|
</Badge>
|
||||||
</Button>
|
)}
|
||||||
</td>
|
</th>
|
||||||
<td data-label={t('budget.spent')} className="num">
|
<td data-label={t('budget.planned')}>
|
||||||
{formatMoney(category.spentKopecks, locale)}
|
{isSystemCategory ? (
|
||||||
</td>
|
<span className="num" title={systemHint}>
|
||||||
<td
|
{formatMoney(category.plannedKopecks, locale)}
|
||||||
data-label={t('budget.remaining')}
|
</span>
|
||||||
className={`num ${category.availableKopecks < 0 ? 'neg' : 'pos'}`}
|
) : (
|
||||||
>
|
<Button variant="subtle" onClick={() => setPlanTarget(category)}>
|
||||||
{formatMoney(category.availableKopecks, locale)}
|
{formatMoney(category.plannedKopecks, locale)}
|
||||||
</td>
|
</Button>
|
||||||
</tr>
|
)}
|
||||||
))
|
</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)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
data-label={t('budget.remaining')}
|
||||||
|
className={`num ${category.availableKopecks < 0 ? 'neg' : 'pos'}`}
|
||||||
|
>
|
||||||
|
{formatMoney(category.availableKopecks, locale)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -217,21 +247,17 @@ export function BudgetPage() {
|
|||||||
{summary.data && summary.data.blockers.length > 0 ? (
|
{summary.data && summary.data.blockers.length > 0 ? (
|
||||||
<div className="banner banner-danger" role="alert">
|
<div className="banner banner-danger" role="alert">
|
||||||
<strong>{t('budget.blockersTitle')}</strong>
|
<strong>{t('budget.blockersTitle')}</strong>
|
||||||
<ul>
|
<BudgetBlockers blockers={summary.data.blockers} month={month} />
|
||||||
{summary.data.blockers.map((blocker) => (
|
|
||||||
<li key={blocker.code}>
|
|
||||||
{t(`budget.blockers.${blocker.code}`, { defaultValue: blocker.message ?? blocker.code })}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{summary.data && summary.data.warnings.length > 0 ? (
|
{supplementalWarnings.length > 0 ? (
|
||||||
<div className="banner banner-warn">
|
<div className="banner banner-warn">
|
||||||
<ul>
|
<ul>
|
||||||
{summary.data.warnings.map((warning) => (
|
{supplementalWarnings.map((warning) => (
|
||||||
<li key={warning.code}>
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -308,8 +334,13 @@ export function BudgetPage() {
|
|||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
<section aria-label={t('budget.categories')}>
|
<section aria-label={t('budget.categories')} id="categories">
|
||||||
<h2 className="section-title">{t('budget.categories')}</h2>
|
<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 ? (
|
{userCategories.length === 0 ? (
|
||||||
<p className="muted">{t('budget.noCategories')}</p>
|
<p className="muted">{t('budget.noCategories')}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -355,6 +386,7 @@ export function BudgetPage() {
|
|||||||
{fundTarget ? (
|
{fundTarget ? (
|
||||||
<FundModal category={fundTarget} month={month} onClose={() => setFundTarget(null)} />
|
<FundModal category={fundTarget} month={month} onClose={() => setFundTarget(null)} />
|
||||||
) : null}
|
) : null}
|
||||||
|
{managerOpen ? <CategoryManagerModal onClose={() => setManagerOpen(false)} /> : null}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={closeConfirmOpen}
|
open={closeConfirmOpen}
|
||||||
danger
|
danger
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
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 { Badge } from '@/components/ui/Badge'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
|
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 { Modal } from '@/components/ui/Modal'
|
||||||
import { PageHeader } from '@/components/ui/PageHeader'
|
import { PageHeader } from '@/components/ui/PageHeader'
|
||||||
import { QueryGate } from '@/components/ui/QueryGate'
|
import { QueryGate } from '@/components/ui/QueryGate'
|
||||||
@@ -13,6 +16,7 @@ import {
|
|||||||
useConfirmImport,
|
useConfirmImport,
|
||||||
useImport,
|
useImport,
|
||||||
useReparseImport,
|
useReparseImport,
|
||||||
|
useResolveHold,
|
||||||
useRevertImport,
|
useRevertImport,
|
||||||
useSaveRowDecision,
|
useSaveRowDecision,
|
||||||
} from '@/hooks/useImports'
|
} from '@/hooks/useImports'
|
||||||
@@ -26,6 +30,7 @@ const DECISIONS: readonly Exclude<RowDecision, 'pending'>[] = [
|
|||||||
'discard',
|
'discard',
|
||||||
'duplicate',
|
'duplicate',
|
||||||
'transfer',
|
'transfer',
|
||||||
|
'savings_transfer',
|
||||||
]
|
]
|
||||||
|
|
||||||
const STEP_ORDER = ['uploaded', 'queued', 'processing', 'review_required', 'confirmed'] as const
|
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
|
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({
|
function ConfirmModal({
|
||||||
importId,
|
importId,
|
||||||
needsConfirmation,
|
needsConfirmation,
|
||||||
@@ -101,6 +176,7 @@ export function ImportDetailPage() {
|
|||||||
|
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||||
const [revertOpen, setRevertOpen] = useState(false)
|
const [revertOpen, setRevertOpen] = useState(false)
|
||||||
|
const [holdTarget, setHoldTarget] = useState<{ row: ImportRow; action: HoldResolutionAction } | null>(null)
|
||||||
|
|
||||||
const status = detail.data?.status
|
const status = detail.data?.status
|
||||||
const needsConfirmation =
|
const needsConfirmation =
|
||||||
@@ -134,6 +210,9 @@ export function ImportDetailPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{status ? <Badge tone={IMPORT_STATUS_TONES[status]}>{t(`imports.status.${status}`)}</Badge> : null}
|
{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}>
|
<QueryGate query={detail}>
|
||||||
{(data) => (
|
{(data) => (
|
||||||
<>
|
<>
|
||||||
@@ -225,6 +304,8 @@ export function ImportDetailPage() {
|
|||||||
discard: data.totals.discard,
|
discard: data.totals.discard,
|
||||||
duplicate: data.totals.duplicate,
|
duplicate: data.totals.duplicate,
|
||||||
transfer: data.totals.transfer,
|
transfer: data.totals.transfer,
|
||||||
|
savings: data.totals.savingsTransfer,
|
||||||
|
pending: data.totals.pending,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -262,7 +343,9 @@ export function ImportDetailPage() {
|
|||||||
{formatMoney(row.amountKopecks, locale)}
|
{formatMoney(row.amountKopecks, locale)}
|
||||||
</td>
|
</td>
|
||||||
<td data-label={t('imports.rowWarnings')} className="statement-warnings">
|
<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">
|
<ul className="chip-list">
|
||||||
@@ -275,26 +358,71 @@ export function ImportDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td data-label={t('imports.decision')} className="statement-decision">
|
<td data-label={t('imports.decision')} className="statement-decision">
|
||||||
<select
|
{row.bankStatus === 'hold' ? (
|
||||||
className="input select"
|
row.holdResolution ? (
|
||||||
aria-label={t('imports.decision')}
|
<div className="hold-history">
|
||||||
value={row.decision === 'pending' ? '' : row.decision}
|
<Badge
|
||||||
onChange={(event) =>
|
tone={
|
||||||
saveDecision.mutate({
|
row.holdResolution.resolution === 'confirmed_as_posted'
|
||||||
rowId: row.id,
|
? 'accent'
|
||||||
payload: {
|
: 'neutral'
|
||||||
decision: event.target.value as Exclude<RowDecision, 'pending'>,
|
}
|
||||||
},
|
>
|
||||||
})
|
{t(
|
||||||
}
|
`imports.holdResolution.${row.holdResolution.resolution}`,
|
||||||
>
|
)}
|
||||||
<option value="">{t('imports.decision.pending')}</option>
|
</Badge>
|
||||||
{DECISIONS.map((decision) => (
|
{row.holdResolution.reason ? (
|
||||||
<option key={decision} value={decision}>
|
<span className="muted">{row.holdResolution.reason}</span>
|
||||||
{t(`imports.decision.${decision}`)}
|
) : null}
|
||||||
</option>
|
{row.holdResolution.resolvedAt ? (
|
||||||
))}
|
<span className="muted">
|
||||||
</select>
|
{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')}
|
||||||
|
value={row.decision === 'pending' ? '' : row.decision}
|
||||||
|
onChange={(event) =>
|
||||||
|
saveDecision.mutate({
|
||||||
|
rowId: row.id,
|
||||||
|
payload: {
|
||||||
|
decision: event.target.value as Exclude<RowDecision, 'pending'>,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">{t('imports.decision.pending')}</option>
|
||||||
|
{DECISIONS.map((decision) => (
|
||||||
|
<option key={decision} value={decision}>
|
||||||
|
{t(`imports.decision.${decision}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -302,6 +430,14 @@ export function ImportDetailPage() {
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
{holdTarget ? (
|
||||||
|
<HoldResolveModal
|
||||||
|
importId={data.id}
|
||||||
|
row={holdTarget.row}
|
||||||
|
action={holdTarget.action}
|
||||||
|
onClose={() => setHoldTarget(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{confirmOpen ? (
|
{confirmOpen ? (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
importId={data.id}
|
importId={data.id}
|
||||||
|
|||||||
@@ -8,15 +8,16 @@ import { SelectField } from '@/components/ui/fields'
|
|||||||
import { Modal } from '@/components/ui/Modal'
|
import { Modal } from '@/components/ui/Modal'
|
||||||
import { PageHeader } from '@/components/ui/PageHeader'
|
import { PageHeader } from '@/components/ui/PageHeader'
|
||||||
import { QueryGate } from '@/components/ui/QueryGate'
|
import { QueryGate } from '@/components/ui/QueryGate'
|
||||||
import { useActiveAccounts } from '@/hooks/useAccounts'
|
import { useUserAccounts } from '@/hooks/useAccounts'
|
||||||
import { useImports, useUploadImport } from '@/hooks/useImports'
|
import { useImports, useUploadImport } from '@/hooks/useImports'
|
||||||
|
import { accountLabel } from '@/lib/accountLabel'
|
||||||
import { formatDateTime } from '@/lib/dates'
|
import { formatDateTime } from '@/lib/dates'
|
||||||
import { currentLocale } from '@/lib/locale'
|
import { currentLocale } from '@/lib/locale'
|
||||||
import { IMPORT_STATUS_TONES } from '@/lib/operationFormat'
|
import { IMPORT_STATUS_TONES } from '@/lib/operationFormat'
|
||||||
|
|
||||||
function UploadModal({ onClose }: { onClose: () => void }) {
|
function UploadModal({ onClose }: { onClose: () => void }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const accounts = useActiveAccounts()
|
const accounts = useUserAccounts()
|
||||||
const upload = useUploadImport()
|
const upload = useUploadImport()
|
||||||
const [accountId, setAccountId] = useState('')
|
const [accountId, setAccountId] = useState('')
|
||||||
const [file, setFile] = useState<File | null>(null)
|
const [file, setFile] = useState<File | null>(null)
|
||||||
@@ -62,7 +63,7 @@ function UploadModal({ onClose }: { onClose: () => void }) {
|
|||||||
<option value="">{t('common.choose')}</option>
|
<option value="">{t('common.choose')}</option>
|
||||||
{(accounts.data ?? []).map((account) => (
|
{(accounts.data ?? []).map((account) => (
|
||||||
<option key={account.id} value={account.id}>
|
<option key={account.id} value={account.id}>
|
||||||
{account.name}
|
{accountLabel(account, t)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</SelectField>
|
</SelectField>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import type { TFunction } from 'i18next'
|
import type { TFunction } from 'i18next'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { Account, Operation, OperationFilters, OperationKind, OperationStatus } from '@/api/types'
|
import type { Account, Operation, OperationFilters, OperationKind, OperationStatus } from '@/api/types'
|
||||||
|
import { isSystemAccount } from '@/api/types'
|
||||||
import { Badge } from '@/components/ui/Badge'
|
import { Badge } from '@/components/ui/Badge'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { EmptyState } from '@/components/ui/EmptyState'
|
import { EmptyState } from '@/components/ui/EmptyState'
|
||||||
@@ -13,12 +14,15 @@ import { Modal } from '@/components/ui/Modal'
|
|||||||
import { PageHeader } from '@/components/ui/PageHeader'
|
import { PageHeader } from '@/components/ui/PageHeader'
|
||||||
import { QueryGate } from '@/components/ui/QueryGate'
|
import { QueryGate } from '@/components/ui/QueryGate'
|
||||||
import { useBudgetCategories } from '@/hooks/useBudget'
|
import { useBudgetCategories } from '@/hooks/useBudget'
|
||||||
import { useActiveAccounts } from '@/hooks/useAccounts'
|
import { useActiveAccounts, useUserAccounts } from '@/hooks/useAccounts'
|
||||||
import {
|
import {
|
||||||
|
useConvertToSavings,
|
||||||
useCreateOperation,
|
useCreateOperation,
|
||||||
useOperations,
|
useOperations,
|
||||||
|
useRevertSavingsConversion,
|
||||||
useVoidOperation,
|
useVoidOperation,
|
||||||
} from '@/hooks/useOperations'
|
} from '@/hooks/useOperations'
|
||||||
|
import { accountLabel } from '@/lib/accountLabel'
|
||||||
import { categoryLabel } from '@/lib/categoryLabel'
|
import { categoryLabel } from '@/lib/categoryLabel'
|
||||||
import { currentMonth, formatDate, todayISO } from '@/lib/dates'
|
import { currentMonth, formatDate, todayISO } from '@/lib/dates'
|
||||||
import { currentLocale } from '@/lib/locale'
|
import { currentLocale } from '@/lib/locale'
|
||||||
@@ -50,7 +54,7 @@ function AccountSelect({
|
|||||||
<option value="">{t('common.choose')}</option>
|
<option value="">{t('common.choose')}</option>
|
||||||
{(accounts ?? []).map((account) => (
|
{(accounts ?? []).map((account) => (
|
||||||
<option key={account.id} value={account.id}>
|
<option key={account.id} value={account.id}>
|
||||||
{account.name}
|
{accountLabel(account, t)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</SelectField>
|
</SelectField>
|
||||||
@@ -60,7 +64,7 @@ function AccountSelect({
|
|||||||
function ExpenseForm({ onDone }: { onDone: () => void }) {
|
function ExpenseForm({ onDone }: { onDone: () => void }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const create = useCreateOperation()
|
const create = useCreateOperation()
|
||||||
const accounts = useActiveAccounts()
|
const accounts = useUserAccounts()
|
||||||
const categories = useBudgetCategories(currentMonth())
|
const categories = useBudgetCategories(currentMonth())
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
accountId: z.string().min(1, t('common.requiredField')),
|
accountId: z.string().min(1, t('common.requiredField')),
|
||||||
@@ -143,7 +147,7 @@ function ExpenseForm({ onDone }: { onDone: () => void }) {
|
|||||||
function IncomeForm({ onDone }: { onDone: () => void }) {
|
function IncomeForm({ onDone }: { onDone: () => void }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const create = useCreateOperation()
|
const create = useCreateOperation()
|
||||||
const accounts = useActiveAccounts()
|
const accounts = useUserAccounts()
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
accountId: z.string().min(1, t('common.requiredField')),
|
accountId: z.string().min(1, t('common.requiredField')),
|
||||||
amount: amountField(t),
|
amount: amountField(t),
|
||||||
@@ -297,7 +301,7 @@ function RefundForm({ onDone }: { onDone: () => void }) {
|
|||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const locale = currentLocale(i18n.language)
|
const locale = currentLocale(i18n.language)
|
||||||
const create = useCreateOperation()
|
const create = useCreateOperation()
|
||||||
const accounts = useActiveAccounts()
|
const accounts = useUserAccounts()
|
||||||
const recent = useOperations({ kind: 'expense', limit: 20 })
|
const recent = useOperations({ kind: 'expense', limit: 20 })
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
accountId: z.string().min(1, t('common.requiredField')),
|
accountId: z.string().min(1, t('common.requiredField')),
|
||||||
@@ -447,6 +451,76 @@ function VoidModal({ operation, onClose }: { operation: Operation; onClose: () =
|
|||||||
|
|
||||||
const PAGE_SIZE = 50
|
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() {
|
export function OperationsPage() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const locale = currentLocale(i18n.language)
|
const locale = currentLocale(i18n.language)
|
||||||
@@ -481,9 +555,13 @@ export function OperationsPage() {
|
|||||||
const operations = useOperations(filters)
|
const operations = useOperations(filters)
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [voidTarget, setVoidTarget] = useState<Operation | null>(null)
|
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) =>
|
const accountName = (id: string | null) => {
|
||||||
accounts.data?.find((account) => account.id === id)?.name ?? '—'
|
const account = accounts.data?.find((item) => item.id === id)
|
||||||
|
return account ? accountLabel(account, t) : '—'
|
||||||
|
}
|
||||||
|
|
||||||
const goNext = () => {
|
const goNext = () => {
|
||||||
const next = operations.data?.nextCursor
|
const next = operations.data?.nextCursor
|
||||||
@@ -536,7 +614,7 @@ export function OperationsPage() {
|
|||||||
<option value="">{t('operations.filterAll')}</option>
|
<option value="">{t('operations.filterAll')}</option>
|
||||||
{(accounts.data ?? []).map((account) => (
|
{(accounts.data ?? []).map((account) => (
|
||||||
<option key={account.id} value={account.id}>
|
<option key={account.id} value={account.id}>
|
||||||
{account.name}
|
{accountLabel(account, t)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</SelectField>
|
</SelectField>
|
||||||
@@ -618,6 +696,22 @@ export function OperationsPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{page.items.map((operation) => {
|
{page.items.map((operation) => {
|
||||||
const amount = operationAmount(operation, locale)
|
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 (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={operation.id}
|
key={operation.id}
|
||||||
@@ -644,16 +738,34 @@ export function OperationsPage() {
|
|||||||
: ''}
|
: ''}
|
||||||
</td>
|
</td>
|
||||||
<td data-label={t('operations.category')} className="operation-category">
|
<td data-label={t('operations.category')} className="operation-category">
|
||||||
{operation.categoryName ?? t('operations.noCategory')}
|
{categoryText}
|
||||||
</td>
|
</td>
|
||||||
<td data-label={t('common.amount')} className={`operation-amount num ${amount.tone}`}>
|
<td data-label={t('common.amount')} className={`operation-amount num ${amount.tone}`}>
|
||||||
{amount.text}
|
{amount.text}
|
||||||
</td>
|
</td>
|
||||||
<td data-label={t('common.actions')} className="operation-actions">
|
<td data-label={t('common.actions')} className="operation-actions">
|
||||||
{operation.status === 'confirmed' ? (
|
{operation.status === 'confirmed' ? (
|
||||||
<Button variant="subtle" onClick={() => setVoidTarget(operation)}>
|
<span className="inline-actions">
|
||||||
{t('operations.void')}
|
{canConvert ? (
|
||||||
</Button>
|
<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}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -676,6 +788,12 @@ export function OperationsPage() {
|
|||||||
{voidTarget ? (
|
{voidTarget ? (
|
||||||
<VoidModal operation={voidTarget} onClose={() => setVoidTarget(null)} />
|
<VoidModal operation={voidTarget} onClose={() => setVoidTarget(null)} />
|
||||||
) : null}
|
) : null}
|
||||||
|
{convertTarget ? (
|
||||||
|
<SavingsConvertModal operation={convertTarget} onClose={() => setConvertTarget(null)} />
|
||||||
|
) : null}
|
||||||
|
{revertTarget ? (
|
||||||
|
<SavingsRevertModal operation={revertTarget} onClose={() => setRevertTarget(null)} />
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { BudgetBlockers } from '@/components/budget/BudgetBlockers'
|
||||||
import { PageHeader } from '@/components/ui/PageHeader'
|
import { PageHeader } from '@/components/ui/PageHeader'
|
||||||
import { QueryGate } from '@/components/ui/QueryGate'
|
import { QueryGate } from '@/components/ui/QueryGate'
|
||||||
import { useBudgetCategories, useBudgetSummary } from '@/hooks/useBudget'
|
import { useBudgetCategories, useBudgetSummary } from '@/hooks/useBudget'
|
||||||
@@ -61,7 +62,7 @@ export function OverviewPage() {
|
|||||||
<aside className="decision-panel">
|
<aside className="decision-panel">
|
||||||
<div className="section-heading"><h2>{t('overview.closeReadiness')}</h2><span>{data.blockers.length} {t('overview.blockers')}</span></div>
|
<div className="section-heading"><h2>{t('overview.closeReadiness')}</h2><span>{data.blockers.length} {t('overview.blockers')}</span></div>
|
||||||
{data.blockers.length > 0 ? (
|
{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>}
|
) : <p>{t('overview.readyToClose')}</p>}
|
||||||
{attentionImports.length > 0 ? <p>{t('overview.importsNeedReview', { count: attentionImports.length })}</p> : null}
|
{attentionImports.length > 0 ? <p>{t('overview.importsNeedReview', { count: attentionImports.length })}</p> : null}
|
||||||
<Link className="btn btn-subtle" to="/review">{t('overview.reviewBlockers')}</Link>
|
<Link className="btn btn-subtle" to="/review">{t('overview.reviewBlockers')}</Link>
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import type { BudgetCategory, Operation } from '@/api/types'
|
import type { BudgetCategory, ImportSummary, Operation } from '@/api/types'
|
||||||
import { Badge } from '@/components/ui/Badge'
|
import { Badge } from '@/components/ui/Badge'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { EmptyState } from '@/components/ui/EmptyState'
|
import { EmptyState } from '@/components/ui/EmptyState'
|
||||||
|
import { MonthSwitcher } from '@/components/ui/MonthSwitcher'
|
||||||
import { PageHeader } from '@/components/ui/PageHeader'
|
import { PageHeader } from '@/components/ui/PageHeader'
|
||||||
import { QueryGate } from '@/components/ui/QueryGate'
|
import { QueryGate } from '@/components/ui/QueryGate'
|
||||||
import { useBudgetCategories } from '@/hooks/useBudget'
|
import { useBudgetCategories } from '@/hooks/useBudget'
|
||||||
import { useCategorizeOperation, useOperations } from '@/hooks/useOperations'
|
import { useCategorizeOperation, useOperations } from '@/hooks/useOperations'
|
||||||
import { useImports } from '@/hooks/useImports'
|
import { useImports } from '@/hooks/useImports'
|
||||||
import { categoryLabel } from '@/lib/categoryLabel'
|
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 { currentLocale } from '@/lib/locale'
|
||||||
import { IMPORT_STATUS_TONES } from '@/lib/operationFormat'
|
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({
|
function UncategorizedRow({
|
||||||
operation,
|
operation,
|
||||||
categories,
|
categories,
|
||||||
@@ -68,23 +73,41 @@ function UncategorizedRow({
|
|||||||
export function ReviewQueuePage() {
|
export function ReviewQueuePage() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const locale = currentLocale(i18n.language)
|
const locale = currentLocale(i18n.language)
|
||||||
const imports = useImports()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const uncategorized = useOperations({ uncategorized: true, status: 'confirmed', limit: 50 })
|
|
||||||
const categories = useBudgetCategories(currentMonth())
|
|
||||||
|
|
||||||
const problematicImports = (imports.data ?? []).filter(
|
const monthParam = searchParams.get('month')
|
||||||
(item) => item.status === 'review_required' || item.status === 'failed',
|
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
|
const hasWork = problematicImports.length > 0 || (uncategorized.data?.items.length ?? 0) > 0
|
||||||
|
|
||||||
return (
|
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 ? (
|
{imports.isPending || uncategorized.isPending ? null : !hasWork ? (
|
||||||
<EmptyState title={t('review.empty')} />
|
<EmptyState title={t('review.empty')} />
|
||||||
) : null}
|
) : null}
|
||||||
{problematicImports.length > 0 ? (
|
{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>
|
<h2 className="section-title">{t('review.sectionImports')}</h2>
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<caption className="visually-hidden">{t('review.sectionImports')}</caption>
|
<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.fileName')}</th>
|
||||||
<th scope="col">{t('imports.bank')}</th>
|
<th scope="col">{t('imports.bank')}</th>
|
||||||
<th scope="col">{t('common.status')}</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('imports.createdAt')}</th>
|
||||||
<th scope="col">{t('common.actions')}</th>
|
<th scope="col">{t('common.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -103,12 +127,23 @@ export function ReviewQueuePage() {
|
|||||||
<th scope="row" data-label={t('imports.fileName')}>
|
<th scope="row" data-label={t('imports.fileName')}>
|
||||||
{item.fileName}
|
{item.fileName}
|
||||||
</th>
|
</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')}>
|
<td data-label={t('common.status')}>
|
||||||
<Badge tone={IMPORT_STATUS_TONES[item.status]}>
|
<Badge tone={IMPORT_STATUS_TONES[item.status]}>
|
||||||
{t(`imports.status.${item.status}`)}
|
{t(`imports.status.${item.status}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</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')}>
|
<td data-label={t('imports.createdAt')}>
|
||||||
{formatDateTime(item.createdAt, locale)}
|
{formatDateTime(item.createdAt, locale)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -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-success { color: var(--green); background: var(--green-soft); border-color: #82c7a1; }
|
||||||
.banner summary { cursor: pointer; font-weight: 600; }
|
.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 {
|
.modal {
|
||||||
width: min(430px, 100vw);
|
width: min(430px, 100vw);
|
||||||
max-width: none;
|
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-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; }
|
.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 {
|
.steps {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
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 li { padding: 10px 0; color: #d5d5d1; border-bottom: 1px solid #323230; font-size: 12px; }
|
||||||
.decision-panel p { color: #b9b9b5; font-size: 12px; }
|
.decision-panel p { color: #b9b9b5; font-size: 12px; }
|
||||||
.decision-panel .btn { width: 100%; color: #a8a8a3; background: #2e2e2c; border: 0; }
|
.decision-panel .btn { width: 100%; color: #a8a8a3; background: #2e2e2c; border: 0; }
|
||||||
|
.decision-panel .blocker-link { color: #a9c4ff; }
|
||||||
.category-pulse { padding-top: 4px; }
|
.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-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); }
|
.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;
|
inset: auto 0 0;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(6, 1fr);
|
||||||
min-height: 62px;
|
min-height: 62px;
|
||||||
background: var(--white);
|
background: var(--white);
|
||||||
border-top: 1px solid var(--line);
|
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 { justify-content: flex-end; }
|
||||||
.page-actions-row { justify-content: flex-end; }
|
.page-actions-row { justify-content: flex-end; }
|
||||||
.page-actions .btn { min-height: 34px; padding-inline: 10px; font-size: 11px; }
|
.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; }
|
.stats { grid-template-columns: repeat(3, minmax(0, 1fr)); margin-inline: -14px; }
|
||||||
.stat { padding: 14px 10px; }
|
.stat { padding: 14px 10px; }
|
||||||
.stat:nth-child(n + 4) { display: none; }
|
.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 { color: var(--ink); }
|
||||||
.decision-panel li { border-color: #e0c57f; }
|
.decision-panel li { border-color: #e0c57f; }
|
||||||
.decision-panel .btn { color: var(--ink); background: transparent; border: 1px solid var(--ink); }
|
.decision-panel .btn { color: var(--ink); background: transparent; border: 1px solid var(--ink); }
|
||||||
|
.decision-panel .blocker-link { color: var(--blue); }
|
||||||
|
.manager-form { grid-template-columns: 1fr; }
|
||||||
|
.manager-row-actions { width: 100%; }
|
||||||
|
.manager-row-actions .btn { flex: 1; }
|
||||||
.pulse-grid { display: block; border: 0; }
|
.pulse-grid { display: block; border: 0; }
|
||||||
.pulse-item { padding: 13px 0; border-width: 0 0 1px; background: transparent; }
|
.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; }
|
.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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -28,6 +28,8 @@ function account(overrides: Partial<Account> = {}): Account {
|
|||||||
maskedNumber: '•• 4021',
|
maskedNumber: '•• 4021',
|
||||||
openedOn: '2026-01-15',
|
openedOn: '2026-01-15',
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
|
systemKey: null,
|
||||||
|
nameKey: null,
|
||||||
balanceKopecks: 123456,
|
balanceKopecks: 123456,
|
||||||
...overrides,
|
...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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -46,6 +46,7 @@ const categories: BudgetCategory[] = [
|
|||||||
name: null,
|
name: null,
|
||||||
nameKey: 'groceries',
|
nameKey: 'groceries',
|
||||||
kind: 'monthly',
|
kind: 'monthly',
|
||||||
|
system: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
creditAccountId: null,
|
creditAccountId: null,
|
||||||
plannedKopecks: 200000,
|
plannedKopecks: 200000,
|
||||||
@@ -58,6 +59,7 @@ const categories: BudgetCategory[] = [
|
|||||||
name: 'Buffer',
|
name: 'Buffer',
|
||||||
nameKey: null,
|
nameKey: null,
|
||||||
kind: 'savings_envelope',
|
kind: 'savings_envelope',
|
||||||
|
system: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
creditAccountId: null,
|
creditAccountId: null,
|
||||||
plannedKopecks: 0,
|
plannedKopecks: 0,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -32,6 +32,7 @@ function detail(status: ImportStatus): ImportDetail {
|
|||||||
accountName: 'Alfa Card',
|
accountName: 'Alfa Card',
|
||||||
periodFrom: null,
|
periodFrom: null,
|
||||||
periodTo: null,
|
periodTo: null,
|
||||||
|
unresolvedHoldCount: 0,
|
||||||
createdAt: '2026-09-01T10:00:00Z',
|
createdAt: '2026-09-01T10:00:00Z',
|
||||||
failureReason: null,
|
failureReason: null,
|
||||||
blockers: [],
|
blockers: [],
|
||||||
@@ -44,7 +45,15 @@ function detail(status: ImportStatus): ImportDetail {
|
|||||||
reason: null,
|
reason: null,
|
||||||
},
|
},
|
||||||
rows: [],
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,28 +2,28 @@
|
|||||||
"0": "identity/service.py",
|
"0": "identity/service.py",
|
||||||
"1": "budgeting/router.py",
|
"1": "budgeting/router.py",
|
||||||
"2": "Base",
|
"2": "Base",
|
||||||
"3": "set_space_context",
|
"3": "exports/service.py",
|
||||||
"4": "imports/service.py",
|
"4": "imports/service.py",
|
||||||
"5": "alfa_xlsx.py",
|
"5": "alfa_xlsx.py",
|
||||||
"6": "index.ts",
|
"6": "system-account-picker.test.tsx",
|
||||||
"7": "types.ts",
|
"7": "types.ts",
|
||||||
"8": "router.tsx",
|
"8": "router.tsx",
|
||||||
"9": "ledger/router.py",
|
"9": "ledger/router.py",
|
||||||
"10": "@testing-library/jest-dom",
|
"10": "@testing-library/jest-dom",
|
||||||
"11": "compilerOptions",
|
"11": "compilerOptions",
|
||||||
"12": "AnalyticsPage.tsx",
|
"12": "BudgetPage.tsx",
|
||||||
"13": "@testing-library/user-event",
|
"13": "@testing-library/user-event",
|
||||||
"14": "devDependencies",
|
"14": "devDependencies",
|
||||||
"15": "Session",
|
"15": "Session",
|
||||||
"16": "dependencies",
|
"16": "dependencies",
|
||||||
"17": "ImportDetailPage.tsx",
|
"17": "ImportDetailPage.tsx",
|
||||||
"18": "OperationsPage.tsx",
|
"18": "OperationsPage.tsx",
|
||||||
"19": "SettingsPage.tsx",
|
"19": "useAccounts.ts",
|
||||||
"20": "Toaster.tsx",
|
"20": "index.ts",
|
||||||
"21": "test_adapter_contracts.py",
|
"21": "test_adapter_contracts.py",
|
||||||
"22": "BudgetPage.tsx",
|
"22": "AccountsPage.tsx",
|
||||||
"23": "client.ts",
|
"23": "client.ts",
|
||||||
"24": "AccountsPage.tsx",
|
"24": "fields.tsx",
|
||||||
"25": "Docker Compose Development Stack",
|
"25": "Docker Compose Development Stack",
|
||||||
"26": "Financial Space",
|
"26": "Financial Space",
|
||||||
"27": "scripts",
|
"27": "scripts",
|
||||||
@@ -43,10 +43,13 @@
|
|||||||
"41": "test_api.py",
|
"41": "test_api.py",
|
||||||
"42": "i18n-parity.test.ts",
|
"42": "i18n-parity.test.ts",
|
||||||
"43": "Expected Income",
|
"43": "Expected Income",
|
||||||
"44": "TestAlfaDebitContract",
|
"44": "helpers.tsx",
|
||||||
|
"45": "20260922_0003_savings_system.py",
|
||||||
|
"46": "category-manager.test.tsx",
|
||||||
"47": "graphify",
|
"47": "graphify",
|
||||||
"48": "@types/react-dom",
|
"48": "@types/react-dom",
|
||||||
"49": "typescript-eslint",
|
"49": "typescript-eslint",
|
||||||
|
"50": "Ближайший край: накопления, категории и блокировки бюджета",
|
||||||
"51": "vitest",
|
"51": "vitest",
|
||||||
"52": "Favicon SVG (ledger lines icon)",
|
"52": "Favicon SVG (ledger lines icon)",
|
||||||
"53": "init-app-role.sh",
|
"53": "init-app-role.sh",
|
||||||
@@ -54,7 +57,7 @@
|
|||||||
"55": "spaces/__init__.py",
|
"55": "spaces/__init__.py",
|
||||||
"56": "Payee",
|
"56": "Payee",
|
||||||
"57": "eslint.config.js",
|
"57": "eslint.config.js",
|
||||||
"58": "money.test.ts",
|
"58": "spaces/router.py",
|
||||||
"59": "vite-env.d.ts",
|
"59": "vite-env.d.ts",
|
||||||
"60": "vite.config.ts",
|
"60": "vite.config.ts",
|
||||||
"61": "finance-backend",
|
"61": "finance-backend",
|
||||||
@@ -63,9 +66,22 @@
|
|||||||
"64": "Gitea CI and real statement imports",
|
"64": "Gitea CI and real statement imports",
|
||||||
"65": "20260918_0002_hold_lifecycle.py",
|
"65": "20260918_0002_hold_lifecycle.py",
|
||||||
"66": "eslint-plugin-react-hooks",
|
"66": "eslint-plugin-react-hooks",
|
||||||
|
"67": "main.py",
|
||||||
|
"68": "current_session",
|
||||||
|
"69": "TestAlfaDebitContract",
|
||||||
"70": "vite",
|
"70": "vite",
|
||||||
"73": "AppError",
|
"71": "rocketbank_pdf.py",
|
||||||
|
"72": "TestAlfaSavingsRule",
|
||||||
|
"73": "savings-convert-revert.test.tsx",
|
||||||
|
"74": "api",
|
||||||
|
"75": "ColumnChart.tsx",
|
||||||
"76": "get_settings",
|
"76": "get_settings",
|
||||||
"77": "AGENTS.md",
|
"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 @@
|
|||||||
{"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
@@ -1,16 +1,16 @@
|
|||||||
# Graph Report - finance (2026-09-22)
|
# Graph Report - finance (2026-09-23)
|
||||||
|
|
||||||
## Corpus Check
|
## Corpus Check
|
||||||
- 153 files · ~65,647 words
|
- 178 files · ~83,334 words
|
||||||
- Verdict: corpus is large enough that graph structure adds value.
|
- Verdict: corpus is large enough that graph structure adds value.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
- 1288 nodes · 3759 edges · 69 communities (47 shown, 15 thin omitted)
|
- 1567 nodes · 4903 edges · 85 communities (62 shown, 17 thin omitted)
|
||||||
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 71 edges (avg confidence: 0.93)
|
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 91 edges (avg confidence: 0.93)
|
||||||
- Token cost: 0 input · 0 output
|
- Token cost: 0 input · 0 output
|
||||||
|
|
||||||
## Graph Freshness
|
## 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 `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||||
- Run `graphify update .` after code changes (no API cost).
|
- Run `graphify update .` after code changes (no API cost).
|
||||||
|
|
||||||
@@ -18,28 +18,28 @@
|
|||||||
- identity/service.py
|
- identity/service.py
|
||||||
- budgeting/router.py
|
- budgeting/router.py
|
||||||
- Base
|
- Base
|
||||||
- set_space_context
|
- exports/service.py
|
||||||
- imports/service.py
|
- imports/service.py
|
||||||
- alfa_xlsx.py
|
- alfa_xlsx.py
|
||||||
- index.ts
|
- system-account-picker.test.tsx
|
||||||
- types.ts
|
- types.ts
|
||||||
- router.tsx
|
- router.tsx
|
||||||
- ledger/router.py
|
- ledger/router.py
|
||||||
- @testing-library/jest-dom
|
- @testing-library/jest-dom
|
||||||
- compilerOptions
|
- compilerOptions
|
||||||
- AnalyticsPage.tsx
|
- BudgetPage.tsx
|
||||||
- @testing-library/user-event
|
- @testing-library/user-event
|
||||||
- devDependencies
|
- devDependencies
|
||||||
- Session
|
- Session
|
||||||
- dependencies
|
- dependencies
|
||||||
- ImportDetailPage.tsx
|
- ImportDetailPage.tsx
|
||||||
- OperationsPage.tsx
|
- OperationsPage.tsx
|
||||||
- SettingsPage.tsx
|
- useAccounts.ts
|
||||||
- Toaster.tsx
|
- index.ts
|
||||||
- test_adapter_contracts.py
|
- test_adapter_contracts.py
|
||||||
- BudgetPage.tsx
|
|
||||||
- client.ts
|
|
||||||
- AccountsPage.tsx
|
- AccountsPage.tsx
|
||||||
|
- client.ts
|
||||||
|
- fields.tsx
|
||||||
- Docker Compose Development Stack
|
- Docker Compose Development Stack
|
||||||
- Financial Space
|
- Financial Space
|
||||||
- scripts
|
- scripts
|
||||||
@@ -59,36 +59,53 @@
|
|||||||
- test_api.py
|
- test_api.py
|
||||||
- i18n-parity.test.ts
|
- i18n-parity.test.ts
|
||||||
- Expected Income
|
- Expected Income
|
||||||
- TestAlfaDebitContract
|
- helpers.tsx
|
||||||
|
- 20260922_0003_savings_system.py
|
||||||
|
- category-manager.test.tsx
|
||||||
- graphify
|
- graphify
|
||||||
- @types/react-dom
|
- @types/react-dom
|
||||||
- typescript-eslint
|
- typescript-eslint
|
||||||
|
- Ближайший край: накопления, категории и блокировки бюджета
|
||||||
- vitest
|
- vitest
|
||||||
- Favicon SVG (ledger lines icon)
|
- Favicon SVG (ledger lines icon)
|
||||||
- init-app-role.sh
|
- init-app-role.sh
|
||||||
- Payee
|
- Payee
|
||||||
|
- spaces/router.py
|
||||||
- finance-backend
|
- finance-backend
|
||||||
- LocalDirectoryBlobStore
|
- LocalDirectoryBlobStore
|
||||||
- TestOzonDebitContract
|
- TestOzonDebitContract
|
||||||
- Gitea CI and real statement imports
|
- Gitea CI and real statement imports
|
||||||
- 20260918_0002_hold_lifecycle.py
|
- 20260918_0002_hold_lifecycle.py
|
||||||
- eslint-plugin-react-hooks
|
- eslint-plugin-react-hooks
|
||||||
|
- main.py
|
||||||
|
- current_session
|
||||||
|
- TestAlfaDebitContract
|
||||||
- vite
|
- vite
|
||||||
- AppError
|
- rocketbank_pdf.py
|
||||||
|
- TestAlfaSavingsRule
|
||||||
|
- savings-convert-revert.test.tsx
|
||||||
|
- api
|
||||||
|
- ColumnChart.tsx
|
||||||
- get_settings
|
- get_settings
|
||||||
- AGENTS.md
|
- 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)
|
## God Nodes (most connected - your core abstractions)
|
||||||
1. `Session` - 146 edges
|
1. `Session` - 198 edges
|
||||||
2. `AppError` - 54 edges
|
2. `AppError` - 73 edges
|
||||||
3. `Base` - 47 edges
|
3. `Base` - 47 edges
|
||||||
4. `PkMixin` - 39 edges
|
4. `conflict()` - 42 edges
|
||||||
5. `get_settings()` - 38 edges
|
5. `get_settings()` - 39 edges
|
||||||
6. `TimestampMixin` - 38 edges
|
6. `PkMixin` - 39 edges
|
||||||
7. `conflict()` - 35 edges
|
7. `record()` - 38 edges
|
||||||
8. `record()` - 34 edges
|
8. `TimestampMixin` - 38 edges
|
||||||
9. `StatementDocument` - 32 edges
|
9. `StatementDocument` - 36 edges
|
||||||
10. `UnsupportedStatementLayout` - 30 edges
|
10. `create_expense()` - 35 edges
|
||||||
|
|
||||||
## Surprising Connections (you probably didn't know these)
|
## Surprising Connections (you probably didn't know these)
|
||||||
- `Typed Same-Origin API Client` --conceptually_related_to--> `Same-Origin Reverse Proxy` [INFERRED]
|
- `Typed Same-Origin API Client` --conceptually_related_to--> `Same-Origin Reverse Proxy` [INFERRED]
|
||||||
@@ -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]
|
- **Financial Space Tenancy Boundary** — context_financial_space, context_membership, docs_adr_0001_financial_space_ownership_and_rls_defense_in_depth_authorization, docs_adr_0001_financial_space_ownership_and_rls_transaction_local_rls, docs_adr_0001_financial_space_ownership_and_rls_cross_space_integrity [EXTRACTED 1.00]
|
||||||
- **Safe Statement Import Flow** — context_statement_import, context_parse_run, context_source_record, docs_adr_0003_versioned_statement_imports_immutable_statement_document, docs_adr_0003_versioned_statement_imports_conservative_deduplication, docs_adr_0003_versioned_statement_imports_atomic_confirm_revert [EXTRACTED 1.00]
|
- **Safe Statement Import Flow** — context_statement_import, context_parse_run, context_source_record, docs_adr_0003_versioned_statement_imports_immutable_statement_document, docs_adr_0003_versioned_statement_imports_conservative_deduplication, docs_adr_0003_versioned_statement_imports_atomic_confirm_revert [EXTRACTED 1.00]
|
||||||
|
|
||||||
## Communities (69 total, 15 thin omitted)
|
## Communities (85 total, 17 thin omitted)
|
||||||
|
|
||||||
### Community 0 - "identity/service.py"
|
### Community 0 - "identity/service.py"
|
||||||
Cohesion: 0.07
|
Cohesion: 0.28
|
||||||
Nodes (58): current_context(), current_session(), alias, Context, CurrentSession, Db, Header, User (+50 more)
|
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"
|
### Community 1 - "budgeting/router.py"
|
||||||
Cohesion: 0.20
|
Cohesion: 0.13
|
||||||
Nodes (34): AssignmentItem, assignments(), AssignmentsBody, categories(), CategoryBody, CategoryFundingBody, close(), close_frontend() (+26 more)
|
Nodes (48): balances(), cash_flow(), category_spend(), plan_fact(), Context, date, Db, get (+40 more)
|
||||||
|
|
||||||
### Community 2 - "Base"
|
### Community 2 - "Base"
|
||||||
Cohesion: 0.07
|
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"
|
### Community 3 - "exports/service.py"
|
||||||
Cohesion: 0.06
|
Cohesion: 0.10
|
||||||
Nodes (52): balances(), cash_flow(), category_spend(), plan_fact(), Context, date, Db, get (+44 more)
|
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"
|
### Community 4 - "imports/service.py"
|
||||||
Cohesion: 0.06
|
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"
|
### Community 5 - "alfa_xlsx.py"
|
||||||
Cohesion: 0.17
|
Cohesion: 0.15
|
||||||
Nodes (24): AlfaDebitXlsxAdapter, _AlfaHeaderMeta, AlfaXlsxAdapterBase, _cell_str(), _debit_find_header(), _debit_header_metadata(), _debit_header_value(), _debit_norm() (+16 more)
|
Nodes (27): AlfaCreditXlsxAdapter, AlfaDebitXlsxAdapter, _AlfaHeaderMeta, AlfaXlsxAdapterBase, _cell_str(), _debit_find_header(), _debit_header_metadata(), _debit_header_value() (+19 more)
|
||||||
|
|
||||||
### Community 6 - "index.ts"
|
### Community 6 - "system-account-picker.test.tsx"
|
||||||
Cohesion: 0.26
|
Cohesion: 0.15
|
||||||
Nodes (8): Locale, LangSwitcher(), createI18n(), detectLocale(), i18n, LOCALES, setLocale(), storedLocale()
|
Nodes (12): AdjustBalanceResult, BudgetCategory, Operation, Page, categoriesMock, importsListMock, importSummary, operationsListMock (+4 more)
|
||||||
|
|
||||||
### Community 7 - "types.ts"
|
### Community 7 - "types.ts"
|
||||||
Cohesion: 0.06
|
Cohesion: 0.08
|
||||||
Nodes (47): api, Account, AccountStatus, BalanceItem, BalancesReport, BudgetBlocker, BudgetCategory, BudgetStatus (+39 more)
|
Nodes (35): AccountStatus, AccountType, BalanceItem, BalancesReport, BudgetStatus, BudgetWarning, CashFlowMonth, CategoriesBlocker (+27 more)
|
||||||
|
|
||||||
### Community 8 - "router.tsx"
|
### Community 8 - "router.tsx"
|
||||||
Cohesion: 0.10
|
Cohesion: 0.07
|
||||||
Nodes (33): AcceptInvitePayload, LoginPayload, PasswordResetPayload, AcceptInvitePage, BudgetPage, ImportDetailPage, LoginPage, OperationsPage (+25 more)
|
Nodes (49): isApiError(), AcceptInvitePayload, LoginPayload, PasswordResetPayload, App(), queryClient, AcceptInvitePage, AccountsPage (+41 more)
|
||||||
|
|
||||||
### Community 9 - "ledger/router.py"
|
### Community 9 - "ledger/router.py"
|
||||||
Cohesion: 0.15
|
Cohesion: 0.16
|
||||||
Nodes (31): account_json(), AccountBody, accounts(), archive_account(), create_account(), create_operation(), delete_account(), operation_json() (+23 more)
|
Nodes (35): account_json(), AccountBody, accounts(), adjust_balance(), AdjustBalanceBody, archive_account(), create_account(), create_operation() (+27 more)
|
||||||
|
|
||||||
### Community 11 - "compilerOptions"
|
### Community 11 - "compilerOptions"
|
||||||
Cohesion: 0.07
|
Cohesion: 0.07
|
||||||
Nodes (28): compilerOptions, baseUrl, esModuleInterop, forceConsistentCasingInFileNames, isolatedModules, jsx, lib, module (+20 more)
|
Nodes (28): compilerOptions, baseUrl, esModuleInterop, forceConsistentCasingInFileNames, isolatedModules, jsx, lib, module (+20 more)
|
||||||
|
|
||||||
### Community 12 - "AnalyticsPage.tsx"
|
### Community 12 - "BudgetPage.tsx"
|
||||||
Cohesion: 0.12
|
Cohesion: 0.08
|
||||||
Nodes (19): AnalyticsPage, CashFlowColumn, ColumnChart(), ColumnChartProps, BarRow, HorizontalBars(), HorizontalBarsProps, PlanFactBars() (+11 more)
|
Nodes (53): NotFoundPage, CategoryManagerModal(), CreateCategoryForm(), RenameModal(), PlanFactBars(), PlanFactBarsProps, PlanFactRow, MonthSwitcher() (+45 more)
|
||||||
|
|
||||||
### Community 14 - "devDependencies"
|
### Community 14 - "devDependencies"
|
||||||
Cohesion: 0.10
|
Cohesion: 0.10
|
||||||
Nodes (21): eslint, @eslint/js, eslint-plugin-jsx-a11y, devDependencies, eslint, @eslint/js, eslint-plugin-jsx-a11y, globals (+13 more)
|
Nodes (21): eslint, @eslint/js, eslint-plugin-jsx-a11y, devDependencies, eslint, @eslint/js, eslint-plugin-jsx-a11y, globals (+13 more)
|
||||||
|
|
||||||
### Community 15 - "Session"
|
### Community 15 - "Session"
|
||||||
Cohesion: 0.07
|
Cohesion: 0.05
|
||||||
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)
|
Nodes (165): balances(), cash_flow(), category_spend(), plan_fact(), date, UUID, Any, record() (+157 more)
|
||||||
|
|
||||||
### Community 16 - "dependencies"
|
### Community 16 - "dependencies"
|
||||||
Cohesion: 0.09
|
Cohesion: 0.09
|
||||||
Nodes (23): @fontsource/funnel-sans, @fontsource/geist-mono, dependencies, @fontsource/funnel-sans, @fontsource/geist-mono, @hookform/resolvers, i18next, react (+15 more)
|
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"
|
### Community 17 - "ImportDetailPage.tsx"
|
||||||
Cohesion: 0.20
|
Cohesion: 0.16
|
||||||
Nodes (18): Button(), ConfirmDialog(), ConfirmDialogProps, Modal(), ModalProps, useToast(), useConfirmImport(), useImport() (+10 more)
|
Nodes (23): HoldResolutionAction, ImportStatus, RowDecision, useToast(), useConfirmImport(), useImport(), useImportErrorToaster(), useReparseImport() (+15 more)
|
||||||
|
|
||||||
### Community 18 - "OperationsPage.tsx"
|
### Community 18 - "OperationsPage.tsx"
|
||||||
Cohesion: 0.11
|
Cohesion: 0.12
|
||||||
Nodes (30): Operation, OperationKind, OperationStatus, ImportsPage, Badge(), BadgeTone, EmptyState(), EmptyStateProps (+22 more)
|
Nodes (43): isSystemAccount(), Locale, useAccounts(), useActiveAccounts(), useUserAccounts(), useCategorizeOperation(), useConvertToSavings(), useCreateOperation() (+35 more)
|
||||||
|
|
||||||
### Community 19 - "SettingsPage.tsx"
|
### Community 19 - "useAccounts.ts"
|
||||||
Cohesion: 0.16
|
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"
|
### Community 20 - "index.ts"
|
||||||
Cohesion: 0.18
|
Cohesion: 0.16
|
||||||
Nodes (10): App(), queryClient, AppRoutes(), ToastApi, ToastContext, ToastItem, ToastKind, ToastProvider() (+2 more)
|
Nodes (13): BudgetBlocker, blockerCta(), blockerText(), BudgetBlockers(), BudgetBlockersProps, Cta, LangSwitcher(), createI18n() (+5 more)
|
||||||
|
|
||||||
### Community 21 - "test_adapter_contracts.py"
|
### Community 21 - "test_adapter_contracts.py"
|
||||||
Cohesion: 0.11
|
Cohesion: 0.12
|
||||||
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)
|
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"
|
### Community 22 - "AccountsPage.tsx"
|
||||||
Cohesion: 0.17
|
Cohesion: 0.12
|
||||||
Nodes (20): NotFoundPage, OverviewPage, PageHeader(), PageHeaderProps, useBudgetCategories(), useBudgetErrorToaster(), useBudgetSummary(), useCloseMonth() (+12 more)
|
Nodes (23): Account, CREATE_KINDS, Badge(), BadgeTone, Button(), ButtonProps, Variant, ConfirmDialog() (+15 more)
|
||||||
|
|
||||||
### Community 23 - "client.ts"
|
### Community 23 - "client.ts"
|
||||||
Cohesion: 0.23
|
Cohesion: 0.26
|
||||||
Nodes (10): apiClient, ApiError, buildUrl(), ErrorEnvelope, parseErrorEnvelope(), QueryValue, readCsrfToken(), request() (+2 more)
|
Nodes (10): apiClient, ApiError, buildUrl(), ErrorEnvelope, parseErrorEnvelope(), QueryValue, readCsrfToken(), request() (+2 more)
|
||||||
|
|
||||||
### Community 24 - "AccountsPage.tsx"
|
### Community 24 - "fields.tsx"
|
||||||
Cohesion: 0.13
|
Cohesion: 0.23
|
||||||
Nodes (21): AccountType, CreateAccountPayload, AccountsPage, FieldChrome, InputProps, MoneyField(), MoneyFieldProps, SelectField() (+13 more)
|
Nodes (10): FieldChrome, InputProps, MoneyField(), MoneyFieldProps, SelectFieldProps, SelectProps, TextareaField(), TextareaFieldProps (+2 more)
|
||||||
|
|
||||||
### Community 25 - "Docker Compose Development Stack"
|
### Community 25 - "Docker Compose Development Stack"
|
||||||
Cohesion: 0.29
|
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)
|
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"
|
### Community 38 - "TestRocketbankDebitContract"
|
||||||
Cohesion: 0.24
|
Cohesion: 0.13
|
||||||
Nodes (3): same_page_rocket_lines(), synthetic_rocket_lines(), TestRocketbankDebitContract
|
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"
|
### Community 39 - "generate-api.mjs"
|
||||||
Cohesion: 0.40
|
Cohesion: 0.40
|
||||||
@@ -268,22 +285,34 @@ Nodes (5): components, $defs, operations, paths, webhooks
|
|||||||
Cohesion: 0.67
|
Cohesion: 0.67
|
||||||
Nodes (3): TestClient, test_invite_login_and_authenticated_space(), test_openapi_contains_critical_paths()
|
Nodes (3): TestClient, test_invite_login_and_authenticated_space(), test_openapi_contains_critical_paths()
|
||||||
|
|
||||||
### Community 44 - "TestAlfaDebitContract"
|
### Community 44 - "helpers.tsx"
|
||||||
Cohesion: 0.08
|
Cohesion: 0.16
|
||||||
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)
|
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"
|
### Community 47 - "graphify"
|
||||||
Cohesion: 0.17
|
Cohesion: 0.17
|
||||||
Nodes (11): command, enabled, timeout, type, mcp, graphify, plugin, $schema (+3 more)
|
Nodes (11): command, enabled, timeout, type, mcp, graphify, plugin, $schema (+3 more)
|
||||||
|
|
||||||
|
### Community 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"
|
### Community 62 - "LocalDirectoryBlobStore"
|
||||||
Cohesion: 0.39
|
Cohesion: 0.39
|
||||||
Nodes (3): LocalDirectoryBlobStore, Path, S3-neutral local store; production swaps in an S3-compatible client.
|
Nodes (3): LocalDirectoryBlobStore, Path, S3-neutral local store; production swaps in an S3-compatible client.
|
||||||
|
|
||||||
### Community 63 - "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"
|
### Community 64 - "Gitea CI and real statement imports"
|
||||||
Cohesion: 0.29
|
Cohesion: 0.29
|
||||||
Nodes (6): Confirmed decisions, Gitea CI and real statement imports, Goal and constraints, Implementation plan, Principal risks, Validation and rollout
|
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
|
Cohesion: 0.80
|
||||||
Nodes (4): downgrade(), _has_check_constraint(), _has_column(), upgrade()
|
Nodes (4): downgrade(), _has_check_constraint(), _has_column(), upgrade()
|
||||||
|
|
||||||
### Community 73 - "AppError"
|
### Community 67 - "main.py"
|
||||||
Cohesion: 0.21
|
Cohesion: 0.13
|
||||||
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)
|
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"
|
### Community 76 - "get_settings"
|
||||||
Cohesion: 0.12
|
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
|
## 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)
|
- **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; 343 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
|
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 429 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.
|
- **17 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||||
|
|
||||||
## Suggested Questions
|
## Suggested Questions
|
||||||
_Questions this graph is uniquely positioned to answer:_
|
_Questions this graph is uniquely positioned to answer:_
|
||||||
|
|
||||||
- **Why does `Session` connect `Session` to `identity/service.py`, `Base`, `set_space_context`, `imports/service.py`?**
|
- **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.082) - this node is a cross-community bridge._
|
_High betweenness centrality (0.063) - 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`?**
|
- **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.068) - this node is a cross-community bridge._
|
_High betweenness centrality (0.050) - 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`?**
|
- **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.017) - this node is a cross-community bridge._
|
_High betweenness centrality (0.018) - 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?**
|
- **Are the 3 inferred relationships involving `Session` (e.g. with `upgrade()` and `UTCDateTime`) actually correct?**
|
||||||
_`Session` has 2 INFERRED edges - model-reasoned connections that need verification._
|
_`Session` has 3 INFERRED edges - model-reasoned connections that need verification._
|
||||||
- **Are the 24 inferred relationships involving `AppError` (e.g. with `request_export()` and `login()`) actually correct?**
|
- **Are the 40 inferred relationships involving `AppError` (e.g. with `request_export()` and `login()`) actually correct?**
|
||||||
_`AppError` has 24 INFERRED edges - model-reasoned connections that need verification._
|
_`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?**
|
- **Are the 3 inferred relationships involving `Base` (e.g. with `downgrade()` and `upgrade()`) actually correct?**
|
||||||
_`Base` has 3 INFERRED edges - model-reasoned connections that need verification._
|
_`Base` has 3 INFERRED edges - model-reasoned connections that need verification._
|
||||||
- **What connects `$schema`, `file:///home/k1nq/repos/finance/.kilo/plugins/graphify.js`, `type` to the rest of the system?**
|
- **What connects `$schema`, `file:///home/k1nq/repos/finance/.kilo/plugins/graphify.js`, `type` to the rest of the system?**
|
||||||
_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
File diff suppressed because it is too large
Load Diff
+304
-154
@@ -24,16 +24,16 @@
|
|||||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||||
},
|
},
|
||||||
"backend/app/analytics/router.py": {
|
"backend/app/analytics/router.py": {
|
||||||
"mtime": 1789129123.9786532,
|
"mtime": 1790061745.2630062,
|
||||||
"seen": 1789377303.0691855,
|
"seen": 1790096479.536423,
|
||||||
"ast_hash": "dc4d5f43d1e17ae77b21f774d37c1106",
|
"ast_hash": "6207eef3120bdd3ae7a9692f17e80134",
|
||||||
"semantic_hash": "dc4d5f43d1e17ae77b21f774d37c1106"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/app/analytics/service.py": {
|
"backend/app/analytics/service.py": {
|
||||||
"mtime": 1789129314.8210335,
|
"mtime": 1790061159.3943744,
|
||||||
"seen": 1789377303.0691862,
|
"seen": 1790096479.536424,
|
||||||
"ast_hash": "0385e76cccd5d140b8cc01af5b74821e",
|
"ast_hash": "58ac24a62b4cf8a986527f79d456127d",
|
||||||
"semantic_hash": "0385e76cccd5d140b8cc01af5b74821e"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/app/audit/__init__.py": {
|
"backend/app/audit/__init__.py": {
|
||||||
"mtime": 1789127312.3538313,
|
"mtime": 1789127312.3538313,
|
||||||
@@ -66,15 +66,15 @@
|
|||||||
"semantic_hash": "039081cab3d91704e8f8a7152a981304"
|
"semantic_hash": "039081cab3d91704e8f8a7152a981304"
|
||||||
},
|
},
|
||||||
"backend/app/budgeting/router.py": {
|
"backend/app/budgeting/router.py": {
|
||||||
"mtime": 1789129314.8240335,
|
"mtime": 1790097111.8510067,
|
||||||
"seen": 1789377303.0691917,
|
"seen": 1790129548.0529666,
|
||||||
"ast_hash": "c352243e4fb85ab49f9cf4532c11bd3f",
|
"ast_hash": "64e5050629404acc7877b8489944f28f",
|
||||||
"semantic_hash": "c352243e4fb85ab49f9cf4532c11bd3f"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/app/budgeting/service.py": {
|
"backend/app/budgeting/service.py": {
|
||||||
"mtime": 1789730971.072982,
|
"mtime": 1790129321.9786477,
|
||||||
"seen": 1789735433.158112,
|
"seen": 1790129548.052968,
|
||||||
"ast_hash": "c30d4c58608f88b81ff05be63a314247",
|
"ast_hash": "9e7273a5b90092b3df2fc7eb89c7bde5",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/app/categorization/__init__.py": {
|
"backend/app/categorization/__init__.py": {
|
||||||
@@ -90,10 +90,10 @@
|
|||||||
"semantic_hash": "922083067aa2a24d67954296844db8f0"
|
"semantic_hash": "922083067aa2a24d67954296844db8f0"
|
||||||
},
|
},
|
||||||
"backend/app/categorization/service.py": {
|
"backend/app/categorization/service.py": {
|
||||||
"mtime": 1789128297.7359908,
|
"mtime": 1790060937.0384295,
|
||||||
"seen": 1789377303.0691953,
|
"seen": 1790096479.536434,
|
||||||
"ast_hash": "9590b3c99e7c2b20b40d895d7495b5cf",
|
"ast_hash": "175c114e437a93f2c4deb76c7bd7df2c",
|
||||||
"semantic_hash": "9590b3c99e7c2b20b40d895d7495b5cf"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/app/config.py": {
|
"backend/app/config.py": {
|
||||||
"mtime": 1789356583.024539,
|
"mtime": 1789356583.024539,
|
||||||
@@ -210,15 +210,15 @@
|
|||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/app/imports/adapters/alfa_xlsx.py": {
|
"backend/app/imports/adapters/alfa_xlsx.py": {
|
||||||
"mtime": 1789736201.215202,
|
"mtime": 1790061232.3863332,
|
||||||
"seen": 1789736272.9455209,
|
"seen": 1790096479.5364504,
|
||||||
"ast_hash": "31eee0cded845f9ddaff98d2127f4354",
|
"ast_hash": "6a3d3456eceef5ad3e2a79d2c43bcdc2",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/app/imports/adapters/base.py": {
|
"backend/app/imports/adapters/base.py": {
|
||||||
"mtime": 1789733778.7145572,
|
"mtime": 1790061188.1227522,
|
||||||
"seen": 1789735433.1581285,
|
"seen": 1790096479.536451,
|
||||||
"ast_hash": "4f6364ab07cf38ba2eea1ba94678d784",
|
"ast_hash": "f6bce881a99c0233fa6b129ddf0c255e",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/app/imports/adapters/ozon_pdf.py": {
|
"backend/app/imports/adapters/ozon_pdf.py": {
|
||||||
@@ -264,15 +264,15 @@
|
|||||||
"semantic_hash": "0904afccafd9eb544b4d6846fc6384c8"
|
"semantic_hash": "0904afccafd9eb544b4d6846fc6384c8"
|
||||||
},
|
},
|
||||||
"backend/app/imports/router.py": {
|
"backend/app/imports/router.py": {
|
||||||
"mtime": 1789987595.224261,
|
"mtime": 1790097243.6436644,
|
||||||
"seen": 1789987623.1673603,
|
"seen": 1790129548.052993,
|
||||||
"ast_hash": "561967cd45b365aeeb5f06dcba55e0a3",
|
"ast_hash": "5c3be5ef9ee835a376b463b59132ecef",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/app/imports/service.py": {
|
"backend/app/imports/service.py": {
|
||||||
"mtime": 1789987595.224261,
|
"mtime": 1790097243.6436644,
|
||||||
"seen": 1789987623.1673615,
|
"seen": 1790129548.0529938,
|
||||||
"ast_hash": "d78aede9091025373227eeea80758ffb",
|
"ast_hash": "2d46532c0c4294ac19adfbc4696506dd",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/app/imports/tasks.py": {
|
"backend/app/imports/tasks.py": {
|
||||||
@@ -288,22 +288,22 @@
|
|||||||
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||||
},
|
},
|
||||||
"backend/app/ledger/models.py": {
|
"backend/app/ledger/models.py": {
|
||||||
"mtime": 1789130236.8679283,
|
"mtime": 1790060833.8770478,
|
||||||
"seen": 1789377303.069223,
|
"seen": 1790096479.5364609,
|
||||||
"ast_hash": "81335e0d9f1f5a71be1626ec218da410",
|
"ast_hash": "86891c404082d8d2c84214f31d8616ff",
|
||||||
"semantic_hash": "81335e0d9f1f5a71be1626ec218da410"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/app/ledger/router.py": {
|
"backend/app/ledger/router.py": {
|
||||||
"mtime": 1789358018.8352346,
|
"mtime": 1790097195.1290338,
|
||||||
"seen": 1789377303.0692236,
|
"seen": 1790129548.052997,
|
||||||
"ast_hash": "809411054fbede0694ed6ee863723fe5",
|
"ast_hash": "cc108ddf12a8020198afe9d3d0847e2a",
|
||||||
"semantic_hash": "809411054fbede0694ed6ee863723fe5"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/app/ledger/service.py": {
|
"backend/app/ledger/service.py": {
|
||||||
"mtime": 1789358018.8349617,
|
"mtime": 1790077834.4411142,
|
||||||
"seen": 1789377303.0692244,
|
"seen": 1790096479.536463,
|
||||||
"ast_hash": "96a715590e5af44c494e5c7c817780a3",
|
"ast_hash": "9d5895e92011706e835b582aca3878c8",
|
||||||
"semantic_hash": "96a715590e5af44c494e5c7c817780a3"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/app/main.py": {
|
"backend/app/main.py": {
|
||||||
"mtime": 1789130118.4222198,
|
"mtime": 1789130118.4222198,
|
||||||
@@ -342,10 +342,10 @@
|
|||||||
"semantic_hash": "073fa39a355b257a832ead3830942d8f"
|
"semantic_hash": "073fa39a355b257a832ead3830942d8f"
|
||||||
},
|
},
|
||||||
"backend/app/spaces/service.py": {
|
"backend/app/spaces/service.py": {
|
||||||
"mtime": 1789129887.1068308,
|
"mtime": 1790060979.7579985,
|
||||||
"seen": 1789377303.0692303,
|
"seen": 1790096479.5364685,
|
||||||
"ast_hash": "99f21c94dad6403b77f4cafed97c1c2e",
|
"ast_hash": "214a4094630b761ea2a64de83d59317a",
|
||||||
"semantic_hash": "99f21c94dad6403b77f4cafed97c1c2e"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/app/worker.py": {
|
"backend/app/worker.py": {
|
||||||
"mtime": 1789129206.4285526,
|
"mtime": 1789129206.4285526,
|
||||||
@@ -402,8 +402,8 @@
|
|||||||
"semantic_hash": "ed54799a921d10394f826893acb46876"
|
"semantic_hash": "ed54799a921d10394f826893acb46876"
|
||||||
},
|
},
|
||||||
"frontend/package.json": {
|
"frontend/package.json": {
|
||||||
"mtime": 1789990591.2584393,
|
"mtime": 1790055350.366911,
|
||||||
"seen": 1790048203.8354623,
|
"seen": 1790096479.5364823,
|
||||||
"ast_hash": "172e2880b6084c1fc423ea759b99280a",
|
"ast_hash": "172e2880b6084c1fc423ea759b99280a",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
@@ -426,32 +426,32 @@
|
|||||||
"semantic_hash": "dbea075a006b7703340f7f93e69d53ac"
|
"semantic_hash": "dbea075a006b7703340f7f93e69d53ac"
|
||||||
},
|
},
|
||||||
"frontend/src/api/endpoints.ts": {
|
"frontend/src/api/endpoints.ts": {
|
||||||
"mtime": 1789357272.302138,
|
"mtime": 1790076936.1693087,
|
||||||
"seen": 1789377303.069242,
|
"seen": 1790096479.536486,
|
||||||
"ast_hash": "c4d7620bf50bef28b20286b6093e7e30",
|
"ast_hash": "bc61b518c946899ff0a9f4f00aaff9a0",
|
||||||
"semantic_hash": "c4d7620bf50bef28b20286b6093e7e30"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/api/generated/schema.d.ts": {
|
"frontend/src/api/generated/schema.d.ts": {
|
||||||
"mtime": 1789130160.8338337,
|
"mtime": 1790129418.1908941,
|
||||||
"seen": 1789377303.069243,
|
"seen": 1790129548.0530229,
|
||||||
"ast_hash": "8857d50fb909f019e1d8c03009c0ada7",
|
"ast_hash": "36e1ccd2ad9eb079ab1502d535ed90ff",
|
||||||
"semantic_hash": "8857d50fb909f019e1d8c03009c0ada7"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/api/queryKeys.ts": {
|
"frontend/src/api/queryKeys.ts": {
|
||||||
"mtime": 1789127044.43022,
|
"mtime": 1790076958.2303789,
|
||||||
"seen": 1789377303.0692437,
|
"seen": 1790096479.5364876,
|
||||||
"ast_hash": "5ec23457cf6b0f223df671d3bc0f8455",
|
"ast_hash": "e2bf898fcebf3d8b13b51dccfd6d34ba",
|
||||||
"semantic_hash": "5ec23457cf6b0f223df671d3bc0f8455"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/api/types.ts": {
|
"frontend/src/api/types.ts": {
|
||||||
"mtime": 1789357272.302138,
|
"mtime": 1790097111.8510067,
|
||||||
"seen": 1789377303.069248,
|
"seen": 1790129548.0530243,
|
||||||
"ast_hash": "0d3865b4525321cf5a6b360f72de5f5a",
|
"ast_hash": "744887bcd7ef47a37a6899108a5fcbc0",
|
||||||
"semantic_hash": "0d3865b4525321cf5a6b360f72de5f5a"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/app/router.tsx": {
|
"frontend/src/app/router.tsx": {
|
||||||
"mtime": 1789990322.8798492,
|
"mtime": 1790055350.367207,
|
||||||
"seen": 1790048203.8354712,
|
"seen": 1790096479.5364902,
|
||||||
"ast_hash": "9ae2869af5fbf84240281598d14e30b9",
|
"ast_hash": "9ae2869af5fbf84240281598d14e30b9",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
@@ -474,9 +474,9 @@
|
|||||||
"semantic_hash": "f581486baa48447ec4c573acef3190ef"
|
"semantic_hash": "f581486baa48447ec4c573acef3190ef"
|
||||||
},
|
},
|
||||||
"frontend/src/components/layout/AppLayout.tsx": {
|
"frontend/src/components/layout/AppLayout.tsx": {
|
||||||
"mtime": 1789990291.3144267,
|
"mtime": 1790096202.721176,
|
||||||
"seen": 1790048203.8354752,
|
"seen": 1790096479.5364962,
|
||||||
"ast_hash": "3077033bf2d5b262eaadeb8519b28160",
|
"ast_hash": "40d35a9e1278142b233541c6c104e289",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/components/layout/LangSwitcher.tsx": {
|
"frontend/src/components/layout/LangSwitcher.tsx": {
|
||||||
@@ -516,8 +516,8 @@
|
|||||||
"semantic_hash": "6827eb04b914e8c675e842fea74b5ba5"
|
"semantic_hash": "6827eb04b914e8c675e842fea74b5ba5"
|
||||||
},
|
},
|
||||||
"frontend/src/components/ui/Modal.tsx": {
|
"frontend/src/components/ui/Modal.tsx": {
|
||||||
"mtime": 1789990425.5642233,
|
"mtime": 1790055350.367207,
|
||||||
"seen": 1790048203.8354838,
|
"seen": 1790096479.5365033,
|
||||||
"ast_hash": "4250ebda526b86679ba5eb965feccf59",
|
"ast_hash": "4250ebda526b86679ba5eb965feccf59",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
@@ -558,10 +558,10 @@
|
|||||||
"semantic_hash": "5e1b157a5cd5b484582ba959049443aa"
|
"semantic_hash": "5e1b157a5cd5b484582ba959049443aa"
|
||||||
},
|
},
|
||||||
"frontend/src/hooks/useAccounts.ts": {
|
"frontend/src/hooks/useAccounts.ts": {
|
||||||
"mtime": 1789127463.8123653,
|
"mtime": 1790077275.5295525,
|
||||||
"seen": 1789377303.0692647,
|
"seen": 1790096479.5365093,
|
||||||
"ast_hash": "5cbd401bea752132870ca35a0b2cf29f",
|
"ast_hash": "2104274d4051595fe50ab8b55e99c1c7",
|
||||||
"semantic_hash": "5cbd401bea752132870ca35a0b2cf29f"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/hooks/useAnalytics.ts": {
|
"frontend/src/hooks/useAnalytics.ts": {
|
||||||
"mtime": 1789127476.179531,
|
"mtime": 1789127476.179531,
|
||||||
@@ -576,22 +576,22 @@
|
|||||||
"semantic_hash": "40f9f47b108ec0b484f9564bd1d319cb"
|
"semantic_hash": "40f9f47b108ec0b484f9564bd1d319cb"
|
||||||
},
|
},
|
||||||
"frontend/src/hooks/useBudget.ts": {
|
"frontend/src/hooks/useBudget.ts": {
|
||||||
"mtime": 1789127463.8123653,
|
"mtime": 1790077001.481125,
|
||||||
"seen": 1789377303.0692673,
|
"seen": 1790096479.5365117,
|
||||||
"ast_hash": "06c203b89322dca6cf80931f3558b0dc",
|
"ast_hash": "2ab1d3e626a5771c11c6f35b39d9055f",
|
||||||
"semantic_hash": "06c203b89322dca6cf80931f3558b0dc"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/hooks/useImports.ts": {
|
"frontend/src/hooks/useImports.ts": {
|
||||||
"mtime": 1789127476.176531,
|
"mtime": 1790077023.7244031,
|
||||||
"seen": 1789377303.0692682,
|
"seen": 1790096479.5365126,
|
||||||
"ast_hash": "d23f116da79b15438601751b45519268",
|
"ast_hash": "58c2ec19df6427aaf77fdc8120ce8f34",
|
||||||
"semantic_hash": "d23f116da79b15438601751b45519268"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/hooks/useOperations.ts": {
|
"frontend/src/hooks/useOperations.ts": {
|
||||||
"mtime": 1789127463.8123653,
|
"mtime": 1790076985.3479233,
|
||||||
"seen": 1789377303.069269,
|
"seen": 1790096479.5365133,
|
||||||
"ast_hash": "3cac9924554a5465276a2c4431121866",
|
"ast_hash": "26f72b4e30b26fe12d90b29dfb4c4246",
|
||||||
"semantic_hash": "3cac9924554a5465276a2c4431121866"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/hooks/useSpaceLifecycle.ts": {
|
"frontend/src/hooks/useSpaceLifecycle.ts": {
|
||||||
"mtime": 1789356647.3201435,
|
"mtime": 1789356647.3201435,
|
||||||
@@ -606,34 +606,34 @@
|
|||||||
"semantic_hash": "243512f6e095deeaa29393b7c831777f"
|
"semantic_hash": "243512f6e095deeaa29393b7c831777f"
|
||||||
},
|
},
|
||||||
"frontend/src/i18n/locales/en.json": {
|
"frontend/src/i18n/locales/en.json": {
|
||||||
"mtime": 1790048119.281922,
|
"mtime": 1790096831.314309,
|
||||||
"seen": 1790048203.835497,
|
"seen": 1790129548.0530467,
|
||||||
"ast_hash": "2a494738341ae850ef916befc380db56",
|
"ast_hash": "2d62cdde2e726baf5e6b9627d58372ec",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/i18n/locales/ru.json": {
|
"frontend/src/i18n/locales/ru.json": {
|
||||||
"mtime": 1790048119.281922,
|
"mtime": 1790096831.3153088,
|
||||||
"seen": 1790048203.8354976,
|
"seen": 1790129548.0530472,
|
||||||
"ast_hash": "08995a7a5ca546d034ab9ab05d5621a5",
|
"ast_hash": "aebf731fc4a592f2f20abd2d309e9a97",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/main.tsx": {
|
"frontend/src/main.tsx": {
|
||||||
"mtime": 1789990619.7178197,
|
"mtime": 1790055350.367207,
|
||||||
"seen": 1790048203.8354983,
|
"seen": 1790096479.5365248,
|
||||||
"ast_hash": "b88ee6ed2ae6eab1f3db7097b6912329",
|
"ast_hash": "b88ee6ed2ae6eab1f3db7097b6912329",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/pages/AcceptInvitePage.tsx": {
|
"frontend/src/pages/AcceptInvitePage.tsx": {
|
||||||
"mtime": 1789990425.548223,
|
"mtime": 1790055350.367207,
|
||||||
"seen": 1790048203.835499,
|
"seen": 1790096479.5365257,
|
||||||
"ast_hash": "936aaf3f5f5874de65e1bd31af620936",
|
"ast_hash": "936aaf3f5f5874de65e1bd31af620936",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/pages/AccountsPage.tsx": {
|
"frontend/src/pages/AccountsPage.tsx": {
|
||||||
"mtime": 1789127567.9147615,
|
"mtime": 1790097195.1318128,
|
||||||
"seen": 1789377303.0692747,
|
"seen": 1790129548.0530555,
|
||||||
"ast_hash": "5da5d66e5eb40222e4ceb1f75ca951bd",
|
"ast_hash": "e3646de544b00aa8a3ab294afb02e159",
|
||||||
"semantic_hash": "5da5d66e5eb40222e4ceb1f75ca951bd"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/pages/AnalyticsPage.tsx": {
|
"frontend/src/pages/AnalyticsPage.tsx": {
|
||||||
"mtime": 1789127904.7983484,
|
"mtime": 1789127904.7983484,
|
||||||
@@ -642,26 +642,26 @@
|
|||||||
"semantic_hash": "4a83070566fde66ff6423b1064cde4e8"
|
"semantic_hash": "4a83070566fde66ff6423b1064cde4e8"
|
||||||
},
|
},
|
||||||
"frontend/src/pages/BudgetPage.tsx": {
|
"frontend/src/pages/BudgetPage.tsx": {
|
||||||
"mtime": 1789127621.1764753,
|
"mtime": 1790097127.972161,
|
||||||
"seen": 1789377303.0692763,
|
"seen": 1790129548.053057,
|
||||||
"ast_hash": "c1421d4088b0f174dc7ecb082b2a257d",
|
"ast_hash": "7435e885343ed139a64dfbe152b44624",
|
||||||
"semantic_hash": "c1421d4088b0f174dc7ecb082b2a257d"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/pages/ImportDetailPage.tsx": {
|
"frontend/src/pages/ImportDetailPage.tsx": {
|
||||||
"mtime": 1790048024.9117486,
|
"mtime": 1790077605.334677,
|
||||||
"seen": 1790048203.8355024,
|
"seen": 1790096479.5365288,
|
||||||
"ast_hash": "ebd497c87a8600cf030d905bbdedcb94",
|
"ast_hash": "75923869914b7ef53b939676f9d5f095",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/pages/ImportsPage.tsx": {
|
"frontend/src/pages/ImportsPage.tsx": {
|
||||||
"mtime": 1789127830.2842758,
|
"mtime": 1790078666.8189514,
|
||||||
"seen": 1789377303.069278,
|
"seen": 1790096479.5365298,
|
||||||
"ast_hash": "721376253dda864ff81a5025691947e0",
|
"ast_hash": "89167c348b1cef4b9a7a1e754da22857",
|
||||||
"semantic_hash": "721376253dda864ff81a5025691947e0"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/pages/LoginPage.tsx": {
|
"frontend/src/pages/LoginPage.tsx": {
|
||||||
"mtime": 1789990425.5480313,
|
"mtime": 1790055350.367207,
|
||||||
"seen": 1790048203.8355043,
|
"seen": 1790096479.5365305,
|
||||||
"ast_hash": "cb1c4cfbb684a4667e0662b452fb9459",
|
"ast_hash": "cb1c4cfbb684a4667e0662b452fb9459",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
@@ -672,22 +672,22 @@
|
|||||||
"semantic_hash": "53e014a77ff3070820a83458af08a5fd"
|
"semantic_hash": "53e014a77ff3070820a83458af08a5fd"
|
||||||
},
|
},
|
||||||
"frontend/src/pages/OperationsPage.tsx": {
|
"frontend/src/pages/OperationsPage.tsx": {
|
||||||
"mtime": 1790048012.6410372,
|
"mtime": 1790095531.9706314,
|
||||||
"seen": 1790048203.8355062,
|
"seen": 1790096479.5365317,
|
||||||
"ast_hash": "8820a38967f7c248508cb8c62ea4fa00",
|
"ast_hash": "f3eb4c376ab882b8b83cc27a09f0ab68",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/pages/ResetPasswordPage.tsx": {
|
"frontend/src/pages/ResetPasswordPage.tsx": {
|
||||||
"mtime": 1789990425.558781,
|
"mtime": 1790055350.367207,
|
||||||
"seen": 1790048203.8355079,
|
"seen": 1790096479.5365329,
|
||||||
"ast_hash": "2b2c87734763d8abc2af638babc012d6",
|
"ast_hash": "2b2c87734763d8abc2af638babc012d6",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/pages/ReviewQueuePage.tsx": {
|
"frontend/src/pages/ReviewQueuePage.tsx": {
|
||||||
"mtime": 1789127802.1288989,
|
"mtime": 1790097345.6599903,
|
||||||
"seen": 1789377303.0692823,
|
"seen": 1790129548.0530627,
|
||||||
"ast_hash": "b3f57d73ef457c9b2427d74603972213",
|
"ast_hash": "3fd04897b64b97c0423b5b539e2736a6",
|
||||||
"semantic_hash": "b3f57d73ef457c9b2427d74603972213"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/pages/SettingsPage.tsx": {
|
"frontend/src/pages/SettingsPage.tsx": {
|
||||||
"mtime": 1789128572.7868366,
|
"mtime": 1789128572.7868366,
|
||||||
@@ -696,16 +696,16 @@
|
|||||||
"semantic_hash": "6a35c2799ba84fdbc1367d768907e8fa"
|
"semantic_hash": "6a35c2799ba84fdbc1367d768907e8fa"
|
||||||
},
|
},
|
||||||
"frontend/src/test/accounts-page.test.tsx": {
|
"frontend/src/test/accounts-page.test.tsx": {
|
||||||
"mtime": 1789128626.8475864,
|
"mtime": 1790078077.4305809,
|
||||||
"seen": 1789377303.0692837,
|
"seen": 1790096479.536536,
|
||||||
"ast_hash": "d17270fb5ca22286d3d62d7e2679fbcb",
|
"ast_hash": "333f6f0a741400d5f7b0fc778bd17999",
|
||||||
"semantic_hash": "d17270fb5ca22286d3d62d7e2679fbcb"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/test/budget-plan.test.tsx": {
|
"frontend/src/test/budget-plan.test.tsx": {
|
||||||
"mtime": 1789128448.8001103,
|
"mtime": 1790097111.8589516,
|
||||||
"seen": 1789377303.0692847,
|
"seen": 1790129548.0530663,
|
||||||
"ast_hash": "b2abf4ff7b3ea5b6f730cddd021b4127",
|
"ast_hash": "0eb496e1c89aa33ed330b71f7bde8185",
|
||||||
"semantic_hash": "b2abf4ff7b3ea5b6f730cddd021b4127"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/test/chart-accessible-table.test.tsx": {
|
"frontend/src/test/chart-accessible-table.test.tsx": {
|
||||||
"mtime": 1789128448.8031104,
|
"mtime": 1789128448.8031104,
|
||||||
@@ -726,10 +726,10 @@
|
|||||||
"semantic_hash": "cc870c8a667b6982c9fabd654468a46f"
|
"semantic_hash": "cc870c8a667b6982c9fabd654468a46f"
|
||||||
},
|
},
|
||||||
"frontend/src/test/import-polling.test.tsx": {
|
"frontend/src/test/import-polling.test.tsx": {
|
||||||
"mtime": 1789128692.423248,
|
"mtime": 1790078095.9788127,
|
||||||
"seen": 1789377303.0692883,
|
"seen": 1790096479.5365417,
|
||||||
"ast_hash": "2b83dee550ca31b0d5980c8ce3235fd0",
|
"ast_hash": "49ccb2891bba56407a762149d55e59be",
|
||||||
"semantic_hash": "2b83dee550ca31b0d5980c8ce3235fd0"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/test/login-page.test.tsx": {
|
"frontend/src/test/login-page.test.tsx": {
|
||||||
"mtime": 1789128286.0698264,
|
"mtime": 1789128286.0698264,
|
||||||
@@ -804,10 +804,10 @@
|
|||||||
"semantic_hash": "8fbddf58406caccd2d5a5b0e5f9ffab4"
|
"semantic_hash": "8fbddf58406caccd2d5a5b0e5f9ffab4"
|
||||||
},
|
},
|
||||||
"docs/adr/0002-journal-and-budget-reserves.md": {
|
"docs/adr/0002-journal-and-budget-reserves.md": {
|
||||||
"mtime": 1789126779.7821348,
|
"mtime": 1790061406.9456153,
|
||||||
"seen": 1789377303.0693,
|
"seen": 1790096479.5365562,
|
||||||
"ast_hash": "6ca9f9b8f91733ce37e15f339a656726",
|
"ast_hash": "8a83c81c137c5698631ad0ed3ff7a3bb",
|
||||||
"semantic_hash": "6ca9f9b8f91733ce37e15f339a656726"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"docs/adr/0003-versioned-statement-imports.md": {
|
"docs/adr/0003-versioned-statement-imports.md": {
|
||||||
"mtime": 1789732137.8203447,
|
"mtime": 1789732137.8203447,
|
||||||
@@ -864,9 +864,9 @@
|
|||||||
"semantic_hash": "0a7bb82cce04631f36f2d561a1665478"
|
"semantic_hash": "0a7bb82cce04631f36f2d561a1665478"
|
||||||
},
|
},
|
||||||
"infra/docker-compose.yml": {
|
"infra/docker-compose.yml": {
|
||||||
"mtime": 1789400438.2433548,
|
"mtime": 1790067636.943902,
|
||||||
"seen": 1789735433.1582313,
|
"seen": 1790096479.5365646,
|
||||||
"ast_hash": "9f04dd1906326ddea306584fe68a2ae4",
|
"ast_hash": "24a00b6c306c0d043c7e2bd7dffa4cc1",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/public/favicon.svg": {
|
"frontend/public/favicon.svg": {
|
||||||
@@ -888,9 +888,9 @@
|
|||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/tests/test_adapter_contracts.py": {
|
"backend/tests/test_adapter_contracts.py": {
|
||||||
"mtime": 1789735944.366427,
|
"mtime": 1790061478.1675427,
|
||||||
"seen": 1789736272.9455402,
|
"seen": 1790096479.5364716,
|
||||||
"ast_hash": "c3f63850c85b7c5262e66370ab7ab8a9",
|
"ast_hash": "9d446a10c953a14c8d4962eff50f5b97",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"backend/tests/test_import_holds.py": {
|
"backend/tests/test_import_holds.py": {
|
||||||
@@ -918,9 +918,9 @@
|
|||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/pnpm-workspace.yaml": {
|
"frontend/pnpm-workspace.yaml": {
|
||||||
"mtime": 1789377936.9976747,
|
"mtime": 1790076555.3445592,
|
||||||
"seen": 1789735433.1582298,
|
"seen": 1790096479.5365627,
|
||||||
"ast_hash": "2ae2370fa527b015cd7bd98dc7ca2a6b",
|
"ast_hash": "abd02edee0c2599c6acaf570827446e9",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
".kilo/plugins/graphify.js": {
|
".kilo/plugins/graphify.js": {
|
||||||
@@ -936,15 +936,165 @@
|
|||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/components/layout/AuthLayout.tsx": {
|
"frontend/src/components/layout/AuthLayout.tsx": {
|
||||||
"mtime": 1789990425.546223,
|
"mtime": 1790055350.367207,
|
||||||
"seen": 1790048203.8354764,
|
"seen": 1790096479.5364969,
|
||||||
"ast_hash": "0b6a76918da0118d0580d2c36c9649e6",
|
"ast_hash": "0b6a76918da0118d0580d2c36c9649e6",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"frontend/src/pages/OverviewPage.tsx": {
|
"frontend/src/pages/OverviewPage.tsx": {
|
||||||
"mtime": 1789990291.3154266,
|
"mtime": 1790077489.1771908,
|
||||||
"seen": 1790048203.835507,
|
"seen": 1790096479.5365322,
|
||||||
"ast_hash": "02a37c24bbeb6de67b2c6449209a5f0f",
|
"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": ""
|
"semantic_hash": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,7 @@ services:
|
|||||||
networks: [backend]
|
networks: [backend]
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:latest
|
image: quay.io/minio/minio:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["server", "/data", "--console-address", ":9001"]
|
command: ["server", "/data", "--console-address", ":9001"]
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
Reference in New Issue
Block a user