Bicycle/.claude/memory/бюджет_текущий_план.md
Egor Isaev cff643ebc5 dev
2026-08-12 17:08:46 +03:00

584 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
name: budget-current-plan
description: Актуальный бизнес-процесс фичи «Бюджет» — версия после полного сброса проекта (см. ниже "Второй урок"); не путать со старым бюджет_проект_план.md (2006 год, источник идей, не требований)
metadata:
type: project
---
Старый файл `бюджет_проект_план.md` — заметки 2006 года, источник идей, не требований. Этот файл —
то, что реально подтверждено в разговоре. **Актуальна только эта версия** — предыдущая версия этого
же файла (обсуждение копилок/целей ДО того, как весь проект снесли и начали заново) частично устарела,
см. "Второй урок" ниже про то, почему.
## Продукт
Личный (не мульти-пользовательский) трекер бюджета — один человек ведёт свой бюджет. `users` всё же
нужна (не совсем однопользовательский в смысле схемы БД) — счета/категории/транзакции ссылаются на
`user_id`, без этого пришлось бы возвращаться к вопросу при первом же втором пользователе.
## Users
`id`, `login`, `password`, `name`, `email`, `date_add`, `last_login`. Пока достаточно.
## Мультипользовательский режим — согласованный дизайн (гипотетика, НЕ в текущем приоритете, не кодировать)
Обсуждали отдельно от основного скоупа, пользователь попросил зафиксировать. Два независимых механизма,
оба дёшевы и не требуют объединять/сверять чужие бюджеты между собой:
1. **Изолированные бюджеты (например «я» и «сын»)** — ничего доделывать не нужно, уже работает: каждый
`users.id` — это и есть владелец полностью независимого бюджета, `accounts`/`categories`/
`transactions`/`budgets`/`goals` уже фильтруются по своему `user_id` (см. "Продукт" выше — заложено
с самого начала на этот случай). Один бюджет никак не видит другой, ничего сверять/делить не нужно
(в отличие от гипотезы про «общие расходы между раздельными бюджетами», которую обсуждали и
отбросили — пользователь явно сказал не думать про дележ ЖКХ/общих счетов).
2. **Viewer/editor доступ к ЧУЖОМУ бюджету (например жена смотрит бюджет мужа)** — новая маленькая
таблица-разрешение, не полноценная система приглашений:
```sql
CREATE TABLE budget_access (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
owner_user_id INT UNSIGNED NOT NULL, -- чей бюджет (users.id)
viewer_login VARCHAR(64) NOT NULL, -- логин из Services\Auth — кого пускаем
role ENUM('viewer','editor') NOT NULL DEFAULT 'viewer',
FOREIGN KEY (owner_user_id) REFERENCES users (id)
);
```
Логика: человек логинится через `Services\Auth` (уже поддерживает несколько логинов). Приложение
смотрит — есть ли у него свой `users.id` (владеет своим бюджетом), и есть ли записи в `budget_access`
по его `login` (доступ к чужим). Оба факта независимы и совместимы одновременно: можно и владеть
своим бюджетом, и иметь viewer/editor-доступ к чужому сразу — это НЕ взаимоисключающие случаи.
**Следствие для UI**: если у залогиненного человека доступ больше чем к одному бюджету (свой + чужой
через `budget_access`), нужен переключатель «чей бюджет сейчас смотрим».
**Точечная видимость внутри доступа (например скрыть «Подарки», чтобы не спалить сюрприз жене)** —
блок-лист поверх `budget_access`, не allow-лист (по умолчанию viewer видит всё разрешённое, точечно
скрываем конкретные счета/категории, а не наоборот — перечислять вручную всё видимое было бы утомительно):
```sql
CREATE TABLE budget_access_restrictions (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
budget_access_id INT UNSIGNED NOT NULL, -- к какому разрешению (budget_access) относится
entity_type ENUM('account', 'category') NOT NULL,
entity_id INT UNSIGNED NOT NULL, -- id скрываемого счёта или категории
FOREIGN KEY (budget_access_id) REFERENCES budget_access (id)
);
```
Скрытие категории должно прятать и связанные с ней транзакции/план-факт в отчётах viewer'а (не
спроектировано, как именно — фильтровать на уровне запроса или на уровне рендера).
**Решено, с примером.** У viewer'а те же две цифры, что у владельца («Доступно сейчас» и «Всего по
всем счетам»), но пересчитанные только по счетам, которые ему разрешены (не заблокированы через
`budget_access_restrictions`). Два независимых уровня фильтрации, не путать:
1. **`include_in_total`** — решает владелец, на каждом своём счёте: считать ли его в СВОЁ «Доступно
сейчас» (кошелёк/карта — да, копилка — обычно нет).
2. **Доступ viewer'а** (`budget_access_restrictions`) — более грубый фильтр: какие счета viewer вообще
видит. Скрытый от viewer'а счёт не участвует ни в одной из его двух сумм, независимо от того, какая
у этого счёта галочка `include_in_total` у владельца.
Пример: Лена смотрит бюджет Егора, но копилка «Остаток» от неё скрыта. У Лены «Доступно сейчас» =
кошелёк+карта (то же, что и у Егора, минус «Остаток», которого она вообще не видит), «Всего по всем
счетам» — тоже без «Остатка». Скрытый счёт просто не участвует в подсчёте — не до конца проговорено,
нужно ли отдельно прятать сам факт его существования (не только сумму), и блок-лист это или allow-лист
по ощущению пользователя — переспросить при реализации.
**Побочный эффект для схемы**: при таком дизайне `login`/`password` в таблице `users` (бюджетной)
становятся не нужны вообще — реальная авторизация целиком на `Services\Auth`, а `users` вырождается
в «чей это бюджет» (по сути `id` + `name`). Не менять `users` прямо сейчас — это следствие, которое
всплывёт, только если реально начнём кодировать мультипользовательский режим.
**Побочный эффект для схемы**: при таком дизайне `login`/`password` в таблице `users` (бюджетной)
становятся не нужны вообще — реальная авторизация целиком на `Services\Auth`, а `users` вырождается
в «чей это бюджет» (по сути `id` + `name`). Не менять `users` прямо сейчас — это следствие, которое
всплывёт, только если реально начнём кодировать мультипользовательский режим.
## Accounts (счета)
**Один пользователь приложения может вести несколько реальных счетов, включая счета, номинально
оформленные на других членов семьи** (пример: «мой счёт», «счёт жены») — это не мультипользовательская
функция и не требует отдельного `user_id` на счёт: все они просто разные строки `accounts` с разными
`title`, привязанные к одному `user_id` (см. "Продукт" выше). Различать их — по `title`, не по схеме.
**Будущие типы счёта под инвестиции (не сейчас, см. "Приоритет фич" → Инвестиции)** — уточнено, это
два разных счёта, не один:
- **Брокерский** — акции, облигации, фонды, фьючерсы.
- **ЦФА** (цифровые финансовые активы) — долговые, ноты, картины (NFT-подобное), крипто.
Оба не подходят ни под `cash` (не наличные), ни под `bank` (не просто расчётный счёт), ни под `savings`
(не вклад под процент, а портфель с рыночной переоценкой, не фиксированной ставкой). **Как именно с ними
работать (отдельные позиции/тикеры внутри счёта, переоценка, дивиденды/купоны, transfer при
покупке/продаже) — пользователь явно попросил отложить обсуждение, не проектировать сейчас вообще**,
даже на уровне схемы `accounts`/`type_acc`. Сейчас — только основной бюджет (cash/bank/savings).
Три типа `type_acc` (актуальные, реализовано):
- **`cash`** — кошелёк (наличные).
- **`bank`** — счёт в банке; карта просто привязана к нему, сама по себе денег не хранит (это
сознательное упрощение — раньше в модели были отдельные "дебетовая карта"/"кредитная карта"/
"е-money" и т.п., это НЕ подтверждено заново после сброса, убрано).
- **`savings`** — копилка: тот же счёт, но с процентом на остаток (история ставок — отдельная таблица,
привязанная к `account_id`). Опционально может иметь цель (см. `goals` ниже) — если записи в `goals`
нет, это просто копилка "без цели", копишь под процент без конкретной суммы/даты.
Поля: `id`, `user_id`, `type_acc`, `title`, `summa` (баланс, вводится как есть при создании счёта —
любая сумма и своя валюта, не обязательно с нуля), `currency` (при создании счёта, вместе с суммой),
`description` (необязательное свободное поле), `is_delete` (архивация вместо удаления), `order`,
**`include_in_total`** (bool) — счёт учитывается в общей сумме на дашборде или нет; по умолчанию `true`
для `cash`/`bank`, `false` для `savings` — но это **чисто ручной переключатель пользователя, не
системное правило по типу/валюте счёта**. Подтверждено явно: то, что валютный счёт «Евро» был
исключён из «Доступно сейчас» на макете — не потому что валютные счета по умолчанию исключаются, а
потому что пользователь лично выбрал не включать именно этот счёт. Дефолт (true для cash/bank, false
для savings) — просто стартовое значение при создании счёта, дальше пользователь сам решает по каждому.
**Счёт = title + summa + currency (+ description)** — минимальный набор полей, ничего структурного
сверху (например «принадлежит члену семьи X») не нужно — если понадобится пометить «это счёт жены»,
это просто текст в `title`/`description`, не отдельное поле.
**Уход в минус — невозможен для `cash`/`bank`/`savings`.** Овердрафт не подключается и не планируется;
наличным взяться неоткуда, если их нет. Если пользователь тратит больше, чем есть на счету — это
концептуально уже кредит (транзакция «с кредита»), а не отрицательный баланс обычного счёта. Кредиты
по-прежнему вне скоупа (см. "Приоритет фич"), но когда до них дойдём — они не про минус на обычном
счёте, а про отдельный тип счёта/операции.
**Копилка/цель — намеренно тот же счёт, не отдельная сущность.** Пользователь явно подтвердил принцип
«не плодить сущности»: `savings` — это `accounts` с типом + история ставок (`account_rates`) + опционально
`goals` (цель) сверху, а не отдельная параллельная таблица «копилки». Это уже так и спроектировано —
просто явное подтверждение архитектурного выбора.
**Курс валюты — автоматически, из внешнего источника (ЦБ РФ)**, не вручную (в отличие от процентной
ставки копилки — там осознанно вручную, см. `Account_rates` ниже; для курса валют внешний источник
есть и он надёжный, незачем дублировать руками).
**Endpoint — решено.** `https://www.cbr.ru/scripts/XML_daily.asp?date_req=DD/MM/YYYY` — простой GET,
плоский XML, без авторизации. Обычный `.asmx` SOAP-веб-сервис ЦБ РФ (`DailyInfoWebServ/DailyInfo.asmx`)
рассматривали и отклонили — потребовал бы отдельного SOAP-клиента, которого в проекте нет (только
`Curl`+REST, см. `Services\Elasticsearch\Client` как образец такого же паттерна).
Формат ответа (проверено на реальном запросе):
```xml
<ValCurs Date="11.08.2026" name="Foreign Currency Market">
<Valute ID="R01235">
<NumCode>840</NumCode>
<CharCode>USD</CharCode>
<Nominal>1</Nominal>
<Name>Доллар США</Name>
<Value>82,6060</Value>
<VunitRate>82,606</VunitRate>
</Valute>
...
</ValCurs>
```
**Подводные камни при парсинге:**
- Кодировка ответа — `windows-1251`, не UTF-8 (проект — `utf8mb4`), нужна конвертация.
- Разделитель дробной части в `Value`/`VunitRate` — запятая, не точка (`82,6060`) — `str_replace(',', '.', …)`
перед `(float)`.
- Нужен `VunitRate` (курс за 1 единицу валюты), не `Value` — у некоторых валют `Nominal` не 1 (например
100 йен), `Value` — курс за весь номинал, не за единицу; `rate_to_rub`/курс на дашборде — всегда за
единицу.
- `CharCode` — это и есть ISO-код, совпадает с форматом `accounts.currency`/`transactions.currency` (`CHAR(3)`).
Клиент — новый `Services\CurrencyRate` (по аналогии с `Services\Elasticsearch`/`Services\Mongo` — точка
входа + REST/HTTP-клиент), поверх уже существующего `System\Classes\HTTP\Client\Curl`. Дашборд
показывает курс для валют, которые реально встречаются среди счетов пользователя (не весь список валют
ЦБ РФ огулом). **Для дашборда курс — просто «сегодняшнее значение» с кэшем на сутки, историю хранить
для этого не нужно** (история нужна в другом месте, см. ниже).
**Общая сумма на дашборде** — не сумма вообще всех счетов, а сумма счетов с `include_in_total = true`,
сконвертированная в рубли по текущему курсу для не-рублёвых. Рубль — базовая валюта для агрегации
(остальные конвертируются в неё для складывания в одну сумму).
**Курс валюты на операции — фиксируется прямо в транзакции, не отдельной историей.** Возникло из
вопроса «отчёт по Отпуску, если траты в разных валютах» — решено, что в момент создания расходной
операции по валютному счёту курс ЦБ РФ на этот день записывается прямо в саму транзакцию и остаётся
неизменным навсегда (так же, как в реальных банковских FX-операциях — курс фиксируется в моменте, а не
ищется задним числом). Отдельная таблица `currency_rates` с историей по датам **не нужна** — это было
более сложное первое решение, отменено в пользу этого:
```sql
ALTER TABLE transactions ADD COLUMN currency CHAR(3) NULL; -- валюта операции (обычно = валюте счёта списания)
ALTER TABLE transactions ADD COLUMN rate_to_rub DECIMAL(10, 4) NULL; -- курс ЦБ РФ на момент операции; NULL для рублёвых
```
Отчёт за период (например «Отпуск» за произвольные даты, не обязательно календарный месяц) суммирует
`summa * COALESCE(rate_to_rub, 1)` по нужным транзакциям — без join'ов и без логики «на эту дату курса
нет, берём ближайший». Период отчёта по категории — отдельная фича поверх `transactions`, не привязана
к месячной сетке `budgets` (та остаётся по месяцам, это для другого — план/факт, не разовые отчёты).
## Categories (статьи) — с подкатегориями
`id`, `user_id`, `parent_id` (null — категория верхнего уровня; иначе — id родителя), `title`, `type`
(`income`/`expense`), `order`, **`is_delete`** (архивация вместо удаления — тот же приём, что уже
есть у `accounts`; подтверждено: если по статье есть история транзакций, просто скрывать её из выбора
при вводе новой операции, не удалять физически).
Пример: «Продукты» — родительская категория, «Алкоголь» — подкатегория внутри неё. Транзакция
привязывается к конкретной (под)категории. В отчёте можно смотреть по родителю целиком (сумма всех
подкатегорий) или провалиться в конкретную подкатегорию отдельно.
**Защита от дублирования статей — подтверждено, нужно.** Два сценария:
1. Заархивировали статью, забыли, завели новую с тем же названием — история разъезжается на две части.
2. Опечатка/невнимательность — завели одну и ту же статью дважды по рассеянности.
Решение для обоих — на уровне приложения, не БД-constraint: при создании/переименовании статьи
проверять, нет ли уже **активной** (`is_delete = 0`) статьи с таким же `title` у **этого же
`user_id`** (в рамках типа доход/расход и той же родительской группы) — если есть, не давать создать
копию. Если находится **архивная** статья с тем же именем — предлагать восстановить её
(`is_delete = 0`) вместо создания новой.
**Важно: проверка скоуплена по `user_id`, не глобально.** Если у двух разных (изолированных)
пользователей — например Егора и сына — у каждого своя статья «Питание», это два независимых ряда в
`categories` с одинаковым текстом в `title` и это нормально, конфликта нет — проверка сработала бы
только если ОДИН И ТОТ ЖЕ пользователь попытался завести такую статью у себя дважды. Изоляция
пользователей (см. раздел про мультипользовательский режим) и так гарантирует, что один вообще не
видит статьи другого — здесь ничего дополнительно обеспечивать не нужно.
**Группировка — целиком в руках пользователя, через `parent_id`, никак не трогает историю.**
Пользователь явно подтвердил это как требование: перекинул статью в другую группу (сменил `parent_id`)
— старые `transactions` не меняются (они ссылаются на `categorie_id` листа, не на группу), просто
отчёты/дашборд на лету пересчитываются по текущей группировке при следующем открытии. Схему менять не
пришлось — `parent_id` уже это поддерживает.
**Реальный список статей пользователя** (из его старой таблицы, не придумано мной) —
Доход: Зарплата Егор, Зарплата Лена, Квартира, Другие доходы.
Расход: 10%, К, Питание, Хозтовары, ЖКХ, ЖКХ Люберцы, Авто+Бензин, Проезд, Связь+Инет, Школа, Садик,
Дети, Медицина, Одежда, Личные расходы, Развлечение, Подарки, Парик/Красота, Отпуск, Хобби,
Образование, Спорт, Дом, Непредв. расходы, Непонятные расходы.
Расшифровка непрозрачных названий:
- **«К»** — Кирилл (сын). Инвестиция на его счёт, вносится один раз в начале года всей суммой
(сложный процент выгоднее, чем вносить помесячно), но в плане (`budgets.plan_summ`) размазывается
по 3 000 ₽/мес. Следствие: факт в январе резко подскочит (реальная транзакция ~36 000 ₽), с февраля
по декабрь факт будет 0 — это ожидаемо, не баг.
- **«10%»** — отчисление 10% от зарплаты в копилку «Магнит», позже инвестируется. Та же природа, что
и «К» — плановая регулярная строка поверх нерегулярных реальных движений денег.
- **«ЖКХ» / «ЖКХ Люберцы»** — коммуналка по двум разным квартирам (не первая — родитель, вторая —
подкатегория; это две равноправные отдельные статьи, обе можно сгруппировать вместе, если нужно).
Группировка (пример, обсуждали при показе макета категорий) — пользователь дал 5 групп-примеров:
«Основные» (ЖКХ, Питание, Связь), «Подписки» (Клод, ЧатGPT — новые примеры, не было в старом списке),
«Дети» (Биба, Боба — тоже новые имена, не факт что совпадают с «Дети»/«Школа»/«Садик»/«К»), «Транспорт»
(Бензин, Штрафы, Авто, Платные дороги), «Медицина» (Аптека, Лечение). Остальные статьи из старого
списка пользователь осознанно оставил без группы — сам разберёт в приложении, не наша забота гадать.
## Transactions
`id`, `user_id`, `categorie_id`, `account_f_id` (откуда), `account_in_id` (куда), `date`, `summa`,
`type` (`income`/`expense`/`transfer`). Для дохода — только `account_in_id`, для расхода — только
`account_f_id`, для перевода — оба.
**Все transfer — без категории, без исключений**, в том числе пополнение savings-счёта/копилки/цели:
переложил деньги в копилку «Отпуск» — это просто перемещение между своими счетами, не потрачено и не
заработано, `categorie_id = null`. **Решено** (закрывает предыдущий открытый вопрос про задвоение):
реальный расход считается только тогда, когда деньги действительно потрачены — гостиница, билеты и
т.п. оформляются обычными `expense`-транзакциями с `account_f_id` = счёт, с которого реально платили
(в т.ч. со счёта-копилки, если платили прямо с него), и категорией «Отпуск» (точнее — под-категорией
типа «Билеты»/«Гостиница»). Задвоения нет, потому что откладывание в копилку категорией не помечается
вовсе — план/факт по категории «Отпуск» видит только реальные траты.
**Баланс счёта (`accounts.summa`) — решено, «как в банках».** Не пересчитывать всю историю на каждый
показ, но и не хранить как самостоятельное число без контроля. Гибрид: `summa` — хранимый кэш, который
обновляется **атомарно, в той же БД-транзакции**, что и сама операция создания/правки/удаления
`transactions` (`PdoConnection::transaction()` уже есть для этого) — источник истины всё равно лента
операций, баланс — материализованный кэш поверх неё для скорости. Дополнительно нужен служебный метод
«пересчитать баланс с нуля из истории» — не для обычной работы, а как инструмент сверки/восстановления
при подозрении на расхождение (аналог банковской реконсиляции).
**Разбивка одной операции на несколько статей — решено, никакой отдельной сущности не нужно.**
Изначальная гипотеза про дочернюю таблицу `transaction_items` (шапка + детали) — **неверна,
отклонена**. По факту «разбивка» — это просто **две (или больше) обычных, полностью независимых
строки в `transactions`** (пример: поход в магазин на 800 ₽ → строка 500 ₽ Питание + строка 300 ₽
Хозтовары, каждая сама по себе). Никакой связи между ними в БД не хранится — они не привязаны друг к
другу как «части одного чека», просто совпадают по дате/счёту. Схема `transactions` уже полностью
это поддерживает как есть, менять её под эту фичу не нужно.
**Два способа ввода — оба просто заводят обычные транзакции:**
1. **Голосовой** — пользователь надиктовывает через ИИ (например «Питание 500 рублей, Хозтовары 300
рублей»), ИИ присылает JSON на несколько транзакций, каждая заводится как обычная строка
`transactions`.
2. **Ручной** — то же самое, но пользователь сам вводит несколько отдельных транзакций подряд.
**AI-провайдер — решено, Claude API, но с возможностью переключиться (например на ChatGPT).**
Не хардкодить конкретного вендора — по аналогии со `Services\Auth`/`AuthDriver` (интерфейс + сменный
драйвер, см. CLAUDE.md → раздел Auth): скорее всего новый `Services\AI`/`AIDriver`-интерфейс,
`ClaudeAIDriver` как реализация по умолчанию, конкретный провайдер — через конфиг (как
`Config::get('auth', 'driver')`).
**Запись голоса — не наша забота, решено.** Никакого захвата аудио/распознавания речи в приложении
не нужно — голос в текст переводит штатная голосовая клавиатура Android (диктовка). В приложение
попадает уже готовый **текст** (обычное текстовое поле, куда пользователь надиктовал через клавиатуру
телефона) — именно этот текст целиком уходит в AI API на разбор в JSON с транзакциями. Никакой
интеграции с аудио/speech-to-text на своей стороне не требуется.
**Ошибки разбора — через отложенную проверку (tmp-таблица), решено.** Более раннее решение «без
подтверждения, сохраняем сразу» — **отменено, заменено этим**. Схема:
1. ИИ разбирает текст диктовки → результат пишется не в `transactions` напрямую, а во временную
таблицу `transaction_drafts` (имя решено, точная структура полей — нет).
2. При заходе на дашборд или страницу бюджета — если в этой таблице есть непросмотренные записи,
всплывает окно с ними.
3. Пользователь просматривает, при необходимости правит (или отклоняет — не уточнено явно, но
логично, что можно и удалить черновик, а не только редактировать).
4. Подтверждённые записи переносятся в `transactions`, черновик по ним удаляется из tmp-таблицы.
Не спроектировано: точная схема tmp-таблицы, можно ли отклонить/удалить черновик (не только
редактировать), что происходит, если пользователь долго не заходит на дашборд/бюджет (черновики
просто копятся до просмотра — видимо ок, не обсуждали иное).
**Промпт с контекстом пользователя — решено, нужно.** В запрос к AI API передаётся не только текст
диктовки, но и список реальных статей/счетов пользователя (актуальные `categories`/`accounts`) — чтобы
ИИ подбирал существующие `categorie_id`/`account_id`, а не придумывал названия статей от себя. Точный
формат промпта/что именно передавать (весь список категорий целиком, или как-то отфильтрованный) —
не спроектировано, решать при реализации.
**Лог правок/удалений — отдельная таблица в БД, структура решена.** Не общий файловый `System\Classes\Log`
(тот — для аудита запросов вообще, не для точечных «покажи все правки операции №123»). Правка и удаление
разрешены всегда, без ограничений по давности. **Цель — возможность откатить операцию по логу**, отсюда
и структура:
```sql
CREATE TABLE transaction_history (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
transaction_id INT UNSIGNED NOT NULL,
user_id INT UNSIGNED NOT NULL,
changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
action ENUM ('create', 'edit', 'delete') NOT NULL,
snapshot JSON NULL, -- полный слепок transactions на этот момент; NULL для action='delete'
PRIMARY KEY (id),
KEY ix_transaction_history_transaction_id (transaction_id)
);
```
**Без отдельных `old_values`/`new_values` — не нужно дублировать «было».** Каждая запись `create`/`edit`
хранит цельный снимок состояния операции **после** действия (`snapshot`). Откат — это переход по цепочке
снимков одной `transaction_id`:
- откатить `edit` → взять `snapshot` из **предыдущей** записи истории этой же операции;
- восстановить после `delete` → взять `snapshot` из записи, предшествующей записи об удалении (сама
запись `delete` данных не хранит, только кто/когда её удалил);
- откатить `create` → просто удалить операцию, данные снимка не нужны.
Собственно механизм отката (кнопка/логика восстановления) — не спроектирован, только структура лога.
## Budgets (план/факт)
`id`, `categorie_id`, `plan_summ`, `year`, `month`, `type`. Факт — не хранится, считается на лету как
сумма `transactions` по этой категории за месяц (с учётом открытого вопроса выше — возможно, включая
помеченные transfer на копилки).
**Рабочий процесс ввода плана — не помесячно по ходу года, а весь год сразу.** План составляется один
раз, в конце года, сразу на все 12 месяцев следующего года по каждой статье (пример: в конце 2026-го
вводится план по «Проезду» на весь 2027-й — 12 чисел за один заход). Влияет на форму ввода — нужна не
«план на текущий месяц», а «план на год» с 12 полями (по одному на месяц) на каждую статью.
## Формульные статьи бюджета
У статьи дохода (например «ЗП Егор») может быть привязана статья расхода (например «Курица») с
настраиваемым процентом — **связь настраивается со стороны статьи дохода**, не расхода (пример:
«ЗП Егор» → «Курица» 10%; «ЗП Лена» → ничего не привязано, это нормально, необязательное поле;
«Халтура» → «Инвестиции» 15% — независимая от первой связь, свой процент). **Строго один-к-одному**:
одна статья дохода — максимум одна статья расхода; вешать одну и ту же статью расхода на две статьи
дохода **не нужно** (сумма нескольких источников не предусмотрена — рассматривали и отклонили).
Механика:
- **Процент — дефолт на статье дохода** (настройка при создании/редактировании категории), не
разовое значение только на момент ввода.
- **При вводе годового плана по статье-источнику** (ЗП, 12 месяцев за один заход — см. `Budgets`
выше) план привязанной статьи расхода автозаполняется = дефолтный процент × план ЗП **для каждого
месяца отдельно**.
- **Процент можно менять помесячно** — правится не история/задним числом (в отличие от
`account_rates`), а сам месяц: меняешь процент для конкретного месяца — пересчитывается план
только этого месяца, остальные 11 не трогаются. Никакой ретроактивности/периодов действия, как у
ставки копилки, не нужно — у каждого месяца просто свой процент.
**Факт — обычный, не формула.** Формулой автозаполняется только `plan_summ`; факт по такой статье —
такие же реальные транзакции, как у любой другой статьи расхода, без ограничения по формуле. Можно
потратить больше (или меньше) рассчитанного плана — это просто обычное расхождение план/факт, ничем
не отличается от нессылочных статей.
**Схема — решено:**
```sql
ALTER TABLE categories ADD COLUMN linked_expense_categorie_id INT UNSIGNED NULL;
ALTER TABLE categories ADD COLUMN formula_pct DECIMAL(5, 2) NULL;
ALTER TABLE categories ADD CONSTRAINT fk_categories_linked_expense
FOREIGN KEY (linked_expense_categorie_id) REFERENCES categories (id);
```
Оба поля имеют смысл только у статей дохода (`type = 'income'`) с настроенной связью, у остальных
`NULL`. В `budgets` — новое поле `formula_pct_used DECIMAL(5, 2) NULL`, хранит, каким процентом
посчитан план конкретного месяца (нужно для показа/правки в форме плана), `NULL` для обычных
(нессылочных) строк.
**`plan_summ` — хранится как обычно, даже у формульных строк** (как и у обычных статей — план в
принципе всегда хранимое число в `budgets`, только у формульных он не вводится руками, а
автоматически пересчитывается и перезаписывается при правке суммы/процента источника, в той же
транзакции — тот же паттерн кэша, что и `accounts.summa`). Чтение `budgets` одинаково для всех
строк, формульных и обычных — везде просто число, разница только в том, кто его туда положил.
## Регулярные/автоматические операции — РЕШЕНО НЕ ДЕЛАТЬ
Спрашивал явно — нужна ли фича «повторяющаяся операция», которая сама заводит транзакцию по расписанию
(для подписок типа Клод/ЧатGPT, коммуналки и т.п.). **Ответ: всё вручную**, автозаведения не нужно.
Обоснование пользователя: у подписок сумма почти всегда одна и та же, но может повыситься (автоматика
завела бы неверную сумму молча), а у коммуналки сумма всегда разная (автоматике там вообще нечего
угадывать) — в обоих случаях ручной ввод надёжнее, чем шаблон "повторить прошлый месяц".
## Остаток / Резерв — РЕШЕНО НЕ ДЕЛАТЬ (обсуждали и отменили)
Пользователь прислал реальный список категорий из своей старой таблицы, где внизу были строки
«Остаток», «Ост. с прошлого мес.», «Резерв» — сквозные метрики уровня всего бюджета (не по счёту,
не по категории). Формула, которую успели согласовать: Остаток(M) = Доход(M) − Расход(M); Резерв(M) =
Ост. с прошлого мес.(M) + Остаток(M); Ост. с прошлого мес.(M) = Резерв(M−1) — то есть running total
«Доход минус Расход» с начала учёта.
**Но при уточнении пользователь сам решил, что это не нужно** — это был костыль конкретно таблицы,
у которой не было реальных остатков на счетах: приходилось вручную вычислять «сколько у меня реально
осталось» через накопительный доход-минус-расход. У нас есть настоящие `accounts.summa` (см. «Доступно
сейчас» на дашборде — сумма счетов с `include_in_total = true`) — это и есть тот самый ответ на вопрос
«сколько у меня есть», причём точнее (не зависит от того, залогирована ли вообще каждая транзакция).
А сравнение план/факт — уже отдельно покрыто таблицей `budgets` по категориям. Резерв как отдельная
сущность дублировал бы то, что уже есть в двух других местах — **не реализовывать**.
## Account_rates (история ставок)
`id`, `account_id`, `rate_percent`, `valid_from`, `valid_to` (null = текущая).
**История (не единственное поле на `accounts`) нужна ради конкретной фичи — узнать, сколько процентов
начислилось за произвольный выбранный период** (например «сколько капнуло за март»). Если ставка
внутри периода менялась, расчёт должен пройти по каждому отрезку `account_rates`, пересекающему
период, со своей ставкой — без истории это невозможно восстановить задним числом. Это и есть
причина оставить таблицу, а не одно поле `rate_percent` на `accounts` (более ранняя версия этого
раздела ошибочно связывала историю только с будущим кредитом — кредит по-прежнему вне скоупа, но
раз есть более близкий, реальный повод, обоснование хранения истории теперь этот, не кредитный).
**Период капитализации — день** (решено, актуально и для расчёта начисленных процентов, и для
формулы прогноза в `Goals` ниже — один и тот же вопрос на обе задачи).
**Фактическое начисление процентов на копилку — без автоматического расчёта.** Пользователь сам
вводит сумму вручную по кнопке «начислить проценты» (см. `Goals` ниже) — система не считает и не
подставляет её сама. Расчёт «сколько капнуло за период» по дневной ставке (с разбивкой по
`account_rates`, если ставка менялась внутри периода) нужен только для **прогноза/отчёта**
(показать пользователю ориентир перед тем, как он введёт сумму сам), не для автозаполнения
транзакции.
**Ставка вводится вручную, без интеграции с банком** (это личный трекер, не агрегатор счетов) —
план, часть реализована (см. ниже):
- **При создании копилки** (форма счёта, `type_acc = savings`) — доп. поле «Ставка, % годовых».
Создаёт первую строку `account_rates`: `valid_from` = дата создания счёта (или явно введённая
пользователем), `valid_to = null`. **Реализовано** — `AccountsController::createAction()`,
`valid_from` = дата создания (сегодня), без отдельного поля даты в форме (ввод задним числом
не обсуждали для самого первого открытия копилки — только для смены ставки ниже).
- **При изменении ставки** — отдельное действие на странице счёта (не правка задним числом старой
строки — иначе теряется история для прошедших периодов). Пользователь вводит **и новый процент, и
дату, с которой он начинает действовать** (`valid_from` — не обязательно «сегодня», банк мог
прислать уведомление заранее/задним числом). Логика: найти текущую строку через
`AccountRateRepository::getCurrentRate($account_id)`, закрыть её (`valid_to` = день перед новым
`valid_from`), вставить новую (`valid_from` = введённая дата, `valid_to = null`, новый
`rate_percent`) — одной транзакцией (`$repo->transaction(...)`).
- Метод под это — `AccountRateRepository::setRate(int $account_id, float $rate_percent, string $valid_from)`.
Сейчас в `AccountRateRepository` есть только `getCurrentRate()` (чтение, уже реализовано и
протестировано на реальной БД) — `setRate()` ещё не написан.
- Прогноз «надо ≈X ₽/мес» у цели — **решено**: формула аннуитета со сложным процентом по текущей
ставке, дневная капитализация (не линейно) — см. выше.
## Goals (цель для копилки)
`id`, `account_id`, `target_summa`, `deadline`. Необязательная надстройка над `savings`-счётом.
Прогноз «сколько класть в месяц» считается на лету по ТЕКУЩЕЙ ставке (не хранится), в предположении,
что она не изменится до дедлайна; пересчитывается при просмотре/смене ставки.
**Проценты, начисленные на копилку** — `income`-транзакция на счёт-копилку с одной системной
категорией «Проценты по вкладам/целям» (одна на все копилки, не по одной на каждую, заводится
пользователю автоматически при первом использовании). **Сумма — вручную, кнопкой «начислить
проценты»**: пользователь сам вписывает число (система не считает и не подставляет его сама, см.
`Account_rates` выше — расчёт по дневной ставке нужен только как ориентир для прогноза, не для
автозаполнения этой транзакции).
**Просроченный `deadline`, цель не достигнута — без особой обработки.** Просто показываем прогресс
как есть (например «10 000 из 15 000»), без подсветки/предупреждения и без предложения сдвинуть
дедлайн.
**UI — на дашборде, не отдельная страница/форма.** Карточки счетов на дашборде: кнопка «+» —
добавить счёт; на каждой карточке-копилке — меню «три точки» → карандаш (редактировать) / корзина
(удалить), как в типичных современных интерфейсах. Создание и редактирование — через модалку, не
отдельную страницу. Раз цель — надстройка над `savings`-счётом (не отдельная сущность), вероятно
поля цели (`target_summa`/`deadline`) — в той же модалке, что и сам счёт; отдельной модалки под
цель не обсуждали.
**Корзина на карточке копилки — архивация счёта целиком** (`is_delete = 1`, тот же приём, что и у
обычных счетов), не просто снятие цели. **Если на счёте остались деньги** (`summa > 0`) — перед
архивацией спросить, на какой счёт перевести остаток.
**В форме закрытия — два отдельных поля суммы: «остаток» и «проценты»**, не одно. Перевод на
счёт-получатель уходит одной суммой (сложение обеих), но перед этим переводом система заводит на
саму копилку `income`-транзакцию на сумму из поля «проценты» с категорией «Проценты по вкладам/целям»
(та же, что и при обычном ручном начислении, см. выше) — это финальное начисление процентов перед
закрытием, совмещённое с самим переводом в один шаг формы. Раздельные поля нужны именно ради этого:
не потерять проценты как отдельную статью дохода в отчётах (transfer — всегда без категории, см.
`Transactions`), просто слив всё в одну сумму перевода это бы стёр.
## Быстрый ввод операции — замена идее Telegram-бота
В старом `бюджет_проект_план.md` (заметки ДО сброса, источник идей) был пункт «Telegram-бот для
быстрого ввода трат». **Переиграно**: вместо внешнего бота — страница быстрого добавления операции
внутри самого Bicycle (тот же стек, без бот-токена/вебхука/парсинга свободного текста от Telegram API).
**PWA-иконка — решено, не делаем.** Обычная страница в браузере, без `View::setManifest()`.
## Онбординг нового пользователя — создание из шаблона
Когда у только что созданного пользователя всё пусто (0 счетов) — предложить создать данные не с
нуля, а из готового шаблона (пока только один шаблон, без выбора):
**Решено:**
- **1 счёт**: тип `bank`, название «Карта» (дефолт шаблона; тип и название свободно правятся позже —
название всегда, `cash`↔`bank`↔`savings` тоже просто смена одного поля `type_acc`, без каскадных
правок `account_rates`/`goals` — это необязательные доп. таблицы, не часть самого счёта, см.
`Accounts` выше). **Сумма — не хардкодится в шаблоне, спрашивается у пользователя** прямо в процессе
создания (как и при обычном создании любого счёта — `summa` вводится как есть).
- **1 статья дохода**: «Зарплата».
- **4 статьи расхода**: «Питание», «ЖКХ», «Проезд», «Развлечение».
- Схема БД под это **менять не нужно** — шаблон это просто набор `INSERT` в уже существующие
`accounts`/`categories` через `AccountRepository`/`CategoryRepository`, никаких новых полей/таблиц.
**Не решено:**
- **Где кнопка/предложение** — в пустом состоянии дашборда рядом с «+» (сейчас там просто текст «Пока
нет ни одного счёта»), или отдельный экран сразу после первого входа/регистрации?
- **План (`budgets`)** — шаблон заводит только счёт + 5 статей без сумм плана, или сразу дефолтные
плановые суммы на текущий месяц по каждой статье?
- Сама кнопка/логика создания из шаблона — не реализована, только идея и часть решений выше.
## Приоритет фич
1. **Цели (план/факт) + анализ бюджета** — сейчас. **Счета (CRUD) — реализовано** (2026-08-12):
`App/Controller/AccountsController.php` (create/edit/delete=архивация, JSON, CSRF/владение
через `user_id` проверяются), модалка + карточки на дашборде (`App/view/Index/index.html`,
`App/media/js/accounts.js`) — без отдельной страницы `/accounts`, как и решено выше. Дальше по
этому приоритету — Категории/Операции/Цели, тем же паттерном контроллера (см. CLAUDE.md →
Controller hierarchy).
2. **Инвестиции** (вклады/акции/облигации) — следующий слой, схему делать с запасом.
3. **Кредиты** — отложены, но `account_rates` намеренно универсальна для переиспользования позже.
**Статьи (categories) + годовой план (budgets)** — план утверждён (2026-08-12), реализация ещё не
начата. Полный план — `/home/isaevea/.claude/plans/rippling-hatching-rabbit.md` (Часть A —
`CategoriesController`/`categories.js`/`categories.html`, отдельная страница `/categories`, защита
от дублей активных/архивных статей; Часть B — `BudgetsController`/`budgets.js`/`budgets.html`, сетка
статьи×12 месяцев сохраняется одной кнопкой одним POST, автозаполнение связанной expense-статьи по
`formula_pct`). `CategoryRepository`/`BudgetRepository` пока пустые обёртки — методы
(`findActiveDuplicate`/`findArchivedDuplicate`/`getTree`, `getYear`/`upsert`) ещё не добавлены.
Начинать реализацию — с `CategoryRepository`, затем `CategoriesController` (по образцу
`AccountsController`).
**Реальная БД и пользователь** (2026-08-12): `schema.sql` применена к боевой MariaDB
(`192.168.11.247:3306`, база `budget`, все таблицы были пустые на момент применения — пересоздавались
без риска потери данных). Единственный реальный пользователь — `login=mikrit` (и в `Services\Auth`/
`App/config/auth_users.php`, и в бюджетной таблице `users`, `name='Egor'`), заглушки `admin`/`manager`
в `auth_users.php` оставлены как тестовые логины, в бюджетной `users` для них строк нет.
## Второй урок (почему эта версия файла переписана)
После того как весь код/схема домена бюджета были снесены "с нуля" по прямому требованию пользователя,
я в следующем же заходе молча притащил старые выводы из ПРЕДЫДУЩЕЙ версии этого файла (users, accounts,
categories, transactions, budgets) как будто они уже согласованы — хотя после сброса заново обсуждались
только копилки/цели. Пользователь: «про пользователя мы ничего не обсуждали, про бюджет мы ничего не
обсуждали... ОТКУДА БЛЯДЬ???». **Урок: "сохранено в памяти" ≠ "можно молча переиспользовать после
полного сброса"** — если проект/фичу explicitly снесли и просили начать заново, каждое решение
переподтверждается в текущем разговоре заново, даже если оно дословно совпадёт с тем, что уже было
записано. Ссылаться на память можно только явно и открыто ("вот что записано, актуально ли?"), не
молча вставлять в предложение как решённое.
**How to apply**: перед тем как писать `schema.sql`/контроллеры/репозитории — свериться с этим файлом
**и убедиться, что каждый пункт был реально произнесён в текущем контексте разговора**, а не просто
существует здесь с прошлого раза. Новые уточнения — дописывать сюда сразу.