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

389 lines
45 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 ЦБ РФ
(daily XML/JSON), куда класть клиент — скорее всего новый `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()` уже есть для этого) — источник истины всё равно лента
операций, баланс — материализованный кэш поверх неё для скорости. Дополнительно нужен служебный метод
«пересчитать баланс с нуля из истории» — не для обычной работы, а как инструмент сверки/восстановления
при подозрении на расхождение (аналог банковской реконсиляции).
**Разбивка одной операции на несколько статей — подтверждено, нужно.** Один чек/покупка → несколько
пар (статья, сумма) в рамках одной операции (пример: поход в магазин — 500 ₽ Питание + 300 ₽
Хозтовары). Это меняет схему `transactions`: `categorie_id`/`summa` на самой строке `transactions`
достаточно только для НЕразбитых операций; для разбитых нужна отдельная дочерняя таблица (условно
`transaction_items`: `id`, `transaction_id`, `categorie_id`, `summa`) — шапка `transactions` держит
дату/счета/общую сумму/тип, детали разбивки — в дочерних строках. Не спроектировано окончательно:
нужна ли разбивка для `income`/`transfer` тоже, или только для `expense` (вероятно только `expense` —
transfer и так без категории, доход обычно один источник). **Два способа ввода:**
1. **Голосовой** — пользователь надиктовывает через AI API (например «Питание 500 рублей, Хозтовары
300 рублей»), система распознаёт речь и разбирает текст на пары статья/сумма автоматически.
2. **Ручной** — то же самое, но вводится руками, пара за парой.
Не спроектировано: какой конкретно AI API для голоса (Claude API уже упоминался в старом
`бюджет_проект_план.md` как ИИ-помощник — возможно, тот же), как обрабатывать ошибки распознавания.
**Лог правок/удалений — отдельная таблица в БД** (не общий файловый `System\Classes\Log` — тот
подходит для аудита запросов вообще, но не для точечных «покажи все правки операции №123»). Правка и
удаление разрешены всегда, без ограничений по давности — но каждое изменение логируется: кто, когда,
что именно поменялось. Не спроектировано: точная структура (`transaction_history`: id, transaction_id,
user_id, changed_at, action, old_values/new_values — набросок, не финал).
## Budgets (план/факт)
`id`, `categorie_id`, `plan_summ`, `year`, `month`, `type`. Факт — не хранится, считается на лету как
сумма `transactions` по этой категории за месяц (с учётом открытого вопроса выше — возможно, включая
помеченные transfer на копилки).
**Рабочий процесс ввода плана — не помесячно по ходу года, а весь год сразу.** План составляется один
раз, в конце года, сразу на все 12 месяцев следующего года по каждой статье (пример: в конце 2026-го
вводится план по «Проезду» на весь 2027-й — 12 чисел за один заход). Влияет на форму ввода — нужна не
«план на текущий месяц», а «план на год» с 12 полями (по одному на месяц) на каждую статью.
**Открыто: формульные статьи бюджета.** Пользователь пояснил про «10%»: план = ЗП × 0,1, факт =
факт ЗП × 0,1 — то есть не фиксированное число и не сумма собственных транзакций, а процент от
другой категории (дохода). Концепция обсуждалась, не спроектирована окончательно: скорее всего
`budgets` получит необязательные `formula_source_categorie_id` + `formula_pct`, и для таких строк
план/факт вычисляются на лету от категории-источника, а не читаются напрямую. Не решено: один
источник или сумма нескольких (у пользователя два дохода — Зарплата Егор/Зарплата Лена), что
происходит при смене процента задним числом. Не проектировать/не кодировать, пока не обсудим детали.
## Регулярные/автоматические операции — РЕШЕНО НЕ ДЕЛАТЬ
Спрашивал явно — нужна ли фича «повторяющаяся операция», которая сама заводит транзакцию по расписанию
(для подписок типа Клод/Чат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` (более ранняя версия этого
раздела ошибочно связывала историю только с будущим кредитом — кредит по-прежнему вне скоупа, но
раз есть более близкий, реальный повод, обоснование хранения истории теперь этот, не кредитный).
Сам расчёт начисленных процентов за период (простой или сложный, как разбивать по датам) — ещё не
спроектирован, отдельная задача на будущее.
**Ставка вводится вручную, без интеграции с банком** (это личный трекер, не агрегатор счетов) —
план, ЕЩЁ НЕ РЕАЛИЗОВАНО, возвращаемся к нему, когда дойдём до формы счёта:
- **При создании копилки** (форма счёта, `type_acc = savings`) — доп. поле «Ставка, % годовых».
Создаёт первую строку `account_rates`: `valid_from` = дата создания счёта (или явно введённая
пользователем), `valid_to = null`.
- **При изменении ставки** — отдельное действие на странице счёта (не правка задним числом старой
строки — иначе теряется история для прошедших периодов). Пользователь вводит **и новый процент, и
дату, с которой он начинает действовать** (`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` ниже — «по текущей
ставке») — не решено, обсудить перед реализацией расчёта.
## Goals (цель для копилки)
`id`, `account_id`, `target_summa`, `deadline`. Необязательная надстройка над `savings`-счётом.
Прогноз «сколько класть в месяц» считается на лету по ТЕКУЩЕЙ ставке (не хранится), в предположении,
что она не изменится до дедлайна; пересчитывается при просмотре/смене ставки.
**Проценты, начисленные на копилку** — `income`-транзакция на счёт-копилку с одной системной
категорией «Проценты по вкладам/целям» (одна на все копилки, не по одной на каждую) — заводится
пользователю автоматически при первом использовании.
## Быстрый ввод операции — замена идее Telegram-бота
В старом `бюджет_проект_план.md` (заметки ДО сброса, источник идей) был пункт «Telegram-бот для
быстрого ввода трат». **Переиграно**: вместо внешнего бота — страница быстрого добавления операции
внутри самого Bicycle (тот же стек, без бот-токена/вебхука/парсинга свободного текста от Telegram API).
**Не решено** — закреплять ли её как PWA-иконку на экране телефона (`View::setManifest()` уже
поддержан в проекте, см. CLAUDE.md) — пользователь пока не уверен, обсудить отдельно перед реализацией.
## Приоритет фич
1. **Цели (план/факт) + анализ бюджета** — сейчас.
2. **Инвестиции** (вклады/акции/облигации) — следующий слой, схему делать с запасом.
3. **Кредиты** — отложены, но `account_rates` намеренно универсальна для переиспользования позже.
## Второй урок (почему эта версия файла переписана)
После того как весь код/схема домена бюджета были снесены "с нуля" по прямому требованию пользователя,
я в следующем же заходе молча притащил старые выводы из ПРЕДЫДУЩЕЙ версии этого файла (users, accounts,
categories, transactions, budgets) как будто они уже согласованы — хотя после сброса заново обсуждались
только копилки/цели. Пользователь: «про пользователя мы ничего не обсуждали, про бюджет мы ничего не
обсуждали... ОТКУДА БЛЯДЬ???». **Урок: "сохранено в памяти" ≠ "можно молча переиспользовать после
полного сброса"** — если проект/фичу explicitly снесли и просили начать заново, каждое решение
переподтверждается в текущем разговоре заново, даже если оно дословно совпадёт с тем, что уже было
записано. Ссылаться на память можно только явно и открыто ("вот что записано, актуально ли?"), не
молча вставлять в предложение как решённое.
**How to apply**: перед тем как писать `schema.sql`/контроллеры/репозитории — свериться с этим файлом
**и убедиться, что каждый пункт был реально произнесён в текущем контексте разговора**, а не просто
существует здесь с прошлого раза. Новые уточнения — дописывать сюда сразу.