71 KiB
| name | description | metadata | ||
|---|---|---|---|---|
| budget-current-plan | Актуальный бизнес-процесс фичи «Бюджет» — версия после полного сброса проекта (см. ниже "Второй урок"); не путать со старым бюджет_проект_план.md (2006 год, источник идей, не требований) |
|
Старый файл бюджет_проект_план.md — заметки 2006 года, источник идей, не требований. Этот файл —
то, что реально подтверждено в разговоре. Актуальна только эта версия — предыдущая версия этого
же файла (обсуждение копилок/целей ДО того, как весь проект снесли и начали заново) частично устарела,
см. "Второй урок" ниже про то, почему.
Продукт
Личный (не мульти-пользовательский) трекер бюджета — один человек ведёт свой бюджет. users всё же
нужна (не совсем однопользовательский в смысле схемы БД) — счета/категории/транзакции ссылаются на
user_id, без этого пришлось бы возвращаться к вопросу при первом же втором пользователе.
Users
id, login, password, name, email, date_add, last_login. Пока достаточно.
Мультипользовательский режим — согласованный дизайн (гипотетика, НЕ в текущем приоритете, не кодировать)
Обсуждали отдельно от основного скоупа, пользователь попросил зафиксировать. Два независимых механизма, оба дёшевы и не требуют объединять/сверять чужие бюджеты между собой:
-
Изолированные бюджеты (например «я» и «сын») — ничего доделывать не нужно, уже работает: каждый
users.id— это и есть владелец полностью независимого бюджета,accounts/categories/transactions/budgets/goalsуже фильтруются по своемуuser_id(см. "Продукт" выше — заложено с самого начала на этот случай). Один бюджет никак не видит другой, ничего сверять/делить не нужно (в отличие от гипотезы про «общие расходы между раздельными бюджетами», которую обсуждали и отбросили — пользователь явно сказал не думать про дележ ЖКХ/общих счетов). -
Viewer/editor доступ к ЧУЖОМУ бюджету (например жена смотрит бюджет мужа) — новая маленькая таблица-разрешение, не полноценная система приглашений:
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 видит всё разрешённое, точечно
скрываем конкретные счета/категории, а не наоборот — перечислять вручную всё видимое было бы утомительно):
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). Два независимых уровня фильтрации, не путать:
include_in_total— решает владелец, на каждом своём счёте: считать ли его в СВОЁ «Доступно сейчас» (кошелёк/карта — да, копилка — обычно нет).- Доступ 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 как образец такого же паттерна).
Формат ответа (проверено на реальном запросе):
<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 с историей по датам не нужна — это было
более сложное первое решение, отменено в пользу этого:
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; подтверждено: если по статье есть история транзакций, просто скрывать её из выбора
при вводе новой операции, не удалять физически).
Пример: «Продукты» — родительская категория, «Алкоголь» — подкатегория внутри неё. Транзакция привязывается к конкретной (под)категории. В отчёте можно смотреть по родителю целиком (сумма всех подкатегорий) или провалиться в конкретную подкатегорию отдельно.
Защита от дублирования статей — подтверждено, нужно. Два сценария:
- Заархивировали статью, забыли, завели новую с тем же названием — история разъезжается на две части.
- Опечатка/невнимательность — завели одну и ту же статью дважды по рассеянности.
Решение для обоих — на уровне приложения, не БД-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 уже полностью
это поддерживает как есть, менять её под эту фичу не нужно.
Два способа ввода — оба просто заводят обычные транзакции:
- Голосовой — пользователь надиктовывает через ИИ (например «Питание 500 рублей, Хозтовары 300
рублей»), ИИ присылает JSON на несколько транзакций, каждая заводится как обычная строка
transactions. - Ручной — то же самое, но пользователь сам вводит несколько отдельных транзакций подряд.
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-таблица), решено. Более раннее решение «без подтверждения, сохраняем сразу» — отменено, заменено этим. Схема:
- ИИ разбирает текст диктовки → результат пишется не в
transactionsнапрямую, а во временную таблицуtransaction_drafts(имя решено, точная структура полей — нет). - При заходе на дашборд или страницу бюджета — если в этой таблице есть непросмотренные записи, всплывает окно с ними.
- Пользователь просматривает, при необходимости правит (или отклоняет — не уточнено явно, но логично, что можно и удалить черновик, а не только редактировать).
- Подтверждённые записи переносятся в
transactions, черновик по ним удаляется из tmp-таблицы.
Не спроектировано: точная схема tmp-таблицы, можно ли отклонить/удалить черновик (не только редактировать), что происходит, если пользователь долго не заходит на дашборд/бюджет (черновики просто копятся до просмотра — видимо ок, не обсуждали иное).
Промпт с контекстом пользователя — решено, нужно. В запрос к AI API передаётся не только текст
диктовки, но и список реальных статей/счетов пользователя (актуальные categories/accounts) — чтобы
ИИ подбирал существующие categorie_id/account_id, а не придумывал названия статей от себя. Точный
формат промпта/что именно передавать (весь список категорий целиком, или как-то отфильтрованный) —
не спроектировано, решать при реализации.
Лог правок/удалений — отдельная таблица в БД, структура решена. Не общий файловый System\Classes\Log
(тот — для аудита запросов вообще, не для точечных «покажи все правки операции №123»). Правка и удаление
разрешены всегда, без ограничений по давности. Цель — возможность откатить операцию по логу, отсюда
и структура:
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; факт по такой статье —
такие же реальные транзакции, как у любой другой статьи расхода, без ограничения по формуле. Можно
потратить больше (или меньше) рассчитанного плана — это просто обычное расхождение план/факт, ничем
не отличается от нессылочных статей.
Схема — решено:
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) — поле «Ставка, % годовых» обязательное, не опционально — уточнено явно: у копилки/цели процент на остаток есть всегда, счётsavingsбез ставки не бывает (в отличие отgoal_target_summa/goal_deadline— вот те действительно опциональная надстройка). Создаёт первую строкуaccount_rates:valid_from= дата создания счёта,valid_to = null. Реализовано —AccountsController::createAction()требуетrate_percentприtype_acc = savings(иначе422),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). Реализовано (2026-08-15) — ровно логика выше (закрыть текущую, открыть новую, одной транзакцией) плюс защита, которой в описании выше не было явно проговорено, но которую иначе легко нарушить:$valid_fromновой ставки должен быть строго позжеvalid_fromтекущей — иначе закрываемая строка получила быvalid_toраньше собственногоvalid_from(невалидный период задом наперёд). Нарушение →MyException(сообщение с обеими датами), транзакция откатывается целиком — ни закрытия старой строки, ни новой записи. Тесты —tests/Unit/AccountRateRepositoryTest.php(временная таблица, не боеваяaccount_rates, тот же приём, что уBudgetRepositoryTest/CategoryRepositoryTest): создание без текущей ставки, закрытие+открытие, отказ на дате ≤ текущейvalid_from(включая точное совпадение), откат при отказе, независимость истории разных счетов. Не сделано осознанно — ещё нет контроллер-эндпоинта/UI-кнопки «изменить ставку» на карточке копилки (само действие в разделеGoalsвыше упомянуто, но отдельно от этой задачи не запрашивалось — метод самодостаточен и протестирован сам по себе, вызывать его сейчас неоткуда). - Прогноз «надо ≈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 статей без сумм плана, или сразу дефолтные плановые суммы на текущий месяц по каждой статье? - Сама кнопка/логика создания из шаблона — не реализована, только идея и часть решений выше.
Приоритет фич
- Цели (план/факт) + анализ бюджета — сейчас. Счета (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). - Инвестиции (вклады/акции/облигации) — следующий слой, схему делать с запасом.
- Кредиты — отложены, но
account_ratesнамеренно универсальна для переиспользования позже.
Статьи (categories) + годовой план (budgets) — реализовано (2026-08-13), план
/home/isaevea/.claude/plans/rippling-hatching-rabbit.md выполнен целиком: CategoryRepository
(findActiveDuplicate/findArchivedDuplicate/getTree), CategoriesController
(index/create/edit/delete/restore, защита от дублей активных/архивных статей), categories.html +
categories.js (страница /categories, группы+подкатегории, формула income→expense в модалке);
BudgetRepository (getYear/upsert, JOIN-таблица categories вынесена в свойство
$categories_table — ради тестируемости без риска для боевой таблицы), BudgetsController
(сетка статьи×12 месяцев, один POST, автопересчёт формульной expense-статьи отдельным проходом
после прямых значений — не зависит от порядка обхода), budgets.html + budgets.js. Ссылка
«Категории» в доке (layout.html) — теперь реальная, с подсветкой активного пункта по
Request::$current->controller(). Тесты — tests/Unit/CategoryRepositoryTest.php,
tests/Unit/BudgetRepositoryTest.php (реальная MariaDB, временные таблицы с отдельными именами —
не боевые categories/budgets). 259 тестов, всё зелено (8 skip — Elasticsearch/Mongo).
Живой CRUD-флоу под mikrit в браузере — не проверен мной (нет учётных данных), маршруты
проверены curl (302 на /login без сессии, без 500).
Транзакции — ручной ввод (create/edit/delete) реализован (2026-08-15):
App/Controller/TransactionsController.php — тот же паттерн, что у Accounts/Categories
(JSON, CSRF/владение через user_id), плюс то, чего не было ни у одного из предыдущих CRUD:
- Баланс счёта обновляется атомарно в той же БД-транзакции, что и сама операция —
AccountRepository::adjustBalance()(новый метод, читает текущийsummaи перезаписывает). - Уход в минус невозможен (см.
Accountsвыше) — expense/transfer, на которые не хватает средств на счёте списания, отклоняются 422«Недостаточно средств на счёте»; при правке порог считается так, будто старая версия операции ещё не списана (иначе правка своей же операции без изменения суммы ложно упёрлась бы в нехватку). - Перевод между счетами разных валют — отклоняется 422. Не решённая заранее фича, а
следствие схемы: у
transactionsодна параsumma/currencyна строку, представить перевод с конвертацией (разные суммы на разных концах) ею нельзя — не проектировать эту фичу сейчас, просто не дать создать операцию, которую схема не может корректно хранить. См..claude/memory/feedback_check_domain_logic.md— тот же принцип «не просто прошло валидацию, а имеет ли смысл по домену», применённый здесь ещё до того, как кто-то наткнулся на баг. - Категория/счета — по владению и типу, не только «существует»:
categorie_idобязателен для income/expense (тип статьи должен совпадать с типом операции), всегдаnullдля transfer независимо от того, что пришло в форме (см.Transactionsвыше — «без категории, без исключений»);account_f_id/account_in_id— по типу операции (expense → только f, income → только in, transfer → оба, разные). - Курс (
rate_to_rub) —Services\CurrencyRate::instance()->rate($currency)(уже был реализован отдельно, до этой сессии) в момент создания/правки; валюта операции = валюта счёта (списания для expense/transfer, зачисления для income) — не выбирается вручную в форме. transaction_history(таблица уже была вschema.sql, но не наполнялась) — новыйApp\Repositories\TransactionHistoryRepository::log(), пишет снимокcreate/edit/deleteтой же БД-транзакцией. Только запись — само чтение лога и механизм отката по-прежнему не спроектированы (см.Transactions→ «Лог правок/удалений» выше).- UI —
App/view/Transactions/index.html+App/media/js/transactions.js, модалка на самой странице/transactions(не на дашборде, в отличие от Accounts) — тип операции тайлами (Расход/Доход/Перевод), остальные поля обычнымиselect/input, показ/скрытие и обязательность полей зависит от типа. Список операций (indexAction, уже был) дополнен меню правки/удаления на каждой строке. - Тесты — не добавлены (в проекте вообще нет тестов на уровне контроллеров, см. таблицу тестов в
CLAUDE.md — только Repository/Model/Core). 266 тестов, всё зелено (8 skip). Живой флоу в
браузере не проверен (нет учётных данных) — маршруты проверены curl (302/403 без сессии/CSRF,
без 500),
php -lна все изменённые файлы. - Голосовой/AI-ввод (
transaction_drafts) — не начат, следующий шаг по этому приоритету.
Реальная БД и пользователь (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/контроллеры/репозитории — свериться с этим файлом
и убедиться, что каждый пункт был реально произнесён в текущем контексте разговора, а не просто
существует здесь с прошлого раза. Новые уточнения — дописывать сюда сразу.