--- 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 840 USD 1 Доллар США 82,6060 82,606 ... ``` **Подводные камни при парсинге:** - Кодировка ответа — `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`. - **При изменении ставки** — отдельное действие на странице счёта (не правка задним числом старой строки — иначе теряется история для прошедших периодов). Пользователь вводит **и новый процент, и дату, с которой он начинает действовать** (`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. **Цели (план/факт) + анализ бюджета** — сейчас. 2. **Инвестиции** (вклады/акции/облигации) — следующий слой, схему делать с запасом. 3. **Кредиты** — отложены, но `account_rates` намеренно универсальна для переиспользования позже. ## Второй урок (почему эта версия файла переписана) После того как весь код/схема домена бюджета были снесены "с нуля" по прямому требованию пользователя, я в следующем же заходе молча притащил старые выводы из ПРЕДЫДУЩЕЙ версии этого файла (users, accounts, categories, transactions, budgets) как будто они уже согласованы — хотя после сброса заново обсуждались только копилки/цели. Пользователь: «про пользователя мы ничего не обсуждали, про бюджет мы ничего не обсуждали... ОТКУДА БЛЯДЬ???». **Урок: "сохранено в памяти" ≠ "можно молча переиспользовать после полного сброса"** — если проект/фичу explicitly снесли и просили начать заново, каждое решение переподтверждается в текущем разговоре заново, даже если оно дословно совпадёт с тем, что уже было записано. Ссылаться на память можно только явно и открыто ("вот что записано, актуально ли?"), не молча вставлять в предложение как решённое. **How to apply**: перед тем как писать `schema.sql`/контроллеры/репозитории — свериться с этим файлом **и убедиться, что каждый пункт был реально произнесён в текущем контексте разговора**, а не просто существует здесь с прошлого раза. Новые уточнения — дописывать сюда сразу.