From db4dd7572e030873f51b3fc8a170e96daf09a90e Mon Sep 17 00:00:00 2001 From: k1nq Date: Wed, 23 Sep 2026 09:23:13 +0500 Subject: [PATCH] Update file metadata in manifest.json and change MinIO image source in docker-compose.yml --- .gitignore | 5 +- ...2854-savings-import-categories-blockers.md | 153 + .../versions/20260922_0003_savings_system.py | 194 + backend/app/analytics/router.py | 8 +- backend/app/analytics/service.py | 19 +- backend/app/budgeting/router.py | 104 +- backend/app/budgeting/service.py | 132 +- backend/app/categorization/service.py | 17 +- backend/app/imports/adapters/alfa_xlsx.py | 45 +- backend/app/imports/adapters/base.py | 3 +- backend/app/imports/router.py | 42 +- backend/app/imports/service.py | 117 +- backend/app/ledger/models.py | 20 +- backend/app/ledger/router.py | 54 + backend/app/ledger/savings.py | 334 + backend/app/ledger/service.py | 24 + backend/app/spaces/service.py | 3 + backend/tests/test_adapter_contracts.py | 124 +- backend/tests/test_budget_savings.py | 337 + backend/tests/test_category_definitions.py | 216 + backend/tests/test_import_savings.py | 386 + backend/tests/test_savings.py | 495 + backend/tests/test_savings_migration.py | 68 + docs/adr/0002-journal-and-budget-reserves.md | 5 +- frontend/pnpm-workspace.yaml | 3 + frontend/src/api/endpoints.ts | 25 +- frontend/src/api/generated/schema.d.ts | 563 +- frontend/src/api/queryKeys.ts | 4 +- frontend/src/api/types.ts | 86 +- .../src/components/budget/BudgetBlockers.tsx | 77 + .../budget/CategoryManagerModal.tsx | 222 + frontend/src/components/layout/AppLayout.tsx | 2 +- frontend/src/hooks/useAccounts.ts | 28 +- frontend/src/hooks/useBudget.ts | 59 + frontend/src/hooks/useImports.ts | 26 +- frontend/src/hooks/useOperations.ts | 14 + frontend/src/i18n/locales/en.json | 111 +- frontend/src/i18n/locales/ru.json | 111 +- frontend/src/lib/accountLabel.ts | 9 + frontend/src/lib/apiErrors.ts | 19 + frontend/src/lib/categoryLabel.ts | 24 + frontend/src/lib/dates.ts | 101 + frontend/src/lib/importStatus.ts | 21 + frontend/src/lib/locale.ts | 5 + frontend/src/lib/money.ts | 46 + frontend/src/lib/operationFormat.ts | 41 + frontend/src/pages/AccountsPage.tsx | 206 +- frontend/src/pages/BudgetPage.tsx | 114 +- frontend/src/pages/ImportDetailPage.tsx | 182 +- frontend/src/pages/ImportsPage.tsx | 7 +- frontend/src/pages/OperationsPage.tsx | 142 +- frontend/src/pages/OverviewPage.tsx | 3 +- frontend/src/pages/ReviewQueuePage.tsx | 59 +- frontend/src/styles/global.css | 38 +- frontend/src/test/account-adjustment.test.tsx | 125 + frontend/src/test/accounts-page.test.tsx | 2 + frontend/src/test/budget-blockers.test.tsx | 64 + frontend/src/test/budget-plan.test.tsx | 2 + frontend/src/test/category-manager.test.tsx | 191 + .../src/test/import-hold-resolution.test.tsx | 155 + frontend/src/test/import-polling.test.tsx | 11 +- frontend/src/test/review-queue-month.test.tsx | 110 + .../src/test/savings-convert-revert.test.tsx | 183 + .../src/test/system-account-picker.test.tsx | 134 + graphify-out/.graphify_labels.json | 38 +- graphify-out/.graphify_labels.json.sig | 2 +- graphify-out/GRAPH_REPORT.md | 265 +- graphify-out/graph.html | 8 +- graphify-out/graph.json | 32316 ++++++++++++---- graphify-out/manifest.json | 458 +- infra/docker-compose.yml | 2 +- 71 files changed, 31011 insertions(+), 8308 deletions(-) create mode 100644 .kilo/plans/1790055652854-savings-import-categories-blockers.md create mode 100644 backend/alembic/versions/20260922_0003_savings_system.py create mode 100644 backend/app/ledger/savings.py create mode 100644 backend/tests/test_budget_savings.py create mode 100644 backend/tests/test_category_definitions.py create mode 100644 backend/tests/test_import_savings.py create mode 100644 backend/tests/test_savings.py create mode 100644 backend/tests/test_savings_migration.py create mode 100644 frontend/src/components/budget/BudgetBlockers.tsx create mode 100644 frontend/src/components/budget/CategoryManagerModal.tsx create mode 100644 frontend/src/lib/accountLabel.ts create mode 100644 frontend/src/lib/apiErrors.ts create mode 100644 frontend/src/lib/categoryLabel.ts create mode 100644 frontend/src/lib/dates.ts create mode 100644 frontend/src/lib/importStatus.ts create mode 100644 frontend/src/lib/locale.ts create mode 100644 frontend/src/lib/money.ts create mode 100644 frontend/src/lib/operationFormat.ts create mode 100644 frontend/src/test/account-adjustment.test.tsx create mode 100644 frontend/src/test/budget-blockers.test.tsx create mode 100644 frontend/src/test/category-manager.test.tsx create mode 100644 frontend/src/test/import-hold-resolution.test.tsx create mode 100644 frontend/src/test/review-queue-month.test.tsx create mode 100644 frontend/src/test/savings-convert-revert.test.tsx create mode 100644 frontend/src/test/system-account-picker.test.tsx diff --git a/.gitignore b/.gitignore index f73ffe5..7fd713c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ eggs/ .eggs/ lib/ lib64/ +# The frontend's runtime utilities live in a source directory named `lib`. +!frontend/src/lib/ +!frontend/src/lib/** parts/ sdist/ var/ @@ -210,4 +213,4 @@ cost.json graphify-out/cache/ examples/ -.playwright-mcp \ No newline at end of file +.playwright-mcp diff --git a/.kilo/plans/1790055652854-savings-import-categories-blockers.md b/.kilo/plans/1790055652854-savings-import-categories-blockers.md new file mode 100644 index 0000000..778b00a --- /dev/null +++ b/.kilo/plans/1790055652854-savings-import-categories-blockers.md @@ -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. diff --git a/backend/alembic/versions/20260922_0003_savings_system.py b/backend/alembic/versions/20260922_0003_savings_system.py new file mode 100644 index 0000000..c8004cd --- /dev/null +++ b/backend/alembic/versions/20260922_0003_savings_system.py @@ -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") diff --git a/backend/app/analytics/router.py b/backend/app/analytics/router.py index fb8360a..bdf3da7 100644 --- a/backend/app/analytics/router.py +++ b/backend/app/analytics/router.py @@ -22,8 +22,8 @@ def category_spend( return [ { "categoryId": row["category_id"], - "name": row["category"] if not row["category"].startswith("category.") else None, - "nameKey": row["category"] if row["category"].startswith("category.") else None, + "name": row["name"], + "nameKey": row["name_key"], "amountKopecks": row["amount_kopecks"], } for row in rows @@ -75,8 +75,8 @@ def plan_fact(month: str, db: Db, context: Context) -> dict: "items": [ { "categoryId": row["category_id"], - "name": row["name"] if not str(row["name"]).startswith("category.") else None, - "nameKey": row["name"] if str(row["name"]).startswith("category.") else None, + "name": row.get("name"), + "nameKey": row.get("name_key"), "plannedKopecks": row["planned"], "actualKopecks": row["spent"], } diff --git a/backend/app/analytics/service.py b/backend/app/analytics/service.py index 20d7407..401f9f6 100644 --- a/backend/app/analytics/service.py +++ b/backend/app/analytics/service.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session from app.budgeting.models import PeriodClosure from app.budgeting.service import compute_period_state, get_or_create_period -from app.categorization.service import resolve_category_name +from app.categorization.service import resolve_category_name, resolve_category_name_key from app.ledger.models import Account, Category, Operation from app.ledger.service import account_balance @@ -40,16 +40,21 @@ def category_spend( ) totals[expense.category_id] += expense.amount_kopecks - int(refunded or 0) categories = { - item.id: resolve_category_name(item) + item.id: item for item in db.query(Category).filter_by(space_id=space_id).all() } return [ { "category_id": category_id, - "category": ( - categories.get(category_id, "category.unnamed") - if category_id is not None - else "category.uncategorized" + "name": ( + resolve_category_name(categories[category_id]) + if category_id is not None and category_id in categories + else None + ), + "name_key": ( + resolve_category_name_key(categories[category_id]) + if category_id is not None and category_id in categories + else "uncategorized" ), "amount_kopecks": amount, } @@ -66,7 +71,7 @@ def cash_flow(db: Session, *, space_id: uuid.UUID, date_from: date, date_to: dat totals: dict[tuple[date, str], int] = defaultdict(int) for operation in operations: effective = operation.posted_on or operation.occurred_on - if not date_from <= effective <= date_to or operation.type == "transfer": + if not date_from <= effective <= date_to or operation.type in {"transfer", "adjustment"}: continue signed = ( operation.amount_kopecks diff --git a/backend/app/budgeting/router.py b/backend/app/budgeting/router.py index 2e84c1a..8ec2286 100644 --- a/backend/app/budgeting/router.py +++ b/backend/app/budgeting/router.py @@ -1,11 +1,12 @@ import uuid from datetime import date -from typing import Literal +from typing import Annotated, Literal -from fastapi import APIRouter +from fastapi import APIRouter, Query from pydantic import BaseModel, Field from app.budgeting import service +from app.categorization import service as categorization from app.dependencies import Context, Csrf, Db router = APIRouter(prefix="/budget", tags=["budget"]) @@ -117,35 +118,78 @@ def categories(month: str, db: Db, context: Context) -> list[dict]: year, month_number = parse_month(month) period = period_for(db, context, year, month_number) state = service.compute_period_state(db, space_id=context.space.id, period=period) - rows = state["monthly_categories"] + [ - { - "category_id": row["category_id"], - "name": row["name"], - "planned": 0, - "funded": row["funded_total"], - "spent": row["funded_total"] - row["available"], - "available": row["available"], - } - for row in state["envelopes"] - ] + rows = state["monthly_categories"] + state["envelopes"] category_by_id = { - str(item.id): item for item in service.list_categories(db, space_id=context.space.id) + str(item.id): item + for item in service.list_categories(db, space_id=context.space.id, include_archived=True) } - return [ - { - "id": row["category_id"], - "name": row["name"] if not str(row["name"]).startswith("category.") else None, - "nameKey": row["name"] if str(row["name"]).startswith("category.") else None, - "kind": category_by_id[row["category_id"]].kind, - "archived": False, - "creditAccountId": category_by_id[row["category_id"]].linked_account_id, - "plannedKopecks": row["planned"], - "fundedKopecks": row["funded"], - "spentKopecks": row["spent"], - "availableKopecks": row["available"], - } - for row in rows - ] + result = [] + for row in rows: + category = category_by_id[row["category_id"]] + result.append( + { + "id": row["category_id"], + "name": row["name"], + "nameKey": row["name_key"], + "kind": category.kind, + "system": row["system"], + "archived": category.archived, + "creditAccountId": category.linked_account_id, + "plannedKopecks": row["planned"], + "fundedKopecks": row["funded"], + "spentKopecks": row["spent"], + "availableKopecks": row["available"], + } + ) + return result + + +def category_definition_json(category) -> dict: + return { + "id": category.id, + "name": categorization.resolve_category_name(category), + "nameKey": categorization.resolve_category_name_key(category), + "kind": category.kind, + "systemKey": category.system_key, + "linkedAccountId": category.linked_account_id, + "archived": category.archived, + } + + +@router.get("/category-definitions") +def category_definitions( + db: Db, + context: Context, + include_archived: Annotated[bool, Query(alias="includeArchived")] = False, +) -> list[dict]: + items = service.list_categories( + db, space_id=context.space.id, include_archived=include_archived + ) + return [category_definition_json(item) for item in items] + + +class CategoryDefinitionPatchBody(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=200) + archived: bool | None = None + + +@router.patch("/category-definitions/{category_id}") +def patch_category_definition( + category_id: uuid.UUID, + body: CategoryDefinitionPatchBody, + db: Db, + context: Context, + _csrf: Csrf, +) -> dict: + category = service.update_category_definition( + db, + space_id=context.space.id, + user=context.user, + category_id=category_id, + name=body.name, + archived=body.archived, + ) + return category_definition_json(category) @router.post("/categories", status_code=201) @@ -157,7 +201,7 @@ def create_category(body: CategoryBody, db: Db, context: Context, _csrf: Csrf) - kind=body.kind, name=body.name, ) - return {"id": category.id, "kind": category.kind, "name": body.name} + return category_definition_json(category) @router.patch("/categories/{category_id}") diff --git a/backend/app/budgeting/service.py b/backend/app/budgeting/service.py index 66a6b8a..91add9d 100644 --- a/backend/app/budgeting/service.py +++ b/backend/app/budgeting/service.py @@ -19,7 +19,7 @@ from app.budgeting.models import ( from app.categorization.service import resolve_category_name from app.errors import bad_request, conflict, not_found from app.identity.models import User -from app.ledger.models import Account, Category, Operation +from app.ledger.models import Account, Category, Operation, Posting from app.ledger.service import ensure_period_open_for_date as _ledger_guard # noqa: F401 @@ -96,8 +96,6 @@ def _closure_snapshot(db: Session, *, space_id: uuid.UUID, year: int, month: int def _balance_until(db: Session, account: Account, end: date) -> int: - from app.ledger.models import Posting - total = ( db.query(func.coalesce(func.sum(Posting.amount_kopecks), 0)) .join(Operation, Operation.id == Posting.operation_id) @@ -125,6 +123,10 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri - a refund on a credit card reduces debt and releases any surplus reserve; - a same-period refund restores the parent purchase's category; a refund on an uncategorized expense restores the pool; + - transfers into the system savings envelope are carved out of the pool as + its backing; transfers back out return to the pool; + - adjustments reconcile a stored balance through one signed posting and are + excluded from income/expense totals, category spend and cash flow; - monthly categories fold their residual into the pool at close; savings envelopes and credit reserves persist via all-time/ snapshot carries. """ @@ -132,7 +134,10 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri accounts = db.query(Account).filter_by(space_id=space_id).all() account_by_id = {a.id: a for a in accounts} - categories = db.query(Category).filter_by(space_id=space_id, archived=False).all() + savings_account = next((a for a in accounts if a.system_key == "savings"), None) + # Archived categories stay in the computation: any plan/funding/operations + # history in the selected period must keep affecting totals. + categories = db.query(Category).filter_by(space_id=space_id).all() ops = ( db.query(Operation) @@ -155,6 +160,9 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri payments_by_account: dict[Any, int] = defaultdict(int) refunds_to_credit_by_account: dict[Any, int] = defaultdict(int) refund_back_uncategorized_period = 0 + # Net transfers into the system savings envelope for this month (+ into, + # - back out). Adjustments are excluded from every flow aggregate. + savings_net_period = 0 for op in ops: if op.type == "income": @@ -177,6 +185,11 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri ) if counter is not None and counter.kind == "credit": payments_by_account[counter.id] += op.amount_kopecks + source = account_by_id.get(op.account_id) + if counter is not None and counter.system_key == "savings": + savings_net_period += op.amount_kopecks + if source is not None and source.system_key == "savings": + savings_net_period -= op.amount_kopecks elif op.type == "refund": parent_account = account_by_id.get(op.account_id) parent = db.get(Operation, op.parent_operation_id) if op.parent_operation_id else None @@ -224,7 +237,7 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri # All-time envelope consumption up to period end. for cat in categories: - if cat.kind != "savings_envelope": + if cat.kind != "savings_envelope" or cat.system_key == "savings": continue consumed = ( db.query(func.coalesce(func.sum(Operation.amount_kopecks), 0)) @@ -323,6 +336,7 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri - (payments_total - releases_payment_total) + surplus_release_total - funding_total + - savings_net_period ) monthly_rows: list[dict[str, Any]] = [] @@ -336,22 +350,65 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri { "category_id": str(cat.id), "name": resolve_category_name(cat), + "name_key": cat.system_key, + "archived": cat.archived, "planned": 0, "funded": funded_period[cat.id], "spent": monthly_consumption[cat.id], "available": available, } ) + # All-time net transfer movement into the system savings account (+ in, - out). + savings_net_all_time = 0 + if savings_account is not None: + savings_net_all_time = int( + db.query(func.coalesce(func.sum(Posting.amount_kopecks), 0)) + .join(Operation, Operation.id == Posting.operation_id) + .filter( + Posting.account_id == savings_account.id, + Operation.status == "confirmed", + Operation.type == "transfer", + ) + .scalar() + or 0 + ) + envelope_rows: list[dict[str, Any]] = [] for cat in categories: if cat.kind != "savings_envelope" or cat.system_key == "credit_reserve": continue + if cat.system_key == "savings": + # System savings envelope: funded shows the net transfer movement of + # the selected month, spent is always zero and available tracks the + # linked account balance at month end (adjustments included). + available = _balance_until(db, savings_account, end) if savings_account else 0 + envelope_rows.append( + { + "category_id": str(cat.id), + "name": resolve_category_name(cat), + "name_key": cat.system_key, + "archived": cat.archived, + "system": True, + "funded_total": savings_net_all_time, + "planned": 0, + "funded": savings_net_period, + "spent": 0, + "available": available, + } + ) + continue available = funded_all_time[cat.id] - envelope_consumption_total[cat.id] envelope_rows.append( { "category_id": str(cat.id), "name": resolve_category_name(cat), + "name_key": cat.system_key, + "archived": cat.archived, + "system": cat.system_key is not None, "funded_total": funded_all_time[cat.id], + "planned": 0, + "funded": funded_period[cat.id], + "spent": funded_all_time[cat.id] - available, "available": available, } ) @@ -376,7 +433,11 @@ def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeri matched_by_expected[m.expected_income_id] += m.amount_kopecks uncategorized_count = sum(1 for op in ops if op.type == "expense" and op.category_id is None) - negative_envelopes = [str(row["name"]) for row in envelope_rows if int(row["available"]) < 0] + negative_envelopes = [ + str(row["name"] or row["name_key"] or row["category_id"]) + for row in envelope_rows + if int(row["available"]) < 0 + ] warnings = [] if planned_total > carry_pool + expected_total: @@ -508,7 +569,10 @@ def create_category( ) -> Category: if kind not in ("monthly", "savings_envelope"): raise bad_request("BUDGET_CATEGORY_KIND_INVALID", "Unknown category kind") - category = Category(space_id=space_id, kind=kind, custom_name=name) + trimmed = name.strip() + if not trimmed: + raise bad_request("BUDGET_CATEGORY_NAME_REQUIRED", "Category name is required") + category = Category(space_id=space_id, kind=kind, custom_name=trimmed[:200]) db.add(category) db.flush() audit.record( @@ -554,7 +618,7 @@ def archive_category( db: Session, *, space_id: uuid.UUID, user: User, category_id: uuid.UUID ) -> Category: category = get_category(db, space_id=space_id, category_id=category_id) - if category.system_key == "credit_reserve": + if category.system_key is not None: raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be archived") category.archived = True audit.record( @@ -567,21 +631,41 @@ def archive_category( return category -def rename_category( - db: Session, *, space_id: uuid.UUID, user: User, category_id: uuid.UUID, name: str +def update_category_definition( + db: Session, + *, + space_id: uuid.UUID, + user: User, + category_id: uuid.UUID, + name: str | None = None, + archived: bool | None = None, ) -> Category: + """Rename and/or archive/restore one user category definition. + + System categories reject both changes; ``archived=False`` restores an + archived user category. History (plans, funding, operations) is preserved. + """ category = get_category(db, space_id=space_id, category_id=category_id) if category.system_key is not None: - category.name_override = name - else: - category.custom_name = name - audit.record( - db, - event_type="category.renamed", - space_id=space_id, - user_id=user.id, - detail={"category_id": str(category.id)}, - ) + raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be renamed or archived") + changes: dict[str, bool | str] = {} + if name is not None: + trimmed = name.strip() + if not trimmed: + raise bad_request("BUDGET_CATEGORY_NAME_REQUIRED", "Category name is required") + category.custom_name = trimmed[:200] + changes["renamed"] = True + if archived is not None: + category.archived = archived + changes["archived"] = archived + if changes: + audit.record( + db, + event_type="category.updated", + space_id=space_id, + user_id=user.id, + detail={"category_id": str(category.id), **changes}, + ) return category @@ -597,6 +681,8 @@ def set_assignments( result = [] for item in items: category = get_category(db, space_id=space_id, category_id=item["category_id"]) + if category.system_key is not None: + raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be planned") if category.archived: raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived") planned = item["planned_kopecks"] @@ -713,7 +799,11 @@ def fund( raise bad_request("BUDGET_FUNDING_KIND_INVALID", "Unknown funding kind") if amount_kopecks <= 0: raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive") - get_category(db, space_id=space_id, category_id=category_id) + category = get_category(db, space_id=space_id, category_id=category_id) + if category.system_key is not None: + raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be funded") + if category.archived: + raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived") movement = FundingMovement( space_id=space_id, period_id=period.id, diff --git a/backend/app/categorization/service.py b/backend/app/categorization/service.py index e4daf8b..1d6e9af 100644 --- a/backend/app/categorization/service.py +++ b/backend/app/categorization/service.py @@ -5,15 +5,22 @@ from app.errors import conflict, not_found from app.ledger.models import Category -def resolve_category_name(category: Category) -> str: - """Standard categories resolve to a translation key; overrides/customs are literal.""" +def resolve_category_name(category: Category) -> str | None: + """Literal display name, or None when the name localizes via the system key. + + System categories serialize as a pure ``nameKey`` (for example ``savings``), + never as a ``category.*`` prefixed string. + """ if category.custom_name is not None: return category.custom_name if category.name_override is not None: return category.name_override - if category.system_key is not None: - return f"category.{category.system_key}" - return "category.unnamed" + return None + + +def resolve_category_name_key(category: Category) -> str | None: + """Pure localization key for system categories; None for user categories.""" + return category.system_key def get_income_source(db: Session, *, space_id, income_source_id) -> IncomeSource: diff --git a/backend/app/imports/adapters/alfa_xlsx.py b/backend/app/imports/adapters/alfa_xlsx.py index cfc188a..77bb105 100644 --- a/backend/app/imports/adapters/alfa_xlsx.py +++ b/backend/app/imports/adapters/alfa_xlsx.py @@ -29,6 +29,40 @@ DESCRIPTION_HEADERS = ("описание", "назначение", "коммен CATEGORY_HEADERS = ("категория",) ACCOUNT_HEADERS = ("номер карты", "номер счёта", "карта") +# Alfa «Копилка для сдачи» round-up service. The rule fails closed: only an +# exact normalized phrase with a source account equal to the statement account +# and an outgoing posted row produces the savings_transfer decision hint. The +# account numbers in the pattern are part of the bank's phrasing contract and +# are matched against the statement header, never hardcoded per user. +SAVINGS_SERVICE_RE = re.compile( + r"перечисление средств в рамках услуги \"копилка для сдачи\" " + r"со счета (\d{20}) на счет (\d{20})" +) + + +def _savings_rule_text(value: str) -> str: + text = value.replace("\u00a0", " ").replace("ё", "е").replace("Ё", "Е").lower() + text = re.sub(r"[«»„“”\"']", '"', text) + return " ".join(text.split()) + + +def detect_savings_transfer( + description: str | None, + *, + direction: str, + bank_status: str, + statement_account: str, +) -> bool: + if direction != DIRECTION_OUT or bank_status != BANK_STATUS_POSTED or not description: + return False + match = SAVINGS_SERVICE_RE.search(_savings_rule_text(description)) + if match is None: + return False + statement_digits = re.sub(r"\D", "", statement_account or "") + if not statement_digits: + return False + return match.group(1) == statement_digits + class AlfaXlsxAdapterBase: """Alfa statement contract: XLSX with a header row and bank marker 'Альфа'.""" @@ -277,7 +311,9 @@ class AlfaDebitXlsxAdapter: header_idx, columns = _debit_find_header(self, rows) meta = _debit_header_metadata(self, rows, header_idx) period_start, period_end = _debit_statement_period(self, rows, header_idx) - parsed = _debit_operation_rows(self, rows, header_idx, columns) + parsed = _debit_operation_rows( + self, rows, header_idx, columns, statement_account=meta["account"] + ) if not parsed: raise UnsupportedStatementLayout("no operation rows found in Alfa statement") return StatementDraft( @@ -413,6 +449,7 @@ def _debit_operation_rows( rows: list[tuple], header_idx: int, columns: dict[str, int], + statement_account: str = "", ) -> list[SourceRow]: parsed: list[SourceRow] = [] separated_from_operations = False @@ -475,6 +512,12 @@ def _debit_operation_rows( row_ref=f"sheet0:row{offset}", warnings=tuple(warnings), bank_status=bank_status, + savings_transfer=detect_savings_transfer( + description, + direction=direction, + bank_status=bank_status, + statement_account=statement_account, + ), ) ) separated_from_operations = False diff --git a/backend/app/imports/adapters/base.py b/backend/app/imports/adapters/base.py index f446b23..5ac9d8a 100644 --- a/backend/app/imports/adapters/base.py +++ b/backend/app/imports/adapters/base.py @@ -16,7 +16,7 @@ BANK_STATUSES = (BANK_STATUS_POSTED, BANK_STATUS_HOLD) PARSER_VERSION = "1" # Per-contract versions: bumped for the rewritten evidenced contracts so old # parse runs can never be confused with the new canonical output. -ALFA_DEBIT_XLSX_VERSION = "2" +ALFA_DEBIT_XLSX_VERSION = "3" OZON_DEBIT_PDF_VERSION = "2" ROCKETBANK_DEBIT_PDF_VERSION = "2" @@ -35,6 +35,7 @@ class SourceRow: row_ref: str warnings: tuple[str, ...] = () bank_status: str = BANK_STATUS_POSTED + savings_transfer: bool = False def __post_init__(self) -> None: if self.amount_kopecks < 0: diff --git a/backend/app/imports/router.py b/backend/app/imports/router.py index 1a3f906..54a4af3 100644 --- a/backend/app/imports/router.py +++ b/backend/app/imports/router.py @@ -1,7 +1,7 @@ import uuid from typing import Annotated, Any -from fastapi import APIRouter, File, Form, UploadFile +from fastapi import APIRouter, File, Form, Query, UploadFile from pydantic import BaseModel, ConfigDict, Field from app.dependencies import Context, Csrf, Db @@ -41,7 +41,7 @@ class HoldResolveBody(BaseModel): account_id: uuid.UUID | None = Field(default=None, alias="accountId") -def summary(document: Any) -> dict: +def summary(document: Any, *, unresolved_hold_count: int = 0) -> dict: return { "id": document.id, "fileName": document.original_filename, @@ -52,14 +52,33 @@ def summary(document: Any) -> dict: "periodFrom": None, "periodTo": None, "needsAccountMapping": document.needs_account_mapping, + "unresolvedHoldCount": unresolved_hold_count, "errorCode": document.error_code, "createdAt": document.created_at, } @router.get("") -def imports(db: Db, context: Context) -> list[dict]: - return [summary(item) for item in service.list_documents(db, space_id=context.space.id)] +def imports( + db: Db, + context: Context, + month: Annotated[str | None, Query()] = None, +) -> list[dict]: + month_filter = service.parse_month_filter(month) + documents = service.list_documents(db, space_id=context.space.id) + hold_counts = service.unresolved_hold_counts( + db, + space_id=context.space.id, + document_ids=[item.id for item in documents], + month=month_filter, + ) + result = [] + for item in documents: + hold_count = hold_counts.get(item.id, 0) + if month_filter is not None and item.status == "confirmed" and hold_count == 0: + continue + result.append(summary(item, unresolved_hold_count=hold_count)) + return result @router.post("", status_code=202) @@ -106,7 +125,12 @@ async def upload( def detail(document_id: uuid.UUID, db: Db, context: Context) -> dict: raw = service.document_detail(db, space_id=context.space.id, document_id=document_id) document = service._document(db, space_id=context.space.id, document_id=document_id) - base = summary(document) + base = summary( + document, + unresolved_hold_count=len( + service.unresolved_holds(db, space_id=context.space.id, document=document) + ), + ) reconciliation = raw.get("reconciliation") or {} rows = [] pending_candidate_row_ids: set[uuid.UUID] = set() @@ -204,6 +228,7 @@ def detail(document_id: uuid.UUID, db: Db, context: Context) -> dict: "discard": sum(row["decision"] == "discard" for row in rows), "duplicate": sum(row["decision"] == "duplicate" for row in rows), "transfer": sum(row["decision"] == "transfer" for row in rows), + "savingsTransfer": sum(row["decision"] == "savings_transfer" for row in rows), "pending": sum(row["decision"] == "pending" for row in rows), }, } @@ -217,7 +242,12 @@ def review(document_id: uuid.UUID, body: ReviewBody, db: Db, context: Context, _ document_id=document_id, **body.model_dump(), ) - return summary(document) + return summary( + document, + unresolved_hold_count=len( + service.unresolved_holds(db, space_id=context.space.id, document=document) + ), + ) @router.patch("/{document_id}/rows/{row_id}") diff --git a/backend/app/imports/service.py b/backend/app/imports/service.py index 51956a4..bc386b6 100644 --- a/backend/app/imports/service.py +++ b/backend/app/imports/service.py @@ -5,6 +5,7 @@ from datetime import date, timedelta from pathlib import Path from typing import Any +from sqlalchemy import func from sqlalchemy.orm import Session from app.audit import service as audit @@ -164,6 +165,7 @@ def _source_from_row( or row.direction not in {"in", "out"} ), record_fp=record_fingerprint(row.occurred_on, abs(row.amount_kopecks), row.description), + review_decision="savings_transfer" if row.savings_transfer else "create", ) @@ -522,6 +524,72 @@ def list_documents(db: Session, *, space_id: uuid.UUID) -> list[StatementDocumen ) +def unresolved_holds( + db: Session, *, space_id: uuid.UUID, document: StatementDocument +) -> list[SourceRecord]: + """Unresolved HOLD rows of the document's active parse run.""" + if not document.active_parse_run_id: + return [] + return ( + db.query(SourceRecord) + .outerjoin(HoldResolution, HoldResolution.hold_source_record_id == SourceRecord.id) + .filter( + SourceRecord.space_id == space_id, + SourceRecord.parse_run_id == document.active_parse_run_id, + SourceRecord.bank_status == "hold", + HoldResolution.id.is_(None), + ) + .all() + ) + + +def unresolved_hold_counts( + db: Session, + *, + space_id: uuid.UUID, + document_ids: list[uuid.UUID], + month: tuple[int, int] | None = None, +) -> dict[uuid.UUID, int]: + """Count unresolved HOLD rows for active parse runs in one query.""" + + if not document_ids: + return {} + query = ( + db.query(StatementDocument.id, func.count(SourceRecord.id)) + .join( + SourceRecord, + (SourceRecord.document_id == StatementDocument.id) + & (SourceRecord.parse_run_id == StatementDocument.active_parse_run_id), + ) + .outerjoin(HoldResolution, HoldResolution.hold_source_record_id == SourceRecord.id) + .filter( + StatementDocument.space_id == space_id, + StatementDocument.id.in_(document_ids), + SourceRecord.bank_status == "hold", + HoldResolution.id.is_(None), + ) + ) + if month is not None: + year, month_number = month + start = date(year, month_number, 1) + end = date(year + 1, 1, 1) if month_number == 12 else date(year, month_number + 1, 1) + query = query.filter(SourceRecord.occurred_on >= start, SourceRecord.occurred_on < end) + return {document_id: count for document_id, count in query.group_by(StatementDocument.id)} + + +def parse_month_filter(value: str | None) -> tuple[int, int] | None: + if value is None: + return None + try: + year_text, month_text = value.split("-", 1) + year, month = int(year_text), int(month_text) + except (ValueError, AttributeError) as exc: + raise bad_request("IMPORT_MONTH_INVALID", "Month filter must use YYYY-MM") from exc + if not 1 <= month <= 12 or year < 2000: + raise bad_request("IMPORT_MONTH_INVALID", "Month filter must use YYYY-MM") + return year, month + + def document_detail(db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID) -> dict: document = _document(db, space_id=space_id, document_id=document_id) records = [] @@ -622,8 +690,17 @@ def decide_source_record( ) if source is None: raise not_found("IMPORT_SOURCE_RECORD_NOT_FOUND", "Import row not found") - if decision not in {"create", "discard", "duplicate", "transfer"}: + if decision not in {"create", "discard", "duplicate", "transfer", "savings_transfer"}: raise conflict("IMPORT_DECISION_INVALID", "Unsupported row decision") + if source.bank_status == "hold" and decision != "discard": + raise conflict("IMPORT_DECISION_INVALID", "HOLD rows are resolved via hold resolution") + if decision == "savings_transfer" and ( + source.bank_status != "posted" or source.direction != "out" + ): + raise conflict( + "IMPORT_DECISION_INVALID", + "Only posted outflows can be transferred to savings", + ) candidates = db.query(DedupCandidate).filter_by(source_record_id=source.id).all() matching = [item for item in candidates if item.candidate_kind == decision] if decision == "duplicate" and duplicate_operation_id is None and len(matching) == 1: @@ -684,6 +761,11 @@ def set_review_confirmation( account = db.query(Account).filter_by(id=account_id, space_id=space_id).one_or_none() if account is None: raise not_found("LEDGER_ACCOUNT_NOT_FOUND", "Account not found") + if account.system_key is not None: + raise conflict( + "IMPORT_SYSTEM_ACCOUNT_INVALID", + "System accounts cannot be used as statement accounts", + ) if confirmed_without_full_reconciliation and not reason: raise conflict("IMPORT_RECONCILIATION_REASON_REQUIRED", "A confirmation reason is required") document.account_id = account.id @@ -988,6 +1070,39 @@ def confirm_document( ] ) continue + if source.review_decision == "savings_transfer": + from app.ledger.savings import ensure_savings_resources + + savings_account, _savings_category = ensure_savings_resources( + db, space_id=space_id + ) + if document.account_id == savings_account.id: + raise conflict( + "IMPORT_SAVINGS_SOURCE_INVALID", + "The mapped statement account cannot be the savings account itself", + ) + assert source.occurred_on is not None + operation = create_transfer( + db, + space_id=space_id, + user=user, + source_account_id=document.account_id, + target_account_id=savings_account.id, + amount_kopecks=source.amount_kopecks, + occurred_on=source.occurred_on, + posted_on=source.posted_on, + description=source.description, + request_key=f"import-savings:{source.id}", + ) + db.add( + SourceRecordLink( + space_id=space_id, + source_record_id=source.id, + operation_id=operation.id, + kind="created", + ) + ) + continue if source.review_decision == "duplicate" and source.duplicate_operation_id: duplicate_operation = ( db.query(Operation) diff --git a/backend/app/ledger/models.py b/backend/app/ledger/models.py index 29f6c64..ae1bd44 100644 --- a/backend/app/ledger/models.py +++ b/backend/app/ledger/models.py @@ -17,9 +17,9 @@ from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base, PkMixin, TimestampMixin -ACCOUNT_KINDS = ("cash", "debit", "credit") +ACCOUNT_KINDS = ("cash", "debit", "credit", "savings") ACCOUNT_STATUSES = ("active", "archived") -OPERATION_TYPES = ("expense", "income", "transfer", "refund") +OPERATION_TYPES = ("expense", "income", "transfer", "refund", "adjustment") OPERATION_STATUSES = ("confirmed", "voided") @@ -28,10 +28,13 @@ class Account(Base, PkMixin, TimestampMixin): __tablename__ = "accounts" __table_args__ = ( - CheckConstraint("kind IN ('cash', 'debit', 'credit')", name="kind_valid"), + CheckConstraint( + "kind IN ('cash', 'debit', 'credit', 'savings')", name="kind_valid" + ), CheckConstraint("status IN ('active', 'archived')", name="status_valid"), CheckConstraint("currency = 'RUB'", name="currency_rub_only"), UniqueConstraint("space_id", "id", name="uq_accounts_space_id"), + UniqueConstraint("space_id", "system_key", name="uq_accounts_space_system_key"), ) space_id: Mapped[uuid.UUID] = mapped_column( @@ -45,6 +48,7 @@ class Account(Base, PkMixin, TimestampMixin): bank_code: Mapped[str | None] = mapped_column(String(32), nullable=True) masked_identifier: Mapped[str | None] = mapped_column(String(32), nullable=True) fingerprint: Mapped[str | None] = mapped_column(String(128), nullable=True) + system_key: Mapped[str | None] = mapped_column(String(64), nullable=True) status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) @@ -56,7 +60,10 @@ class Operation(Base, PkMixin, TimestampMixin): __tablename__ = "operations" __table_args__ = ( - CheckConstraint("type IN ('expense', 'income', 'transfer', 'refund')", name="type_valid"), + CheckConstraint( + "type IN ('expense', 'income', 'transfer', 'refund', 'adjustment')", + name="type_valid", + ), CheckConstraint("status IN ('confirmed', 'voided')", name="status_valid"), CheckConstraint("amount_kopecks > 0", name="amount_positive"), CheckConstraint( @@ -114,6 +121,8 @@ class Operation(Base, PkMixin, TimestampMixin): description: Mapped[str | None] = mapped_column(String(500), nullable=True) void_reason: Mapped[str | None] = mapped_column(String(500), nullable=True) request_key: Mapped[str | None] = mapped_column(String(120), nullable=True) + converted_from_expense: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + previous_category_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True) created_by_user_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) @@ -168,6 +177,9 @@ class Category(Base, PkMixin, TimestampMixin): name="fk_categories_linked_account_tenant", ), UniqueConstraint("space_id", "id", name="uq_categories_space_id"), + UniqueConstraint( + "space_id", "system_key", "linked_account_id", name="uq_categories_system_pair" + ), ) space_id: Mapped[uuid.UUID] = mapped_column( diff --git a/backend/app/ledger/router.py b/backend/app/ledger/router.py index 0e6926d..1b8ef3e 100644 --- a/backend/app/ledger/router.py +++ b/backend/app/ledger/router.py @@ -52,6 +52,14 @@ class OperationPatchBody(BaseModel): category_id: uuid.UUID | None = Field(default=None, alias="categoryId") +class AdjustBalanceBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + target_balance_kopecks: int = Field(ge=0, alias="targetBalanceKopecks") + occurred_on: date = Field(alias="occurredOn") + reason: str = Field(min_length=1, max_length=500) + + def account_json(db: Db, account: Any) -> dict: return { "id": account.id, @@ -62,6 +70,8 @@ def account_json(db: Db, account: Any) -> dict: "openedOn": account.opened_on, "bankCode": account.bank_code, "maskedNumber": account.masked_identifier, + "systemKey": account.system_key, + "nameKey": f"account.{account.system_key}" if account.system_key else None, "status": account.status, "balanceKopecks": service.account_balance(db, account), "version": account.version, @@ -91,6 +101,7 @@ def operation_json(db: Db, operation: Any) -> dict: "postedOn": operation.posted_on, "description": operation.description, "voidReason": operation.void_reason, + "convertedFromExpense": operation.converted_from_expense, "version": operation.version, "createdAt": operation.created_at, } @@ -276,3 +287,46 @@ def patch_operation( category_id=body.category_id, ) return operation_json(db, operation) + + +@router.post("/accounts/{account_id}/adjust-balance") +def adjust_balance( + account_id: uuid.UUID, body: AdjustBalanceBody, db: Db, context: Context, _csrf: Csrf +) -> dict: + from app.ledger import savings + + operation, old_balance, new_balance = savings.adjust_balance( + db, + space_id=context.space.id, + user=context.user, + account_id=account_id, + target_balance_kopecks=body.target_balance_kopecks, + occurred_on=body.occurred_on, + reason=body.reason, + ) + payload = operation_json(db, operation) + payload["oldBalanceKopecks"] = old_balance + payload["newBalanceKopecks"] = new_balance + return payload + + +@router.post("/operations/{operation_id}/savings-transfer") +def savings_transfer(operation_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict: + from app.ledger import savings + + operation = savings.convert_expense_to_savings( + db, space_id=context.space.id, user=context.user, operation_id=operation_id + ) + return operation_json(db, operation) + + +@router.post("/operations/{operation_id}/savings-transfer/revert") +def savings_transfer_revert( + operation_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf +) -> dict: + from app.ledger import savings + + operation = savings.revert_savings_conversion( + db, space_id=context.space.id, user=context.user, operation_id=operation_id + ) + return operation_json(db, operation) diff --git a/backend/app/ledger/savings.py b/backend/app/ledger/savings.py new file mode 100644 index 0000000..381957f --- /dev/null +++ b/backend/app/ledger/savings.py @@ -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 diff --git a/backend/app/ledger/service.py b/backend/app/ledger/service.py index e95f0ea..f78cb19 100644 --- a/backend/app/ledger/service.py +++ b/backend/app/ledger/service.py @@ -48,6 +48,15 @@ def _require_active_account(db: Session, *, space_id: uuid.UUID, account_id: uui return account +def _reject_system_account(account: Account) -> None: + """System accounts (savings) are journal-internal and never statement-facing.""" + if account.system_key is not None: + raise bad_request( + "LEDGER_SYSTEM_ACCOUNT_NOT_EXTERNAL", + "System accounts cannot host external income or expense operations", + ) + + def _validate_category(db: Session, *, space_id: uuid.UUID, category_id: uuid.UUID | None) -> None: if category_id is None: return @@ -58,6 +67,11 @@ def _validate_category(db: Session, *, space_id: uuid.UUID, category_id: uuid.UU raise not_found("BUDGET_CATEGORY_NOT_FOUND", "Category not found") if category.archived: raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived") + if category.system_key is not None: + raise conflict( + "BUDGET_CATEGORY_SYSTEM", + "System categories cannot be assigned to regular operations", + ) def account_balance(db: Session, account: Account) -> int: @@ -155,6 +169,7 @@ def create_expense( if existing is not None: return existing account = _require_active_account(db, space_id=space_id, account_id=account_id) + _reject_system_account(account) _validate_category(db, space_id=space_id, category_id=category_id) ensure_period_open_for_date(db, space_id=space_id, day=occurred_on) operation = Operation( @@ -210,6 +225,7 @@ def create_income( if existing is not None: return existing account = _require_active_account(db, space_id=space_id, account_id=account_id) + _reject_system_account(account) if income_source_id is not None: categorization.get_income_source(db, space_id=space_id, income_source_id=income_source_id) ensure_period_open_for_date(db, space_id=space_id, day=occurred_on) @@ -268,12 +284,16 @@ def create_transfer( source = _require_active_account(db, space_id=space_id, account_id=source_account_id) target = _require_active_account(db, space_id=space_id, account_id=target_account_id) ensure_period_open_for_date(db, space_id=space_id, day=occurred_on) + from app.ledger.savings import transfer_category_for_accounts + + category = transfer_category_for_accounts(db, space_id=space_id, source=source, target=target) operation = Operation( space_id=space_id, type="transfer", amount_kopecks=amount_kopecks, account_id=source.id, counter_account_id=target.id, + category_id=category.id if category is not None else None, occurred_on=occurred_on, posted_on=posted_on, description=description, @@ -463,6 +483,8 @@ def archive_account( db: Session, *, space_id: uuid.UUID, user: User, account_id: uuid.UUID ) -> Account: account = get_account(db, space_id=space_id, account_id=account_id) + if account.system_key is not None: + raise conflict("LEDGER_ACCOUNT_SYSTEM", "System accounts cannot be archived") if account.status == "archived": return account if account_balance(db, account) != 0: @@ -483,6 +505,8 @@ def archive_account( def delete_account(db: Session, *, space_id: uuid.UUID, user: User, account_id: uuid.UUID) -> None: account = get_account(db, space_id=space_id, account_id=account_id) + if account.system_key is not None: + raise conflict("LEDGER_ACCOUNT_SYSTEM", "System accounts cannot be deleted") used = ( db.query(Operation.id) .filter((Operation.account_id == account.id) | (Operation.counter_account_id == account.id)) diff --git a/backend/app/spaces/service.py b/backend/app/spaces/service.py index 6c04c41..1e8ffa9 100644 --- a/backend/app/spaces/service.py +++ b/backend/app/spaces/service.py @@ -15,6 +15,9 @@ def create_space(db: Session, *, user: User, name: str) -> FinancialSpace: db.flush() set_space_context(db, space.id) db.add(Membership(space_id=space.id, user_id=user.id, role="owner")) + from app.ledger.savings import ensure_savings_resources + + ensure_savings_resources(db, space_id=space.id) audit.record(db, event_type="space.created", space_id=space.id, user_id=user.id) return space diff --git a/backend/tests/test_adapter_contracts.py b/backend/tests/test_adapter_contracts.py index 8592484..b6f54fb 100644 --- a/backend/tests/test_adapter_contracts.py +++ b/backend/tests/test_adapter_contracts.py @@ -14,7 +14,11 @@ from openpyxl import Workbook from app.errors import AppError from app.imports.adapters import ADAPTERS, detect_adapter, magic_kind from app.imports.adapters import alfa_xlsx as alfa_module -from app.imports.adapters.alfa_xlsx import AlfaCreditXlsxAdapter, AlfaDebitXlsxAdapter +from app.imports.adapters.alfa_xlsx import ( + AlfaCreditXlsxAdapter, + AlfaDebitXlsxAdapter, + detect_savings_transfer, +) from app.imports.adapters.base import ( ALFA_DEBIT_XLSX_VERSION, BANK_STATUS_HOLD, @@ -89,7 +93,7 @@ class TestBaseContract: def test_per_contract_parser_versions(self) -> None: assert PARSER_VERSION == "1" - assert ALFA_DEBIT_XLSX_VERSION == "2" + assert ALFA_DEBIT_XLSX_VERSION == "3" assert OZON_DEBIT_PDF_VERSION == "2" assert ROCKETBANK_DEBIT_PDF_VERSION == "2" assert AlfaDebitXlsxAdapter.parser_version == ALFA_DEBIT_XLSX_VERSION @@ -472,6 +476,122 @@ class TestAlfaDebitContract: assert "quota" in exc.value.message +class TestAlfaSavingsRule: + """«Копилка для сдачи» contract: exact normalized phrase, statement-side + source account, outgoing posted rows only. Every input is synthetic.""" + + STATEMENT_ACCOUNT = "00000000000000000001" + SERVICE_ACCOUNT = "00000000000000000306" + PHRASE = ( + 'Перечисление средств в рамках услуги "Копилка для сдачи" ' + "со счета {source} на счет {target}" + ) + + def rule( + self, + description: str | None, + *, + direction: str = "out", + bank_status: str = "posted", + statement_account: str = STATEMENT_ACCOUNT, + ) -> bool: + return detect_savings_transfer( + description, + direction=direction, + bank_status=bank_status, + statement_account=statement_account, + ) + + def test_exact_phrase_matches(self) -> None: + assert self.rule( + self.PHRASE.format(source=self.STATEMENT_ACCOUNT, target=self.SERVICE_ACCOUNT) + ) + + def test_spaces_case_and_quotes_are_normalized(self) -> None: + noisy = ( + " Перечисление\u00a0средств в рамках услуги «КОПИЛКА ДЛЯ СДАЧИ» " + f"со счёта {self.STATEMENT_ACCOUNT} на счет {self.SERVICE_ACCOUNT} " + ) + assert self.rule(noisy) + + def test_mismatched_source_account_fails_closed(self) -> None: + assert not self.rule( + self.PHRASE.format(source="00000000000000000099", target=self.SERVICE_ACCOUNT) + ) + + def test_incoming_row_never_matches(self) -> None: + assert not self.rule( + self.PHRASE.format(source=self.STATEMENT_ACCOUNT, target=self.SERVICE_ACCOUNT), + direction="in", + ) + + def test_hold_row_never_matches(self) -> None: + assert not self.rule( + self.PHRASE.format(source=self.STATEMENT_ACCOUNT, target=self.SERVICE_ACCOUNT), + bank_status="hold", + ) + + def test_similar_description_without_full_pattern_fails_closed(self) -> None: + assert not self.rule("Копилка для сдачи: пополнение") + assert not self.rule( + 'Перечисление в рамках услуги "Копилка для сдачи" со счета 123 на счет 306' + ) + + def test_masked_statement_account_fails_closed(self) -> None: + assert not self.rule( + self.PHRASE.format(source=self.STATEMENT_ACCOUNT, target=self.SERVICE_ACCOUNT), + statement_account="0000********0001", + ) + + def savings_statement_rows(self) -> list[list[object]]: + rows = [list(row) for row in synthetic_alfa_rows()] + rows[9] = [ + None, None, None, None, None, None, None, None, None, None, + "Расходы", None, "416,90 RUR", + ] + savings_row = [ + "03.09.2026", + "03.09.2026", + None, + "000000000000009", + "Переводы", + None, + None, + None, + None, + None, + None, + self.PHRASE.format(source=self.STATEMENT_ACCOUNT, target=self.SERVICE_ACCOUNT), + "-123,45", + "Выполнен", + ] + rows.insert(17, savings_row) + return rows + + def test_parse_marks_savings_rows_and_bumps_version(self) -> None: + adapter = AlfaDebitXlsxAdapter() + raw = alfa_workbook_bytes(self.savings_statement_rows()) + draft = adapter.parse(raw) + assert draft.parser_version == ALFA_DEBIT_XLSX_VERSION + by_code = {row.external_id: row for row in draft.rows} + assert len(draft.rows) == 6 + marked = by_code["000000000000009"] + assert marked.savings_transfer is True + assert marked.direction == "out" + assert marked.bank_status == "posted" + assert marked.amount_kopecks == 12345 + assert all( + row.savings_transfer is False + for code, row in by_code.items() + if code != "000000000000009" + ) + + def test_parse_leaves_plain_expenses_unmarked(self) -> None: + adapter = AlfaDebitXlsxAdapter() + draft = adapter.parse(alfa_workbook_bytes(synthetic_alfa_rows())) + assert all(not row.savings_transfer for row in draft.rows) + + def ozon_extracted() -> tuple[list[str], list[list[list[object]]]]: pages_text = [ "\n".join( diff --git a/backend/tests/test_budget_savings.py b/backend/tests/test_budget_savings.py new file mode 100644 index 0000000..00b2583 --- /dev/null +++ b/backend/tests/test_budget_savings.py @@ -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 diff --git a/backend/tests/test_category_definitions.py b/backend/tests/test_category_definitions.py new file mode 100644 index 0000000..8f23e44 --- /dev/null +++ b/backend/tests/test_category_definitions.py @@ -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 diff --git a/backend/tests/test_import_savings.py b/backend/tests/test_import_savings.py new file mode 100644 index 0000000..603a43d --- /dev/null +++ b/backend/tests/test_import_savings.py @@ -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" diff --git a/backend/tests/test_savings.py b/backend/tests/test_savings.py new file mode 100644 index 0000000..811369e --- /dev/null +++ b/backend/tests/test_savings.py @@ -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 + ) diff --git a/backend/tests/test_savings_migration.py b/backend/tests/test_savings_migration.py new file mode 100644 index 0000000..e53ba0f --- /dev/null +++ b/backend/tests/test_savings_migration.py @@ -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() diff --git a/docs/adr/0002-journal-and-budget-reserves.md b/docs/adr/0002-journal-and-budget-reserves.md index 7689349..fd14f08 100644 --- a/docs/adr/0002-journal-and-budget-reserves.md +++ b/docs/adr/0002-journal-and-budget-reserves.md @@ -10,10 +10,13 @@ The product needs trustworthy account balances including credit-card debt, zero- ## Decision -- The ledger is a journal of `operations` (`expense | income | transfer | refund`; `confirmed | voided`) and `postings` (one posting for external income/expense/refund; two equal and opposite postings for an internal transfer). Amounts are integer kopecks; no floats. +- The ledger is a journal of `operations` (`expense | income | transfer | refund | adjustment`; `confirmed | voided`) and `postings` (one posting for external income/expense/refund; two equal and opposite postings for an internal transfer; exactly one signed posting for a balance adjustment). Amounts are integer kopecks; no floats. - Balances are derived, never mutated directly: opening position plus confirmed, non-voided postings. Credit debt follows one documented sign convention. - This is **not** event sourcing and **not** full double-entry against the external world: the journal is just enough structure to make balances reconstructable and voiding explicit. - Budgeting splits plan from funding: `plan_assignments` express intent, `funding_movements` move actually received money from the general pool. Monthly categories fold their remainder into the pool at close; savings envelopes carry balances forward; each credit card has a permanent reserve. +- Every financial space has exactly one visible system `savings` account paired with one system `savings_envelope` category, joined by `system_key`. The pair is created idempotently (space creation, migration, domain commands), is protected from rename/archive/delete, and is identified only by `system_key` — same-named user entities are never captured. Transfers into the envelope are carved out of the general pool as its backing; transfers back out return to the pool. +- A balance adjustment is a journal operation with a single signed posting for the delta (the operation amount stays positive; direction lives in the posting). It moves assets and the envelope balance but never income, expenses, cash flow or the budget pool, and every adjustment stores old/new/delta plus a mandatory reason in the audit log. Direct changes to stored balances remain forbidden. +- Expenses can be converted into savings transfers and converted back; the conversion marks the operation and stores the previous category so reversion restores type, category and postings exactly. Closed periods protect conversions, reversions and adjustments with the same reopen-before-change rule as other mutations. - Period closure writes an immutable snapshot. Changing or voiding an operation in a closed month requires an explicit cascade reopen of all subsequent periods; late refunds after closure credit the current pool and never rewrite the closed fact. ## Consequences diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index e89b8fe..5ca0935 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -1,2 +1,5 @@ +packages: + - "." + allowBuilds: esbuild@0.25.12: true diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 5a834e6..f6d33d6 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -2,13 +2,17 @@ import { apiClient } from './client' import type { AcceptInvitePayload, Account, + AdjustBalancePayload, + AdjustBalanceResult, BalancesReport, BudgetCategory, BudgetSummary, CashFlowMonth, + CategoryDefinition, CategorySpendItem, ConfirmImportPayload, CreateAccountPayload, + CreateCategoryPayload, CreateOperationPayload, ExportInfo, ImportDetail, @@ -17,8 +21,10 @@ import type { Operation, OperationFilters, Page, + PatchCategoryDefinitionPayload, PlanFactReport, PasswordResetPayload, + ResolveHoldPayload, SaveRowDecisionPayload, SpaceDeletionStatus, User, @@ -42,6 +48,8 @@ export const api = { apiClient.post('/accounts', { json: payload }), archive: (id: string) => apiClient.patch(`/accounts/${id}`, { json: { status: 'archived' } }), + adjustBalance: (id: string, payload: AdjustBalancePayload) => + apiClient.post(`/accounts/${id}/adjust-balance`, { json: payload }), }, operations: { list: (filters: OperationFilters) => @@ -52,6 +60,10 @@ export const api = { apiClient.post(`/operations/${id}/void`, { json: { reason } }), categorize: (id: string, categoryId: string) => apiClient.patch(`/operations/${id}`, { json: { categoryId } }), + savingsTransfer: (id: string) => + apiClient.post(`/operations/${id}/savings-transfer`), + savingsTransferRevert: (id: string) => + apiClient.post(`/operations/${id}/savings-transfer/revert`), }, budget: { summary: (month: string) => @@ -70,9 +82,18 @@ export const api = { reopen: (month: string) => apiClient.post('/budget/reopen', { json: { month } }), copyPrevious: (month: string) => apiClient.post('/budget/copy-previous', { json: { month } }), + categoryDefinitions: (includeArchived: boolean) => + apiClient.get('/budget/category-definitions', { + query: { includeArchived }, + }), + createCategory: (payload: CreateCategoryPayload) => + apiClient.post('/budget/categories', { json: payload }), + updateCategoryDefinition: (id: string, payload: PatchCategoryDefinitionPayload) => + apiClient.patch(`/budget/category-definitions/${id}`, { json: payload }), }, imports: { - list: () => apiClient.get('/imports'), + list: (month?: string) => + apiClient.get('/imports', { query: { month } }), get: (id: string) => apiClient.get(`/imports/${id}`), upload: (file: File, accountId: string) => { const formData = new FormData() @@ -82,6 +103,8 @@ export const api = { }, saveRowDecision: (id: string, rowId: string, payload: SaveRowDecisionPayload) => apiClient.patch(`/imports/${id}/rows/${rowId}`, { json: payload }), + resolveHold: (id: string, rowId: string, payload: ResolveHoldPayload) => + apiClient.post(`/imports/${id}/rows/${rowId}/resolve`, { json: payload }), confirm: (id: string, payload: ConfirmImportPayload) => apiClient.post(`/imports/${id}/confirm`, { json: payload }), reparse: (id: string) => apiClient.post(`/imports/${id}/reparse`), diff --git a/frontend/src/api/generated/schema.d.ts b/frontend/src/api/generated/schema.d.ts index 13485b3..a0bd206 100644 --- a/frontend/src/api/generated/schema.d.ts +++ b/frontend/src/api/generated/schema.d.ts @@ -89,6 +89,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/auth/password-reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Request Password Reset */ + post: operations["request_password_reset_api_v1_auth_password_reset_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/password-reset/confirm": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Confirm Password Reset */ + post: operations["confirm_password_reset_api_v1_auth_password_reset_confirm_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/auth/me": { parameters: { query?: never; @@ -140,6 +174,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/spaces/current/invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Invite Member */ + post: operations["invite_member_api_v1_spaces_current_invitations_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/spaces/current/deletion": { parameters: { query?: never; @@ -264,6 +315,57 @@ export interface paths { patch: operations["patch_operation_api_v1_operations__operation_id__patch"]; trace?: never; }; + "/api/v1/accounts/{account_id}/adjust-balance": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Adjust Balance */ + post: operations["adjust_balance_api_v1_accounts__account_id__adjust_balance_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/operations/{operation_id}/savings-transfer": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Savings Transfer */ + post: operations["savings_transfer_api_v1_operations__operation_id__savings_transfer_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/operations/{operation_id}/savings-transfer/revert": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Savings Transfer Revert */ + post: operations["savings_transfer_revert_api_v1_operations__operation_id__savings_transfer_revert_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/budget/summary": { parameters: { query?: never; @@ -299,6 +401,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/budget/category-definitions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Category Definitions */ + get: operations["category_definitions_api_v1_budget_category_definitions_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/budget/category-definitions/{category_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Patch Category Definition */ + patch: operations["patch_category_definition_api_v1_budget_category_definitions__category_id__patch"]; + trace?: never; + }; "/api/v1/budget/categories/{category_id}": { parameters: { query?: never; @@ -555,6 +691,23 @@ export interface paths { patch: operations["decide_row_api_v1_imports__document_id__rows__row_id__patch"]; trace?: never; }; + "/api/v1/imports/{document_id}/rows/{row_id}/resolve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Resolve Hold */ + post: operations["resolve_hold_api_v1_imports__document_id__rows__row_id__resolve_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/imports/{document_id}/confirm": { parameters: { query?: never; @@ -732,10 +885,10 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; - put?: never; /** Download */ - post: operations["download_api_v1_exports__export_id__download_post"]; + get: operations["download_api_v1_exports__export_id__download_get"]; + put?: never; + post?: never; delete?: never; options?: never; head?: never; @@ -790,6 +943,18 @@ export interface components { /** Maskednumber */ maskedNumber?: string | null; }; + /** AdjustBalanceBody */ + AdjustBalanceBody: { + /** Targetbalancekopecks */ + targetBalanceKopecks: number; + /** + * Occurredon + * Format: date + */ + occurredOn: string; + /** Reason */ + reason: string; + }; /** AssignmentItem */ AssignmentItem: { /** @@ -823,6 +988,13 @@ export interface components { */ kind: "monthly" | "savings_envelope"; }; + /** CategoryDefinitionPatchBody */ + CategoryDefinitionPatchBody: { + /** Name */ + name?: string | null; + /** Archived */ + archived?: boolean | null; + }; /** CategoryFundingBody */ CategoryFundingBody: { /** Month */ @@ -889,6 +1061,15 @@ export interface components { /** Detail */ detail?: components["schemas"]["ValidationError"][]; }; + /** HoldResolveBody */ + HoldResolveBody: { + /** Action */ + action: string; + /** Reason */ + reason: string; + /** Accountid */ + accountId?: string | null; + }; /** InviteBody */ InviteBody: { /** @@ -907,6 +1088,14 @@ export interface components { /** Password */ password: string; }; + /** MemberInviteBody */ + MemberInviteBody: { + /** + * Email + * Format: email + */ + email: string; + }; /** MonthBody */ MonthBody: { /** Month */ @@ -957,6 +1146,21 @@ export interface components { /** Plannedkopecks */ plannedKopecks: number; }; + /** ResetBody */ + ResetBody: { + /** Token */ + token: string; + /** Password */ + password: string; + }; + /** ResetRequestBody */ + ResetRequestBody: { + /** + * Email + * Format: email + */ + email: string; + }; /** ReviewBody */ ReviewBody: { /** @@ -1150,6 +1354,76 @@ export interface operations { }; }; }; + request_password_reset_api_v1_auth_password_reset_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ResetRequestBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + confirm_password_reset_api_v1_auth_password_reset_confirm_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ResetBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; me_api_v1_auth_me_get: { parameters: { query?: never; @@ -1245,6 +1519,45 @@ export interface operations { }; }; }; + invite_member_api_v1_spaces_current_invitations_post: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path?: never; + cookie?: { + fin_session?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MemberInviteBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; deletion_status_api_v1_spaces_current_deletion_get: { parameters: { query?: never; @@ -1541,6 +1854,7 @@ export interface operations { occurredFrom?: string | null; occurredTo?: string | null; search?: string | null; + uncategorized?: boolean; limit?: number; cursor?: string | null; }; @@ -1696,6 +2010,121 @@ export interface operations { }; }; }; + adjust_balance_api_v1_accounts__account_id__adjust_balance_post: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path: { + account_id: string; + }; + cookie?: { + fin_session?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdjustBalanceBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + savings_transfer_api_v1_operations__operation_id__savings_transfer_post: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path: { + operation_id: string; + }; + cookie?: { + fin_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + savings_transfer_revert_api_v1_operations__operation_id__savings_transfer_revert_post: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path: { + operation_id: string; + }; + cookie?: { + fin_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; summary_api_v1_budget_summary_get: { parameters: { query: { @@ -1805,6 +2234,82 @@ export interface operations { }; }; }; + category_definitions_api_v1_budget_category_definitions_get: { + parameters: { + query?: { + includeArchived?: boolean; + }; + header?: never; + path?: never; + cookie?: { + fin_session?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }[]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + patch_category_definition_api_v1_budget_category_definitions__category_id__patch: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path: { + category_id: string; + }; + cookie?: { + fin_session?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CategoryDefinitionPatchBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; update_plan_api_v1_budget_categories__category_id__patch: { parameters: { query?: never; @@ -2246,7 +2751,9 @@ export interface operations { }; imports_api_v1_imports_get: { parameters: { - query?: never; + query?: { + month?: string | null; + }; header?: never; path?: never; cookie?: { @@ -2434,6 +2941,48 @@ export interface operations { }; }; }; + resolve_hold_api_v1_imports__document_id__rows__row_id__resolve_post: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path: { + document_id: string; + row_id: string; + }; + cookie?: { + fin_session?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["HoldResolveBody"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; confirm_api_v1_imports__document_id__confirm_post: { parameters: { query?: never; @@ -2796,12 +3345,10 @@ export interface operations { }; }; }; - download_api_v1_exports__export_id__download_post: { + download_api_v1_exports__export_id__download_get: { parameters: { query?: never; - header?: { - "X-CSRF-Token"?: string | null; - }; + header?: never; path: { export_id: string; }; diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index 9eb07a5..5639a04 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -23,8 +23,10 @@ export const queryKeys = { operations: (filters: OperationFilters) => [SPACE_ROOT, 'operations', filters] as const, budgetSummary: (month: string) => [SPACE_ROOT, 'budget', 'summary', month] as const, budgetCategories: (month: string) => [SPACE_ROOT, 'budget', 'categories', month] as const, - imports: () => [SPACE_ROOT, 'imports'] as const, + imports: (month?: string) => + month ? ([SPACE_ROOT, 'imports', month] as const) : ([SPACE_ROOT, 'imports'] as const), import: (id: string) => [SPACE_ROOT, 'imports', id] as const, + budgetCategoryDefinitions: () => [SPACE_ROOT, 'budget', 'category-definitions'] as const, analyticsCategorySpend: (from: string, to: string) => [SPACE_ROOT, 'analytics', 'category-spend', from, to] as const, analyticsPlanFact: (month: string) => [SPACE_ROOT, 'analytics', 'plan-fact', month] as const, diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index f7c4112..e7a62cf 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -8,7 +8,7 @@ export interface User { timezone: string } -export type AccountType = 'cash' | 'debit' | 'credit' +export type AccountType = 'cash' | 'debit' | 'credit' | 'savings' export type AccountStatus = 'active' | 'archived' export interface Account { @@ -20,9 +20,15 @@ export interface Account { maskedNumber: string | null openedOn: string currency: 'RUB' + systemKey: string | null + nameKey: string | null balanceKopecks: number } +export function isSystemAccount(account: Account): boolean { + return account.systemKey !== null +} + export interface CreateAccountPayload { name: string type: AccountType @@ -32,7 +38,7 @@ export interface CreateAccountPayload { maskedNumber?: string } -export type OperationKind = 'expense' | 'income' | 'transfer' | 'refund' +export type OperationKind = 'expense' | 'income' | 'transfer' | 'refund' | 'adjustment' export type OperationStatus = 'confirmed' | 'voided' export interface Operation { @@ -51,10 +57,22 @@ export interface Operation { incomeSourceName: string | null description: string | null parentOperationId: string | null + convertedFromExpense: boolean version: number createdAt: string } +export interface AdjustBalancePayload { + targetBalanceKopecks: number + occurredOn: string + reason: string +} + +export interface AdjustBalanceResult extends Operation { + oldBalanceKopecks: number + newBalanceKopecks: number +} + export type CreateOperationPayload = | { kind: 'expense' @@ -131,11 +149,25 @@ export interface BudgetWarning { message?: string } -export interface BudgetBlocker { +export interface CountBlocker { + code: 'IMPORTS_UNRESOLVED' | 'IMPORT_UNRESOLVED_HOLDS' | 'BUDGET_UNCATEGORIZED_EXPENSES' + count: number + message?: string +} + +export interface CategoriesBlocker { + code: 'BUDGET_NEGATIVE_ENVELOPE' + categories: string[] + message?: string +} + +export interface UnknownBlocker { code: string message?: string } +export type BudgetBlocker = CountBlocker | CategoriesBlocker | UnknownBlocker + export type ExpectedIncomeStatus = 'expected' | 'partially_matched' | 'matched' export interface ExpectedIncome { @@ -162,12 +194,14 @@ export interface BudgetSummary { } export type CategoryKind = 'monthly' | 'savings_envelope' | 'credit_reserve' +export type CreatableCategoryKind = 'monthly' | 'savings_envelope' export interface BudgetCategory { id: string name: string | null nameKey: string | null kind: CategoryKind + system: boolean archived: boolean creditAccountId: string | null plannedKopecks: number @@ -176,6 +210,26 @@ export interface BudgetCategory { availableKopecks: number } +export interface CategoryDefinition { + id: string + name: string | null + nameKey: string | null + kind: CategoryKind + systemKey: string | null + linkedAccountId: string | null + archived: boolean +} + +export interface CreateCategoryPayload { + name: string + kind: CreatableCategoryKind +} + +export interface PatchCategoryDefinitionPayload { + name?: string + archived?: boolean +} + export type ImportStatus = | 'uploaded' | 'queued' @@ -194,6 +248,7 @@ export interface ImportSummary { accountName: string | null periodFrom: string | null periodTo: string | null + unresolvedHoldCount: number createdAt: string } @@ -213,7 +268,20 @@ export interface Reconciliation { reason: string | null } -export type RowDecision = 'pending' | 'create' | 'discard' | 'duplicate' | 'transfer' +export type RowDecision = 'pending' | 'create' | 'discard' | 'duplicate' | 'transfer' | 'savings_transfer' + +export type RowBankStatus = 'posted' | 'hold' + +export type HoldResolutionAction = 'confirm_as_posted' | 'discard_as_cancelled' + +export interface HoldResolution { + resolution: 'confirmed_as_posted' | 'discarded_as_cancelled' + resolvingSourceRecordId: string | null + operationId: string | null + resolvedByUserId: string | null + reason: string | null + resolvedAt: string | null +} export interface ImportRow { id: string @@ -221,11 +289,13 @@ export interface ImportRow { occurredOn: string amountKopecks: number direction: 'in' | 'out' + bankStatus: RowBankStatus description: string | null payeeHint: string | null warnings: string[] decision: RowDecision duplicateOfOperationId: string | null + holdResolution: HoldResolution | null } export interface ImportDetail extends ImportSummary { @@ -240,6 +310,8 @@ export interface ImportDetail extends ImportSummary { discard: number duplicate: number transfer: number + savingsTransfer: number + pending: number } } @@ -248,6 +320,12 @@ export interface SaveRowDecisionPayload { duplicateOfOperationId?: string } +export interface ResolveHoldPayload { + action: HoldResolutionAction + reason: string + accountId?: string +} + export interface ConfirmImportPayload { confirmedWithoutFullReconciliation?: boolean reason?: string diff --git a/frontend/src/components/budget/BudgetBlockers.tsx b/frontend/src/components/budget/BudgetBlockers.tsx new file mode 100644 index 0000000..94abd45 --- /dev/null +++ b/frontend/src/components/budget/BudgetBlockers.tsx @@ -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 ( +
    + {blockers.map((blocker) => { + const cta = blockerCta(blocker, month, t) + return ( +
  • + {blockerText(blocker, t)} + {cta ? ( + + {cta.label} + + ) : null} +
  • + ) + })} +
+ ) +} diff --git a/frontend/src/components/budget/CategoryManagerModal.tsx b/frontend/src/components/budget/CategoryManagerModal.tsx new file mode 100644 index 0000000..efc3d75 --- /dev/null +++ b/frontend/src/components/budget/CategoryManagerModal.tsx @@ -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 + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { name: '', kind: 'monthly' }, + }) + + const submit = handleSubmit((values) => { + create.mutate(values, { onSuccess: () => reset({ name: '', kind: values.kind }) }) + }) + + return ( +
+ + + {CREATE_KINDS.map((kind) => ( + + ))} + + + + ) +} + +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 + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { name: definition.name ?? '' }, + }) + + const submit = handleSubmit((values) => { + update.mutate( + { id: definition.id, payload: { name: values.name } }, + { onSuccess: onClose }, + ) + }) + + return ( + +
+ + + +
+ ) +} + +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(null) + const [archiveTarget, setArchiveTarget] = useState(null) + return ( + + +
+ + +
+ + {(all) => { + const list = all.filter((item) => (tab === 'active' ? !item.archived : item.archived)) + if (list.length === 0) { + return ( +

+ {tab === 'active' + ? t('budget.manager.emptyActive') + : t('budget.manager.emptyArchive')} +

+ ) + } + return ( +
    + {list.map((definition) => { + const isSystem = definition.systemKey !== null + return ( +
  • +
    + + {categoryLabel(definition, t)} + {isSystem ? ( + {t('budget.manager.systemBadge')} + ) : null} + {definition.archived ? ( + {t('accounts.status.archived')} + ) : null} + + + {t(`budget.category.kind.${definition.kind}`)} + {isSystem ? ` · ${t('budget.manager.systemProtected')}` : ''} + +
    + {!isSystem ? ( +
    + {!definition.archived ? ( + <> + + + + ) : ( + + )} +
    + ) : null} +
  • + ) + })} +
+ ) + }} +
+ {renameTarget ? ( + setRenameTarget(null)} /> + ) : null} + setArchiveTarget(null)} + onConfirm={() => { + if (archiveTarget) { + update.mutate({ id: archiveTarget.id, payload: { archived: true } }) + } + }} + /> +
+ ) +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 747802b..b833317 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -16,7 +16,7 @@ const NAV_ITEMS = [ ] as const const MOBILE_NAV_ITEMS = NAV_ITEMS.filter((item) => - ['/', '/operations', '/imports', '/analytics', '/settings'].includes(item.to), + ['/', '/budget', '/operations', '/imports', '/review', '/accounts'].includes(item.to), ) export function AppLayout() { diff --git a/frontend/src/hooks/useAccounts.ts b/frontend/src/hooks/useAccounts.ts index 17351ac..4a89f9b 100644 --- a/frontend/src/hooks/useAccounts.ts +++ b/frontend/src/hooks/useAccounts.ts @@ -1,7 +1,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { api } from '@/api/endpoints' import { invalidationRoots, queryKeys } from '@/api/queryKeys' -import type { CreateAccountPayload } from '@/api/types' +import type { AdjustBalancePayload, CreateAccountPayload } from '@/api/types' +import { isSystemAccount } from '@/api/types' import { useToast } from '@/components/ui/Toaster' import { useTranslation } from 'react-i18next' import { describeApiError } from '@/lib/apiErrors' @@ -15,6 +16,13 @@ export function useActiveAccounts() { return { ...accounts, data: accounts.data?.filter((account) => account.status === 'active') } } +/** Active non-system accounts: the savings system account is not a valid + * target for statements, expenses, incomes or refunds. */ +export function useUserAccounts() { + const accounts = useActiveAccounts() + return { ...accounts, data: accounts.data?.filter((account) => !isSystemAccount(account)) } +} + export function useCreateAccount() { const queryClient = useQueryClient() const { t } = useTranslation() @@ -44,3 +52,21 @@ export function useArchiveAccount() { onError: (error) => toast.push('error', describeApiError(error, t)), }) } + +export function useAdjustBalance() { + const queryClient = useQueryClient() + const { t } = useTranslation() + const toast = useToast() + return useMutation({ + mutationFn: ({ id, payload }: { id: string; payload: AdjustBalancePayload }) => + api.accounts.adjustBalance(id, payload), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: invalidationRoots.accounts }) + void queryClient.invalidateQueries({ queryKey: invalidationRoots.operations }) + void queryClient.invalidateQueries({ queryKey: invalidationRoots.budget }) + void queryClient.invalidateQueries({ queryKey: invalidationRoots.analytics }) + toast.push('success', t('toast.balanceAdjusted')) + }, + onError: (error) => toast.push('error', describeApiError(error, t)), + }) +} diff --git a/frontend/src/hooks/useBudget.ts b/frontend/src/hooks/useBudget.ts index a621597..7d966c9 100644 --- a/frontend/src/hooks/useBudget.ts +++ b/frontend/src/hooks/useBudget.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { api } from '@/api/endpoints' import { invalidationRoots, queryKeys } from '@/api/queryKeys' +import type { CreateCategoryPayload, PatchCategoryDefinitionPayload } from '@/api/types' import { useToast } from '@/components/ui/Toaster' import { useTranslation } from 'react-i18next' import { describeApiError } from '@/lib/apiErrors' @@ -16,6 +17,64 @@ export function useBudgetCategories(month: string) { }) } +export function useCategoryDefinitions() { + return useQuery({ + queryKey: queryKeys.budgetCategoryDefinitions(), + queryFn: () => api.budget.categoryDefinitions(true), + }) +} + +function useCategoryDefinitionInvalidation() { + const queryClient = useQueryClient() + return () => { + void queryClient.invalidateQueries({ queryKey: invalidationRoots.budget }) + void queryClient.invalidateQueries({ queryKey: invalidationRoots.operations }) + void queryClient.invalidateQueries({ queryKey: invalidationRoots.analytics }) + } +} + +export function useCreateCategory() { + const invalidate = useCategoryDefinitionInvalidation() + const { t } = useTranslation() + const toast = useToast() + const onError = useBudgetErrorToaster() + return useMutation({ + mutationFn: (payload: CreateCategoryPayload) => api.budget.createCategory(payload), + onSuccess: (definition) => { + invalidate() + toast.push('success', t('toast.categoryCreated', { name: definition.name ?? '' })) + }, + onError, + }) +} + +export function useUpdateCategoryDefinition() { + const invalidate = useCategoryDefinitionInvalidation() + const { t } = useTranslation() + const toast = useToast() + const onError = useBudgetErrorToaster() + return useMutation({ + mutationFn: ({ + id, + payload, + }: { + id: string + payload: PatchCategoryDefinitionPayload + }) => api.budget.updateCategoryDefinition(id, payload), + onSuccess: (definition, variables) => { + invalidate() + if (variables.payload.archived === true) { + toast.push('success', t('toast.categoryArchived')) + } else if (variables.payload.archived === false) { + toast.push('success', t('toast.categoryRestored')) + } else { + toast.push('success', t('toast.categoryRenamed', { name: definition.name ?? '' })) + } + }, + onError, + }) +} + function useBudgetErrorToaster() { const { t } = useTranslation() const toast = useToast() diff --git a/frontend/src/hooks/useImports.ts b/frontend/src/hooks/useImports.ts index 1d000af..b728b7e 100644 --- a/frontend/src/hooks/useImports.ts +++ b/frontend/src/hooks/useImports.ts @@ -4,6 +4,7 @@ import { invalidationRoots, queryKeys } from '@/api/queryKeys' import type { ConfirmImportPayload, ImportStatus, + ResolveHoldPayload, SaveRowDecisionPayload, } from '@/api/types' import { useToast } from '@/components/ui/Toaster' @@ -11,10 +12,10 @@ import { useTranslation } from 'react-i18next' import { describeApiError } from '@/lib/apiErrors' import { importListPollInterval, importPollInterval } from '@/lib/importStatus' -export function useImports() { +export function useImports(month?: string) { return useQuery({ - queryKey: queryKeys.imports(), - queryFn: api.imports.list, + queryKey: queryKeys.imports(month), + queryFn: () => api.imports.list(month), refetchInterval: (query) => { const statuses = query.state.data?.map((item) => item.status as ImportStatus) return importListPollInterval(statuses) @@ -67,6 +68,25 @@ export function useSaveRowDecision(importId: string) { }) } +export function useResolveHold(importId: string) { + const queryClient = useQueryClient() + const { t } = useTranslation() + const toast = useToast() + return useMutation({ + mutationFn: ({ rowId, payload }: { rowId: string; payload: ResolveHoldPayload }) => + api.imports.resolveHold(importId, rowId, payload), + onSuccess: (updated) => { + queryClient.setQueryData(queryKeys.import(importId), updated) + void queryClient.invalidateQueries({ queryKey: invalidationRoots.imports }) + void queryClient.invalidateQueries({ queryKey: invalidationRoots.operations }) + void queryClient.invalidateQueries({ queryKey: invalidationRoots.accounts }) + void queryClient.invalidateQueries({ queryKey: invalidationRoots.budget }) + toast.push('success', t('toast.holdResolved')) + }, + onError: (error) => toast.push('error', describeApiError(error, t)), + }) +} + export function useConfirmImport(importId: string) { const queryClient = useQueryClient() const { t } = useTranslation() diff --git a/frontend/src/hooks/useOperations.ts b/frontend/src/hooks/useOperations.ts index 9f9190b..968f917 100644 --- a/frontend/src/hooks/useOperations.ts +++ b/frontend/src/hooks/useOperations.ts @@ -55,3 +55,17 @@ export function useCategorizeOperation() { 'toast.categoryAssigned', ) } + +export function useConvertToSavings() { + return useLedgerMutation( + ({ id }: { id: string }) => api.operations.savingsTransfer(id), + 'toast.convertedToSavings', + ) +} + +export function useRevertSavingsConversion() { + return useLedgerMutation( + ({ id }: { id: string }) => api.operations.savingsTransferRevert(id), + 'toast.savingsConversionReverted', + ) +} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 2da93a0..cf845ff 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -88,7 +88,23 @@ "validation_error": "Please check the form fields.", "conflict": "Data was changed elsewhere. Refresh and try again.", "rate_limited": "Too many attempts. Please wait a moment.", - "session_expired": "Your session has expired. Please sign in again." + "session_expired": "Your session has expired. Please sign in again.", + "LEDGER_ADJUST_NOOP": "The balance already equals the target — nothing to adjust.", + "LEDGER_ADJUST_TARGET_NEGATIVE": "The target balance cannot be negative.", + "LEDGER_ADJUST_REASON_REQUIRED": "An adjustment reason is required.", + "LEDGER_ACCOUNT_ARCHIVED": "The account is archived — restore it first.", + "LEDGER_SAVINGS_CONVERT_INVALID": "Only a confirmed expense in an open month can be converted to savings.", + "LEDGER_SAVINGS_REVERT_INVALID": "Only a previously converted transfer can be reverted to an expense.", + "LEDGER_SAVINGS_CONFLICT": "A system savings resource conflict was detected.", + "BUDGET_PERIOD_CLOSED": "The month is closed. Reopen it first.", + "BUDGET_CATEGORY_SYSTEM": "System categories cannot be renamed or archived.", + "BUDGET_CATEGORY_ARCHIVED": "The category is archived — restore it first.", + "BUDGET_CATEGORY_NAME_REQUIRED": "A category name is required.", + "BUDGET_CATEGORY_KIND_INVALID": "Unknown category kind.", + "IMPORT_HOLD_REASON_REQUIRED": "A resolution reason is required.", + "IMPORT_HOLD_ALREADY_RESOLVED": "This operation already has a resolution.", + "IMPORT_HOLD_DOCUMENT_NOT_CONFIRMED": "Holds can be resolved only after the import is applied.", + "IMPORT_HOLD_ACCOUNT_REQUIRED": "The statement account mapping is missing." }, "auth": { "loginTitle": "Sign in", @@ -120,7 +136,7 @@ }, "accounts": { "title": "Accounts", - "subtitle": "Debit, cash and credit accounts of the space.", + "subtitle": "Debit, cash, credit accounts and the system savings account.", "add": "Add account", "empty": "No accounts yet. Create your first account to start tracking.", "formTitle": "New account", @@ -128,6 +144,8 @@ "type.cash": "Cash", "type.debit": "Debit", "type.credit": "Credit card", + "type.savings": "Savings", + "systemBadge": "System", "bank": "Bank", "bankHint": "Optional, e.g. “alfa”", "maskedNumber": "Masked number", @@ -143,7 +161,15 @@ "archive": "Archive", "archiveBlockedHint": "Only an account with zero balance can be archived", "archiveConfirmTitle": "Archive account", - "archiveConfirmBody": "Account “{{name}}” will leave working lists but remain in history." + "archiveConfirmBody": "Account “{{name}}” will leave working lists but remain in history.", + "adjust": "Adjust", + "adjustTitle": "Adjust balance", + "adjustCurrentBalance": "Current balance", + "adjustTargetBalance": "New balance", + "adjustDate": "Adjustment date", + "adjustReason": "Reason", + "adjustReasonHint": "Required; kept in the audit journal", + "adjustNotice": "Adjusting changes the account balance directly: it is not income or expense. It never enters income, expenses, cash flow or the budget pool." }, "operations": { "title": "Ledger", @@ -162,6 +188,7 @@ "kind.income": "Income", "kind.transfer": "Transfer", "kind.refund": "Refund", + "kind.adjustment": "Adjustment", "status.confirmed": "Confirmed", "status.voided": "Voided", "noCategory": "Uncategorized", @@ -183,6 +210,12 @@ "voidBody": "Operation of {{date}} for {{amount}} will be marked voided.", "voidReason": "Void reason", "voidReasonHint": "At least 3 characters", + "convert": "To savings", + "convertTitle": "Convert to savings", + "convertBody": "The expense of {{date}} for {{amount}} becomes a transfer to Savings: it leaves the month's expenses, grows the Savings account and reduces the common budget pool. You can revert this later.", + "revert": "Back to expense", + "revertTitle": "Revert to expense", + "revertBody": "The transfer of {{date}} for {{amount}} becomes a regular expense in the original category again; the common budget pool recalculates.", "prevPage": "Back", "nextPage": "Next" }, @@ -208,9 +241,27 @@ "categories": "Categories", "reserves": "Credit reserves", "noCategories": "No categories this month yet. Copy the previous month or create a plan.", + "manageCategories": "Manage categories", + "savingsEnvelopeHint": "The Savings envelope is fed by transfers and account adjustments; it has no plan.", "category.kind.monthly": "Monthly", "category.kind.savings_envelope": "Envelope", "category.kind.credit_reserve": "Credit reserve", + "manager": { + "title": "Manage categories", + "tabsLabel": "Category lists", + "tabActive": "Active", + "tabArchive": "Archive", + "systemBadge": "System", + "systemProtected": "renaming and archiving are unavailable", + "rename": "Rename", + "archive": "Archive", + "restore": "Restore", + "renameTitle": "Rename category", + "archiveTitle": "Archive category", + "archiveBody": "Category “{{name}}” will leave new plans and categorization, history remains.", + "emptyActive": "No user categories yet — create the first one in the form above.", + "emptyArchive": "The category archive is empty." + }, "planTitle": "Plan: {{name}}", "fundTitle": "Fund: {{name}}", "fund": "Fund", @@ -223,13 +274,25 @@ "income.status.partially_matched": "Partial", "income.status.matched": "Received", "blockersTitle": "Close is blocked", - "blockers.uncategorized_expenses": "There are expenses without a category.", - "blockers.negative_envelope": "A savings envelope balance is negative.", - "blockers.unresolved_imports": "There are unfinished imports.", - "blockers.open_decisions": "There are unresolved import decisions.", + "blockers": { + "IMPORTS_UNRESOLVED": "Statement imports are unfinished: {{count}}", + "IMPORT_UNRESOLVED_HOLDS": "Unconfirmed operations without a resolution: {{count}}", + "BUDGET_UNCATEGORIZED_EXPENSES": "Expenses without a category: {{count}}", + "BUDGET_NEGATIVE_ENVELOPE": "Negative envelope balance: {{categories}}", + "unknown": "Close is blocked by an unresolved reason.", + "cta": { + "imports": "Open review queue", + "holds": "Resolve operations", + "uncategorized": "Assign categories", + "envelope": "Open categories" + } + }, + "warnings.plan_imbalance": "The plan exceeds the projected pool.", "warnings.plan_exceeds_pool": "The plan exceeds the projected pool.", + "warnings.underfunded_credit_debt": "Credit card debt is not fully funded.", "warnings.underfunded_credit": "Credit card debt is not fully funded.", "warnings.expected_income_variance": "Expected income deviates from actual.", + "warnings.unknown": "The month has another issue that needs review.", "close": "Close month", "closeConfirmTitle": "Close month", "closeConfirmBody": "{{month}} will be closed and monthly category leftovers will roll into the general pool.", @@ -256,7 +319,13 @@ "uploading": "Uploading…", "adapterHint": "Supported: Alfa debit/credit XLSX, Ozon debit/credit PDF, Rocketbank debit PDF.", "file": "Statement file", + "fileName": "Statement file", "targetAccount": "Target account", + "adapters.alfa_debit_xlsx": "Alfa debit", + "adapters.alfa_credit_xlsx": "Alfa credit", + "adapters.ozon_debit_pdf": "Ozon debit", + "adapters.ozon_credit_pdf": "Ozon credit", + "adapters.rocketbank_debit_pdf": "Rocketbank debit", "unassigned": "Unassigned", "empty": "No imports yet.", "bank": "Bank / contract", @@ -289,6 +358,19 @@ "blockers.unresolved_duplicate": "There are unresolved duplicates.", "blockers.unresolved_transfer": "There are unresolved transfers.", "blockers.account_conflict": "Account fingerprint conflicts with an existing one.", + "unresolvedHolds": "On hold", + "unresolvedHoldsBadge": "On-hold operations: {{count}}", + "holdBadge": "Hold", + "holdPosted": "Operation posted", + "holdPostedTitle": "Operation posted", + "holdPostedBody": "The held operation of {{date}} for {{amount}} will be posted to the statement account as a regular operation.", + "holdCancelled": "Operation cancelled", + "holdCancelledTitle": "Operation cancelled", + "holdCancelledBody": "The held operation of {{date}} for {{amount}} will be marked cancelled and never enters the ledger.", + "holdReason": "Resolution reason", + "holdReasonHint": "Required; kept in the audit journal", + "holdResolution.confirmed_as_posted": "Posted", + "holdResolution.discarded_as_cancelled": "Cancelled", "warnings": "Warnings ({{count}})", "warnings.amount_warning": "Unusual amount.", "warnings.date_warning": "Unusual date.", @@ -303,7 +385,7 @@ "reconciliation.mismatched": "Mismatched", "reconciliation.not_available": "Not available", "reconciliation.confirmed_without": "Confirmed without reconciliation", - "totals": "Rows: {{total}} · create: {{create}} · discard: {{discard}} · duplicates: {{duplicate}} · transfers: {{transfer}}", + "totals": "Rows: {{total}} · create: {{create}} · discard: {{discard}} · duplicates: {{duplicate}} · transfers: {{transfer}} · savings: {{savings}} · pending: {{pending}}", "rowsTitle": "Statement rows", "noRows": "No rows.", "rowWarnings": "Notes", @@ -318,6 +400,7 @@ "decision.discard": "Discard", "decision.duplicate": "Duplicate", "decision.transfer": "Transfer", + "decision.savings_transfer": "Savings transfer", "confirm": "Apply import", "confirmTitle": "Apply import", "confirmBody": "Operations will be created from the selected rows. Applying is atomic and can be reverted.", @@ -372,9 +455,16 @@ "toast": { "accountCreated": "Account “{{name}}” created", "accountArchived": "Account archived", + "balanceAdjusted": "Account balance adjusted", "operationCreated": "Operation created", "operationVoided": "Operation voided", "categoryAssigned": "Category assigned", + "convertedToSavings": "Expense converted to savings", + "savingsConversionReverted": "Transfer reverted to expense", + "categoryCreated": "Category “{{name}}” created", + "categoryRenamed": "Category renamed to “{{name}}”", + "categoryArchived": "Category archived", + "categoryRestored": "Category restored", "planUpdated": "Plan updated", "categoryFunded": "Category funded", "monthClosed": "Month closed", @@ -383,6 +473,7 @@ "importUploaded": "Statement uploaded, parsing started", "importConfirmed": "Import applied", "importReverted": "Import reverted", + "holdResolved": "Hold resolution saved", "reparseQueued": "Reparse queued", "exportQueued": "Export queued", "exportReady": "Export is ready to download", @@ -394,7 +485,11 @@ "subtitle": "This page does not exist. Check the address or go back to accounts.", "back": "To accounts" }, + "account": { + "savings": "Savings" + }, "categories": { + "uncategorized": "Uncategorized", "groceries": "Groceries", "transport": "Transport", "housing": "Housing", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index db2605f..1f104be 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -88,7 +88,23 @@ "validation_error": "Проверьте правильность заполнения полей.", "conflict": "Данные изменены кем-то другим. Обновите страницу и повторите.", "rate_limited": "Слишком много попыток. Подождите немного.", - "session_expired": "Сессия истекла. Войдите снова." + "session_expired": "Сессия истекла. Войдите снова.", + "LEDGER_ADJUST_NOOP": "Остаток уже равен целевому значению — менять нечего.", + "LEDGER_ADJUST_TARGET_NEGATIVE": "Целевой остаток не может быть отрицательным.", + "LEDGER_ADJUST_REASON_REQUIRED": "Укажите причину корректировки.", + "LEDGER_ACCOUNT_ARCHIVED": "Счёт в архиве — сначала восстановите его.", + "LEDGER_SAVINGS_CONVERT_INVALID": "Преобразовать в накопления можно только подтверждённый расход в открытом месяце.", + "LEDGER_SAVINGS_REVERT_INVALID": "Вернуть в расход можно только преобразованный ранее перевод.", + "LEDGER_SAVINGS_CONFLICT": "Обнаружен конфликт системных ресурсов накоплений.", + "BUDGET_PERIOD_CLOSED": "Месяц закрыт. Сначала откройте его заново.", + "BUDGET_CATEGORY_SYSTEM": "Системную категорию нельзя переименовать или архивировать.", + "BUDGET_CATEGORY_ARCHIVED": "Категория в архиве — сначала восстановите её.", + "BUDGET_CATEGORY_NAME_REQUIRED": "Укажите название категории.", + "BUDGET_CATEGORY_KIND_INVALID": "Неизвестный тип категории.", + "IMPORT_HOLD_REASON_REQUIRED": "Укажите причину решения по замороженной операции.", + "IMPORT_HOLD_ALREADY_RESOLVED": "По этой операции решение уже сохранено.", + "IMPORT_HOLD_DOCUMENT_NOT_CONFIRMED": "Решать замороженные операции можно только после применения импорта.", + "IMPORT_HOLD_ACCOUNT_REQUIRED": "Не указан счёт привязки выписки." }, "auth": { "loginTitle": "Вход", @@ -120,7 +136,7 @@ }, "accounts": { "title": "Счета", - "subtitle": "Дебетовые, наличные и кредитные счета пространства.", + "subtitle": "Дебетовые, наличные, кредитные и системный счёт накоплений.", "add": "Добавить счёт", "empty": "Счетов пока нет. Создайте первый счёт, чтобы начать учёт.", "formTitle": "Новый счёт", @@ -128,6 +144,8 @@ "type.cash": "Наличные", "type.debit": "Дебетовый", "type.credit": "Кредитная карта", + "type.savings": "Накопительный", + "systemBadge": "Системный", "bank": "Банк", "bankHint": "Необязательно, например «alfa»", "maskedNumber": "Маскированный номер", @@ -143,7 +161,15 @@ "archive": "В архив", "archiveBlockedHint": "Архивировать можно только счёт с нулевым остатком", "archiveConfirmTitle": "Архивировать счёт", - "archiveConfirmBody": "Счёт «{{name}}» скроется из рабочих списков, но останется в истории." + "archiveConfirmBody": "Счёт «{{name}}» скроется из рабочих списков, но останется в истории.", + "adjust": "Скорректировать", + "adjustTitle": "Корректировка остатка", + "adjustCurrentBalance": "Текущий остаток", + "adjustTargetBalance": "Новый остаток", + "adjustDate": "Дата корректировки", + "adjustReason": "Причина", + "adjustReasonHint": "Обязательно; попадёт в журнал аудита", + "adjustNotice": "Корректировка меняет остаток счёта напрямую: это не доход и не расход. Она не попадёт в доходы, расходы, денежный поток и общий пул бюджета." }, "operations": { "title": "Гроссбух", @@ -162,6 +188,7 @@ "kind.income": "Доход", "kind.transfer": "Перевод", "kind.refund": "Возврат", + "kind.adjustment": "Корректировка", "status.confirmed": "Подтверждена", "status.voided": "Отменена", "noCategory": "Без категории", @@ -183,6 +210,12 @@ "voidBody": "Операция от {{date}} на {{amount}} будет помечена отменённой.", "voidReason": "Причина отмены", "voidReasonHint": "Минимум 3 символа", + "convert": "В накопления", + "convertTitle": "Перевести в накопления", + "convertBody": "Расход от {{date}} на {{amount}} станет переводом в «Накопления»: он уйдёт из расходов месяца, увеличит счёт «Накопления» и уменьшит общий бюджетный пул. Позже это можно вернуть.", + "revert": "Вернуть в расходы", + "revertTitle": "Вернуть в расходы", + "revertBody": "Перевод от {{date}} на {{amount}} снова станет обычным расходом исходной категории; общий бюджетный пул пересчитается.", "prevPage": "Назад", "nextPage": "Дальше" }, @@ -208,9 +241,27 @@ "categories": "Категории", "reserves": "Резервы кредиток", "noCategories": "В этом месяце ещё нет категорий. Скопируйте прошлый месяц или создайте план.", + "manageCategories": "Управлять категориями", + "savingsEnvelopeHint": "Конверт «Накопления» наполняется переводами и корректировками счёта; план не задаётся.", "category.kind.monthly": "Месячная", "category.kind.savings_envelope": "Конверт", "category.kind.credit_reserve": "Резерв кредитки", + "manager": { + "title": "Управление категориями", + "tabsLabel": "Списки категорий", + "tabActive": "Активные", + "tabArchive": "Архив", + "systemBadge": "Системная", + "systemProtected": "переименование и архивирование недоступны", + "rename": "Переименовать", + "archive": "В архив", + "restore": "Восстановить", + "renameTitle": "Переименовать категорию", + "archiveTitle": "Архивировать категорию", + "archiveBody": "Категория «{{name}}» скроется из новых планов и разметки, но история останется.", + "emptyActive": "Пользовательских категорий пока нет — создайте первую в форме выше.", + "emptyArchive": "Архив категорий пуст." + }, "planTitle": "План: {{name}}", "fundTitle": "Обеспечение: {{name}}", "fund": "Обеспечить", @@ -223,13 +274,25 @@ "income.status.partially_matched": "Частично", "income.status.matched": "Поступил", "blockersTitle": "Закрытие заблокировано", - "blockers.uncategorized_expenses": "Есть расходы без категории.", - "blockers.negative_envelope": "Отрицательный баланс накопительного конверта.", - "blockers.unresolved_imports": "Есть незавершённые импорты.", - "blockers.open_decisions": "Есть неразобранные решения по импорту.", + "blockers": { + "IMPORTS_UNRESOLVED": "Не завершены импорты выписок: {{count}}", + "IMPORT_UNRESOLVED_HOLDS": "Неподтверждённые операции без решения: {{count}}", + "BUDGET_UNCATEGORIZED_EXPENSES": "Расходы без категории: {{count}}", + "BUDGET_NEGATIVE_ENVELOPE": "Отрицательный остаток конверта: {{categories}}", + "unknown": "Закрытие заблокировано необработанной причиной.", + "cta": { + "imports": "Открыть очередь разбора", + "holds": "Разобрать операции", + "uncategorized": "Назначить категории", + "envelope": "Открыть категории" + } + }, + "warnings.plan_imbalance": "План превышает прогнозный пул.", "warnings.plan_exceeds_pool": "План превышает прогнозный пул.", + "warnings.underfunded_credit_debt": "Долг по кредитке обеспечен не полностью.", "warnings.underfunded_credit": "Долг по кредитке обеспечен не полностью.", "warnings.expected_income_variance": "Ожидаемый доход расходится с фактическим.", + "warnings.unknown": "В месяце есть другая проблема, требующая проверки.", "close": "Закрыть месяц", "closeConfirmTitle": "Закрыть месяц", "closeConfirmBody": "Месяц {{month}} будет закрыт, остатки месячных категорий свернутся в общий пул.", @@ -256,7 +319,13 @@ "uploading": "Загрузка…", "adapterHint": "Поддерживаются: Альфа debit/credit XLSX, Ozon debit/credit PDF, Рокетбанк debit PDF.", "file": "Файл выписки", + "fileName": "Файл выписки", "targetAccount": "Счёт привязки", + "adapters.alfa_debit_xlsx": "Альфа debit", + "adapters.alfa_credit_xlsx": "Альфа credit", + "adapters.ozon_debit_pdf": "Ozon debit", + "adapters.ozon_credit_pdf": "Ozon credit", + "adapters.rocketbank_debit_pdf": "Рокетбанк debit", "unassigned": "Не привязан", "empty": "Импортов пока нет.", "bank": "Банк / контракт", @@ -289,6 +358,19 @@ "blockers.unresolved_duplicate": "Есть неразобранные дубликаты.", "blockers.unresolved_transfer": "Есть неразобранные переводы.", "blockers.account_conflict": "Отпечаток счёта конфликтует с существующим.", + "unresolvedHolds": "Замороженные", + "unresolvedHoldsBadge": "Замороженных операций: {{count}}", + "holdBadge": "Заморожена", + "holdPosted": "Операция списалась", + "holdPostedTitle": "Операция списалась", + "holdPostedBody": "Замороженная операция от {{date}} на {{amount}} будет проведена по счёту выписки как обычная операция.", + "holdCancelled": "Операция отменена", + "holdCancelledTitle": "Операция отменена", + "holdCancelledBody": "Замороженная операция от {{date}} на {{amount}} будет помечена отменённой и не попадёт в операции.", + "holdReason": "Причина решения", + "holdReasonHint": "Обязательно; попадёт в журнал аудита", + "holdResolution.confirmed_as_posted": "Проведена", + "holdResolution.discarded_as_cancelled": "Отменена", "warnings": "Предупреждения ({{count}})", "warnings.amount_warning": "Нестандартная сумма.", "warnings.date_warning": "Необычная дата.", @@ -303,7 +385,7 @@ "reconciliation.mismatched": "Расхождение", "reconciliation.not_available": "Данных для сверки нет", "reconciliation.confirmed_without": "Подтверждено без сверки", - "totals": "Строк: {{total}} · создать: {{create}} · пропустить: {{discard}} · дубликатов: {{duplicate}} · переводов: {{transfer}}", + "totals": "Строк: {{total}} · создать: {{create}} · пропустить: {{discard}} · дубликатов: {{duplicate}} · переводов: {{transfer}} · в накопления: {{savings}} · без решения: {{pending}}", "rowsTitle": "Строки выписки", "noRows": "Строк нет.", "rowWarnings": "Замечания", @@ -318,6 +400,7 @@ "decision.discard": "Пропустить", "decision.duplicate": "Это дубликат", "decision.transfer": "Это перевод", + "decision.savings_transfer": "Перевод в накопления", "confirm": "Применить импорт", "confirmTitle": "Применить импорт", "confirmBody": "Будут созданы операции по выбранным строкам. Применение атомарно, его можно отменить.", @@ -374,9 +457,16 @@ "toast": { "accountCreated": "Счёт «{{name}}» создан", "accountArchived": "Счёт отправлен в архив", + "balanceAdjusted": "Остаток счёта скорректирован", "operationCreated": "Операция создана", "operationVoided": "Операция отменена", "categoryAssigned": "Категория назначена", + "convertedToSavings": "Расход переведён в накопления", + "savingsConversionReverted": "Перевод возвращён в расходы", + "categoryCreated": "Категория «{{name}}» создана", + "categoryRenamed": "Категория переименована в «{{name}}»", + "categoryArchived": "Категория отправлена в архив", + "categoryRestored": "Категория восстановлена", "planUpdated": "План обновлён", "categoryFunded": "Категория обеспечена", "monthClosed": "Месяц закрыт", @@ -385,6 +475,7 @@ "importUploaded": "Выписка загружена, разбор запущен", "importConfirmed": "Импорт применён", "importReverted": "Импорт отменён", + "holdResolved": "Решение по замороженной операции сохранено", "reparseQueued": "Переразбор запущен", "exportQueued": "Экспорт поставлен в очередь", "exportReady": "Экспорт готов к скачиванию", @@ -396,7 +487,11 @@ "subtitle": "Такой страницы нет. Проверьте адрес или вернитесь к счетам.", "back": "К счетам" }, + "account": { + "savings": "Накопления" + }, "categories": { + "uncategorized": "Без категории", "groceries": "Продукты", "transport": "Транспорт", "housing": "Жильё", diff --git a/frontend/src/lib/accountLabel.ts b/frontend/src/lib/accountLabel.ts new file mode 100644 index 0000000..d3f839b --- /dev/null +++ b/frontend/src/lib/accountLabel.ts @@ -0,0 +1,9 @@ +import type { TFunction } from 'i18next' +import type { Account } from '@/api/types' + +export function accountLabel(account: Pick, t: TFunction): string { + if (account.nameKey) { + return t(account.nameKey, { defaultValue: account.name }) + } + return account.name +} diff --git a/frontend/src/lib/apiErrors.ts b/frontend/src/lib/apiErrors.ts new file mode 100644 index 0000000..931f2b1 --- /dev/null +++ b/frontend/src/lib/apiErrors.ts @@ -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 } diff --git a/frontend/src/lib/categoryLabel.ts b/frontend/src/lib/categoryLabel.ts new file mode 100644 index 0000000..98ba074 --- /dev/null +++ b/frontend/src/lib/categoryLabel.ts @@ -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') +} diff --git a/frontend/src/lib/dates.ts b/frontend/src/lib/dates.ts new file mode 100644 index 0000000..fd865b7 --- /dev/null +++ b/frontend/src/lib/dates.ts @@ -0,0 +1,101 @@ +import type { Locale } from '@/api/types' + +const INTL_LOCALES: Record = { + 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 } +} diff --git a/frontend/src/lib/importStatus.ts b/frontend/src/lib/importStatus.ts new file mode 100644 index 0000000..ee4df0b --- /dev/null +++ b/frontend/src/lib/importStatus.ts @@ -0,0 +1,21 @@ +import type { ImportStatus } from '@/api/types' + +export const IMPORT_POLL_MS = 2000 + +const ACTIVE_STATUSES = new Set(['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 +} diff --git a/frontend/src/lib/locale.ts b/frontend/src/lib/locale.ts new file mode 100644 index 0000000..cecd426 --- /dev/null +++ b/frontend/src/lib/locale.ts @@ -0,0 +1,5 @@ +import type { Locale } from '@/api/types' + +export function currentLocale(language: string | undefined): Locale { + return language?.toLowerCase().startsWith('ru') ? 'ru' : 'en' +} diff --git a/frontend/src/lib/money.ts b/frontend/src/lib/money.ts new file mode 100644 index 0000000..7f0fac7 --- /dev/null +++ b/frontend/src/lib/money.ts @@ -0,0 +1,46 @@ +import type { Locale } from '@/api/types' + +const INTL_LOCALES: Record = { + 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 { + let total = 0 + for (const value of values) total += value + return total +} diff --git a/frontend/src/lib/operationFormat.ts b/frontend/src/lib/operationFormat.ts new file mode 100644 index 0000000..2b97899 --- /dev/null +++ b/frontend/src/lib/operationFormat.ts @@ -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 diff --git a/frontend/src/pages/AccountsPage.tsx b/frontend/src/pages/AccountsPage.tsx index 77f04d1..7437b11 100644 --- a/frontend/src/pages/AccountsPage.tsx +++ b/frontend/src/pages/AccountsPage.tsx @@ -3,7 +3,8 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { z } from 'zod' -import type { Account, AccountType } from '@/api/types' +import type { Account } from '@/api/types' +import { isSystemAccount } from '@/api/types' import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/Button' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' @@ -12,14 +13,20 @@ import { MoneyField, SelectField, TextField } from '@/components/ui/fields' import { Modal } from '@/components/ui/Modal' import { PageHeader } from '@/components/ui/PageHeader' import { QueryGate } from '@/components/ui/QueryGate' -import { useAccounts, useArchiveAccount, useCreateAccount } from '@/hooks/useAccounts' -import { formatDate } from '@/lib/dates' -import { formatMoney, parseAmountToKopecks, sumKopecks } from '@/lib/money' +import { + useAccounts, + useAdjustBalance, + useArchiveAccount, + useCreateAccount, +} from '@/hooks/useAccounts' +import { accountLabel } from '@/lib/accountLabel' +import { formatDate, todayISO } from '@/lib/dates' +import { formatMoney, kopecksToInput, parseAmountToKopecks, sumKopecks } from '@/lib/money' import { currentLocale } from '@/lib/locale' interface AccountFormValues { name: string - type: AccountType + type: 'cash' | 'debit' | 'credit' openedOn: string openingBalance: string bankCode: string @@ -113,6 +120,84 @@ function useMemoSchema() { }) } +function AdjustBalanceModal({ + account, + onClose, +}: { + account: Account + onClose: () => void +}) { + const { t, i18n } = useTranslation() + const locale = currentLocale(i18n.language) + const adjust = useAdjustBalance() + const schema = z.object({ + targetBalance: z + .string() + .refine((value) => (parseAmountToKopecks(value) ?? -1) >= 0, t('errors.badAmount')), + occurredOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, t('common.requiredField')), + reason: z.string().trim().min(1, t('common.requiredField')), + }) + type Values = z.infer + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + 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 ( + +
+

{t('accounts.adjustNotice')}

+
+ {t('accounts.adjustCurrentBalance')} + {formatMoney(account.balanceKopecks, locale)} +
+ + + + + +
+ ) +} + export function AccountsPage() { const { t, i18n } = useTranslation() const locale = currentLocale(i18n.language) @@ -126,6 +211,7 @@ export function AccountsPage() { const liabilities = sumKopecks( active.filter((a) => a.type === 'credit').map((a) => a.balanceKopecks), ) + const [adjustTarget, setAdjustTarget] = useState(null) return ( <> @@ -174,52 +260,67 @@ export function AccountsPage() { - {list.map((account) => ( - - - {account.name} - {account.maskedNumber ? ( - · {account.maskedNumber} - ) : null} - - - - {t(`accounts.type.${account.type}`)} - - - {account.bankCode ?? '—'} - - {formatDate(account.openedOn, locale)} - - - - {t(`accounts.status.${account.status}`)} - - - - {formatMoney(account.balanceKopecks, locale)} - - - {account.status === 'active' ? ( - - ) : null} - - - ))} + {list.map((account) => { + const system = isSystemAccount(account) + return ( + + + {accountLabel(account, t)} + {system ? {t('accounts.systemBadge')} : null} + {account.maskedNumber ? ( + · {account.maskedNumber} + ) : null} + + + + {t(`accounts.type.${account.type}`)} + + + {account.bankCode ?? '—'} + + {formatDate(account.openedOn, locale)} + + + + {t(`accounts.status.${account.status}`)} + + + + {formatMoney(account.balanceKopecks, locale)} + + + + {account.status === 'active' ? ( + <> + {account.type !== 'credit' ? ( + + ) : null} + {!system ? ( + + ) : null} + + ) : null} + + + + ) + })} )} @@ -228,6 +329,9 @@ export function AccountsPage() { setCreateOpen(false)} title={t('accounts.formTitle')}> setCreateOpen(false)} /> + {adjustTarget ? ( + setAdjustTarget(null)} /> + ) : null} (null) const [closeConfirmOpen, setCloseConfirmOpen] = useState(false) const [reopenConfirmOpen, setReopenConfirmOpen] = useState(false) + const [managerOpen, setManagerOpen] = useState(false) const status = summary.data?.status const allCategories = categories.data ?? [] @@ -141,37 +146,62 @@ export function BudgetPage() { (category) => category.kind !== 'credit_reserve' && !category.archived, ) const reserves = allCategories.filter((category) => category.kind === 'credit_reserve') + const supplementalWarnings = + summary.data?.warnings.filter((warning) => !BLOCKER_WARNING_CODES.has(warning.code)) ?? [] const renderCategoryRows = (rows: BudgetCategory[]) => - rows.map((category) => ( - - - {categoryLabel(category, t)} - - {t(`budget.category.kind.${category.kind}`)} - - - - - - - - - - {formatMoney(category.spentKopecks, locale)} - - - {formatMoney(category.availableKopecks, locale)} - - - )) + rows.map((category) => { + const isSystemCategory = category.system + const systemHint = + category.nameKey === 'savings' + ? t('budget.savingsEnvelopeHint') + : t('budget.manager.systemNote') + return ( + + + {categoryLabel(category, t)} + {isSystemCategory ? ( + {t('budget.manager.systemBadge')} + ) : ( + + {t(`budget.category.kind.${category.kind}`)} + + )} + + + {isSystemCategory ? ( + + {formatMoney(category.plannedKopecks, locale)} + + ) : ( + + )} + + + {isSystemCategory ? ( + + {formatMoney(category.fundedKopecks, locale)} + + ) : ( + + )} + + + {formatMoney(category.spentKopecks, locale)} + + + {formatMoney(category.availableKopecks, locale)} + + + ) + }) return ( <> @@ -217,21 +247,17 @@ export function BudgetPage() { {summary.data && summary.data.blockers.length > 0 ? (
{t('budget.blockersTitle')} -
    - {summary.data.blockers.map((blocker) => ( -
  • - {t(`budget.blockers.${blocker.code}`, { defaultValue: blocker.message ?? blocker.code })} -
  • - ))} -
+
) : null} - {summary.data && summary.data.warnings.length > 0 ? ( + {supplementalWarnings.length > 0 ? (
    - {summary.data.warnings.map((warning) => ( + {supplementalWarnings.map((warning) => (
  • - {t(`budget.warnings.${warning.code}`, { defaultValue: warning.message ?? warning.code })} + {t(`budget.warnings.${warning.code}`, { + defaultValue: t('budget.warnings.unknown'), + })}
  • ))}
@@ -308,8 +334,13 @@ export function BudgetPage() { ) : null} -
-

{t('budget.categories')}

+
+
+

{t('budget.categories')}

+ +
{userCategories.length === 0 ? (

{t('budget.noCategories')}

) : ( @@ -355,6 +386,7 @@ export function BudgetPage() { {fundTarget ? ( setFundTarget(null)} /> ) : null} + {managerOpen ? setManagerOpen(false)} /> : null} [] = [ 'discard', 'duplicate', 'transfer', + 'savings_transfer', ] const STEP_ORDER = ['uploaded', 'queued', 'processing', 'review_required', 'confirmed'] as const @@ -37,6 +42,76 @@ function stepIndexFor(status: string): number { return index === -1 ? 0 : index } +function HoldResolveModal({ + importId, + row, + action, + onClose, +}: { + importId: string + row: ImportRow + action: HoldResolutionAction + onClose: () => void +}) { + const { t, i18n } = useTranslation() + const locale = currentLocale(i18n.language) + const resolve = useResolveHold(importId) + const schema = z.object({ + reason: z.string().trim().min(1, t('common.requiredField')), + }) + type Values = z.infer + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { reason: '' }, + }) + + const submit = handleSubmit((values) => { + resolve.mutate( + { rowId: row.id, payload: { action, reason: values.reason } }, + { onSuccess: onClose }, + ) + }) + + return ( + +
+

+ {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), + })} +

+ + + +
+ ) +} + function ConfirmModal({ importId, needsConfirmation, @@ -101,6 +176,7 @@ export function ImportDetailPage() { const [confirmOpen, setConfirmOpen] = useState(false) const [revertOpen, setRevertOpen] = useState(false) + const [holdTarget, setHoldTarget] = useState<{ row: ImportRow; action: HoldResolutionAction } | null>(null) const status = detail.data?.status const needsConfirmation = @@ -134,6 +210,9 @@ export function ImportDetailPage() { } /> {status ? {t(`imports.status.${status}`)} : null} + {detail.data && detail.data.unresolvedHoldCount > 0 ? ( + {t('imports.unresolvedHoldsBadge', { count: detail.data.unresolvedHoldCount })} + ) : null} {(data) => ( <> @@ -225,6 +304,8 @@ export function ImportDetailPage() { discard: data.totals.discard, duplicate: data.totals.duplicate, transfer: data.totals.transfer, + savings: data.totals.savingsTransfer, + pending: data.totals.pending, })}

@@ -262,7 +343,9 @@ export function ImportDetailPage() { {formatMoney(row.amountKopecks, locale)} - {row.warnings.length === 0 ? ( + {row.bankStatus === 'hold' ? ( + {t('imports.holdBadge')} + ) : row.warnings.length === 0 ? ( '—' ) : (
    @@ -275,26 +358,71 @@ export function ImportDetailPage() { )} - + {row.bankStatus === 'hold' ? ( + row.holdResolution ? ( +
    + + {t( + `imports.holdResolution.${row.holdResolution.resolution}`, + )} + + {row.holdResolution.reason ? ( + {row.holdResolution.reason} + ) : null} + {row.holdResolution.resolvedAt ? ( + + {formatDateTime(row.holdResolution.resolvedAt, locale)} + + ) : null} +
    + ) : ( +
    + + +
    + ) + ) : ( + + )} ))} @@ -302,6 +430,14 @@ export function ImportDetailPage() { )}
+ {holdTarget ? ( + setHoldTarget(null)} + /> + ) : null} {confirmOpen ? ( void }) { const { t } = useTranslation() - const accounts = useActiveAccounts() + const accounts = useUserAccounts() const upload = useUploadImport() const [accountId, setAccountId] = useState('') const [file, setFile] = useState(null) @@ -62,7 +63,7 @@ function UploadModal({ onClose }: { onClose: () => void }) { {(accounts.data ?? []).map((account) => ( ))} diff --git a/frontend/src/pages/OperationsPage.tsx b/frontend/src/pages/OperationsPage.tsx index 421f3fb..4e7b613 100644 --- a/frontend/src/pages/OperationsPage.tsx +++ b/frontend/src/pages/OperationsPage.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next' import type { TFunction } from 'i18next' import { z } from 'zod' import type { Account, Operation, OperationFilters, OperationKind, OperationStatus } from '@/api/types' +import { isSystemAccount } from '@/api/types' import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/Button' import { EmptyState } from '@/components/ui/EmptyState' @@ -13,12 +14,15 @@ import { Modal } from '@/components/ui/Modal' import { PageHeader } from '@/components/ui/PageHeader' import { QueryGate } from '@/components/ui/QueryGate' import { useBudgetCategories } from '@/hooks/useBudget' -import { useActiveAccounts } from '@/hooks/useAccounts' +import { useActiveAccounts, useUserAccounts } from '@/hooks/useAccounts' import { + useConvertToSavings, useCreateOperation, useOperations, + useRevertSavingsConversion, useVoidOperation, } from '@/hooks/useOperations' +import { accountLabel } from '@/lib/accountLabel' import { categoryLabel } from '@/lib/categoryLabel' import { currentMonth, formatDate, todayISO } from '@/lib/dates' import { currentLocale } from '@/lib/locale' @@ -50,7 +54,7 @@ function AccountSelect({ {(accounts ?? []).map((account) => ( ))} @@ -60,7 +64,7 @@ function AccountSelect({ function ExpenseForm({ onDone }: { onDone: () => void }) { const { t } = useTranslation() const create = useCreateOperation() - const accounts = useActiveAccounts() + const accounts = useUserAccounts() const categories = useBudgetCategories(currentMonth()) const schema = z.object({ accountId: z.string().min(1, t('common.requiredField')), @@ -143,7 +147,7 @@ function ExpenseForm({ onDone }: { onDone: () => void }) { function IncomeForm({ onDone }: { onDone: () => void }) { const { t } = useTranslation() const create = useCreateOperation() - const accounts = useActiveAccounts() + const accounts = useUserAccounts() const schema = z.object({ accountId: z.string().min(1, t('common.requiredField')), amount: amountField(t), @@ -297,7 +301,7 @@ function RefundForm({ onDone }: { onDone: () => void }) { const { t, i18n } = useTranslation() const locale = currentLocale(i18n.language) const create = useCreateOperation() - const accounts = useActiveAccounts() + const accounts = useUserAccounts() const recent = useOperations({ kind: 'expense', limit: 20 }) const schema = z.object({ accountId: z.string().min(1, t('common.requiredField')), @@ -447,6 +451,76 @@ function VoidModal({ operation, onClose }: { operation: Operation; onClose: () = const PAGE_SIZE = 50 +function SavingsConvertModal({ + operation, + onClose, +}: { + operation: Operation + onClose: () => void +}) { + const { t, i18n } = useTranslation() + const locale = currentLocale(i18n.language) + const convert = useConvertToSavings() + return ( + +
+

+ {t('operations.convertBody', { + date: formatDate(operation.occurredOn, locale), + amount: formatMoney(operation.amountKopecks, locale), + })} +

+
+ + +
+
+
+ ) +} + +function SavingsRevertModal({ + operation, + onClose, +}: { + operation: Operation + onClose: () => void +}) { + const { t, i18n } = useTranslation() + const locale = currentLocale(i18n.language) + const revert = useRevertSavingsConversion() + return ( + +
+

+ {t('operations.revertBody', { + date: formatDate(operation.occurredOn, locale), + amount: formatMoney(operation.amountKopecks, locale), + })} +

+
+ + +
+
+
+ ) +} + export function OperationsPage() { const { t, i18n } = useTranslation() const locale = currentLocale(i18n.language) @@ -481,9 +555,13 @@ export function OperationsPage() { const operations = useOperations(filters) const [createOpen, setCreateOpen] = useState(false) const [voidTarget, setVoidTarget] = useState(null) + const [convertTarget, setConvertTarget] = useState(null) + const [revertTarget, setRevertTarget] = useState(null) - const accountName = (id: string | null) => - accounts.data?.find((account) => account.id === id)?.name ?? '—' + const accountName = (id: string | null) => { + const account = accounts.data?.find((item) => item.id === id) + return account ? accountLabel(account, t) : '—' + } const goNext = () => { const next = operations.data?.nextCursor @@ -536,7 +614,7 @@ export function OperationsPage() { {(accounts.data ?? []).map((account) => ( ))} @@ -618,6 +696,22 @@ export function OperationsPage() { {page.items.map((operation) => { const amount = operationAmount(operation, locale) + const isConvertedTransfer = + operation.kind === 'transfer' && operation.convertedFromExpense + const sourceAccount = + accounts.data?.find((item) => item.id === operation.accountId) ?? null + const canConvert = + operation.kind === 'expense' && + operation.status === 'confirmed' && + !(sourceAccount && isSystemAccount(sourceAccount)) + const categoryText = + operation.kind === 'adjustment' + ? '—' + : operation.categoryKey + ? t(`categories.${operation.categoryKey}`, { + defaultValue: operation.categoryName ?? operation.categoryKey, + }) + : operation.categoryName ?? t('operations.noCategory') return ( - {operation.categoryName ?? t('operations.noCategory')} + {categoryText} {amount.text} {operation.status === 'confirmed' ? ( - + + {canConvert ? ( + + ) : null} + {isConvertedTransfer ? ( + + ) : null} + + ) : null} @@ -676,6 +788,12 @@ export function OperationsPage() { {voidTarget ? ( setVoidTarget(null)} /> ) : null} + {convertTarget ? ( + setConvertTarget(null)} /> + ) : null} + {revertTarget ? ( + setRevertTarget(null)} /> + ) : null} ) } diff --git a/frontend/src/pages/OverviewPage.tsx b/frontend/src/pages/OverviewPage.tsx index 51f2b09..b837e7f 100644 --- a/frontend/src/pages/OverviewPage.tsx +++ b/frontend/src/pages/OverviewPage.tsx @@ -1,5 +1,6 @@ import { Link } from 'react-router-dom' import { useTranslation } from 'react-i18next' +import { BudgetBlockers } from '@/components/budget/BudgetBlockers' import { PageHeader } from '@/components/ui/PageHeader' import { QueryGate } from '@/components/ui/QueryGate' import { useBudgetCategories, useBudgetSummary } from '@/hooks/useBudget' @@ -61,7 +62,7 @@ export function OverviewPage() {