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

45 KiB
Raw Blame History

name description metadata
budget-current-plan Актуальный бизнес-процесс фичи «Бюджет» — версия после полного сброса проекта (см. ниже "Второй урок"); не путать со старым бюджет_проект_план.md (2006 год, источник идей, не требований)
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 доступ к ЧУЖОМУ бюджету (например жена смотрит бюджет мужа) — новая маленькая таблица-разрешение, не полноценная система приглашений:

    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). Два независимых уровня фильтрации, не путать:

  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 с историей по датам не нужна — это было более сложное первое решение, отменено в пользу этого:

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/контроллеры/репозитории — свериться с этим файлом и убедиться, что каждый пункт был реально произнесён в текущем контексте разговора, а не просто существует здесь с прошлого раза. Новые уточнения — дописывать сюда сразу.