26 KiB
26 KiB
Ближайший край: накопления, категории и блокировки бюджета
Цель и порядок
Сначала ввести корректную доменную модель системных накоплений, затем использовать её в импорте Альфа-Банка и ручной разметке операций. После этого закрыть пользовательские разрывы в управлении категориями и разборе причин, мешающих закрыть месяц.
Порядок реализации обязателен:
- Системный счёт и конверт «Накопления», корректировки и преобразование расходов.
- Автоматическая классификация «Копилки для сдачи» в Альфа debit.
- Полное управление пользовательскими категориями на странице бюджета.
- Человекочитаемые и сразу устранимые блокировки закрытия.
Что обнаружено
- Альфа 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, nullableAccount.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, literalnameлибо чистыйnameKeyбез префиксаcategory.,kind,systemKey,linkedAccountId,archived. POST /budget/categoriesвозвращает полный definition DTO, trim-ит имя и создаёт толькоmonthly | savings_envelope.- Добавить
PATCH /budget/category-definitions/{id}с optionalnameи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. Понятные и устранимые блокировки
Контракт и представление
- Сделать
BudgetBlockerdiscriminated 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: при неполном совпадении остаётся обычный расход, который пользователь может вручную отправить в накопления.
- Архивированные категории недоступны новым операциям, но продолжают отображаться в истории и участвовать в уже созданных фактах.
Проверка
- Backend domain/property tests: две проводки savings-transfer суммируются в ноль; adjustment имеет одну signed posting; convert/revert восстанавливает тип, категорию, баланс и provenance; closed period и cross-space операции отклоняются.
- Migration tests/manual check: upgrade существующей БД создаёт ровно одну пару на пространство; fresh install не дублирует её; пользовательские одноимённые записи не захватываются.
- Budget tests: перевод в накопления уменьшает pool и увеличивает system envelope; обратный перевод делает обратное; adjustment не меняет pool/cash-flow; archived history не исчезает; blockers содержат корректные counts.
- Import tests: Alfa parser contract и parser version, confirm/revert, idempotency/dedup, HOLD и near-miss cases.
- API tests: CSRF для новых mutations, OpenAPI critical paths, system-resource protection, category archive/restore и HOLD resolution.
- Frontend Vitest: account adjustment modal, expense convert/revert, category manager CRUD/archive, blocker text/count/links для RU/EN, month-aware ReviewQueue и HOLD modal.
- Обновить
frontend/openapi.local.jsonиfrontend/src/api/generated/schema.d.ts, затем выполнитьmake backend-check,make frontend-checkи полныйmake check. - По обязательному дизайн-процессу сверить реализацию с выбранными слоями в
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. - После исходных изменений выполнить
graphify update ..
Вне этого края
- Несколько системных счетов накоплений, цели со сроком/суммой, иерархия или split категорий.
- Автоматические правила для банкоматов, P2P, криптовалют и произвольных описаний; для них остаётся ручная разметка расхода.
- Преобразование income/refund в перевод и универсальный редактор типа любой операции.
- Пересчёт или автоматическая мутация уже закрытой истории без явного reopen.