This commit is contained in:
IsaevEA 2026-08-11 22:35:26 +03:00
parent 69ce89a695
commit 8021c86e7b
19 changed files with 1225 additions and 174 deletions

View File

@ -18,3 +18,5 @@
- [Независимость движка от приложения](feedback_engine_app_independence.md) — Services/System не должны импортировать App\* или знать схему конкретного проекта - [Независимость движка от приложения](feedback_engine_app_independence.md) — Services/System не должны импортировать App\* или знать схему конкретного проекта
- [Бюджет — актуальный план (2026)](бюджет_текущий_план.md) — что реально согласовано в разговоре; старый бюджет_проект_план.md — только источник идей, не требований - [Бюджет — актуальный план (2026)](бюджет_текущий_план.md) — что реально согласовано в разговоре; старый бюджет_проект_план.md — только источник идей, не требований
- [Не переиспользовать память молча после сброса](feedback_no_silent_memory_reuse_after_reset.md) — после "снеси всё, начнём заново" каждое решение переподтверждается заново, даже если уже записано - [Не переиспользовать память молча после сброса](feedback_no_silent_memory_reuse_after_reset.md) — после "снеси всё, начнём заново" каждое решение переподтверждается заново, даже если уже записано
- [Не лезть в реализацию раньше времени](feedback_no_premature_implementation.md) — пока фича «Бюджет» обсуждается, решения только в план-файле, schema.sql/код не трогать без явного запроса
- [Макет дашборда (Claude Artifact)](reference_dashboard_design_artifact.md) — ссылка на согласованный визуальный дизайн (тёмная тема, room-card, dock-навбар), сверяться при работе над UI

View File

@ -0,0 +1,24 @@
---
name: feedback-no-premature-implementation
description: Не трогать schema.sql/код, пока обсуждение фичи не согласовано целиком — фиксировать решения только в план-файле до явного запроса реализовать
metadata:
type: feedback
---
Пользователь прямо сказал: «ты же любишь в пекло лезть не обсудив сначала» — опасение, что при
обсуждении фичи (пример — разбивка операции на статьи, см. [[бюджет_текущий_план]]) я уже мог
незаметно поправить `schema.sql` или начать писать код, не дождавшись, пока обсуждение реально
закончится и будет явное «давай теперь пиши».
**Why:** Обсуждение бюджета идёт по частям, решения по одной фиче иногда меняются на лету (пример —
формульные статьи: сначала предполагалась дочерняя таблица `transaction_items`/сумма нескольких
источников дохода, потом это отклонили). Если параллельно с обсуждением редактировать `schema.sql`
или код, придётся откатывать реализацию под каждый разворот мысли — то же самое хуже: изменения в
реальной схеме/коде физически труднее откатить незаметно, чем строчку в план-файле.
**How to apply:** Пока фича обсуждается — фиксировать решения только в
`.claude/memory/бюджет_текущий_план.md` (план-файл, не код). Не трогать `schema.sql`,
`App/Repositories/*`, контроллеры и т.п. под эту фичу, пока пользователь явно не попросит начать
реализацию (не путать с уже завершённым слоем БД/фреймворка — там правки кода это часть текущей
явно запрошенной задачи, речь именно про фичу «Бюджет», которая всё ещё на стадии обсуждения).
Если непонятно, закончилось ли обсуждение — переспросить прямо, не действовать по умолчанию.

View File

@ -23,4 +23,11 @@ metadata:
не расширять объём работы (лишние методы, лишний рефакторинг, лишние файлы) сверх того, что запросили. не расширять объём работы (лишние методы, лишний рефакторинг, лишние файлы) сверх того, что запросили.
Если вопрос пользователя похож на «а мы это ещё где-то используем/будем использовать?» — это, как Если вопрос пользователя похож на «а мы это ещё где-то используем/будем использовать?» — это, как
правило, не команда действовать, а проверка моей логики; сначала ответить прямо, не хвататься за правило, не команда действовать, а проверка моей логики; сначала ответить прямо, не хвататься за
редактирование кода. редактирование кода.
**Второй кейс (2026-08-12, реализация логина/2FA):** на шаге ввода кода из письма сам добавил кнопку
«Назад ко входу» (плюс обработку `back` в `LoginController`) — этого никто не просил, ни в обсуждении
2FA, ни в макете дашборда. Пользователь: «просто убери эта кнопка не нужна, она же ни где не пишется
ты сам это придумал?». Тот же паттерн, что и с `contentDir()`, но уже не про код-абстракцию, а про
UI-элемент — вывод: правило касается не только рефакторинга/абстракций, а вообще любого добавления
сверх того, что обсуждалось (лишняя кнопка/поле формы/пункт меню — тот же случай, что и лишний метод).

View File

@ -0,0 +1,27 @@
---
name: reference-dashboard-design-artifact
description: Ссылка на согласованный визуальный макет дашборда «Бюджет» (тёмная тема, room-card, dock-навбар) — сверяться при любой работе над UI
metadata:
type: reference
---
Макет дашборда согласован заранее (в другой сессии) как Claude Artifact:
`https://claude.ai/code/artifact/55e7f715-2ee7-45e2-acb7-82b883fcad02` («Бюджет — обзор (макет)»).
Ключевые элементы дизайна оттуда, уже перенесённые в `App/media/css/dashboard.css` и
`App/view/Index/index.html`/`App/view/layout.html`:
- Тёмная тема (`--bg:#0b0b0f`, `--surface:#17171d`, акцентные цвета per-карточка через
`--card-accent`), inline SVG-иконки (не Bootstrap Icons) для навигации/типов счетов.
- Карточки-«комнаты» (`.room-card`) для счетов, кольца прогресса (`.ring`) для целей копилок,
списки `.budget-list`/`.budget-row` с прогресс-баром для план/факта по категориям.
- Нижний dock-навбар (`.dock`) вместо верхнего меню: Обзор/Счета/Категории/Операции/Цели.
- Верхний блок — «Доступно сейчас» + «Всего по всем счетам», строка stat-pills (дата/% бюджета/
изменение за месяц/курс валюты) — в реализации пока упрощена (нет данных по бюджету/курсу).
Макет — на русском, включает демо-данные (Кошелёк/Карта Тинькофф/Евро, цели «Отпуск»/«Остаток»,
полный список категорий из [[бюджет_текущий_план]]) — не копировать демо-суммы как реальные, только
структуру/классы/цветовую схему.
**How to apply**: перед тем как добавлять новый раздел UI (Счета/Категории/Операции/Цели — сейчас
это просто неактивные пункты dock-навбара) — свериться с этим макетом (`WebFetch` по ссылке выше),
не изобретать свой стиль карточек/навигации заново.

View File

@ -145,12 +145,41 @@ CREATE TABLE budget_access_restrictions (
**Курс валюты — автоматически, из внешнего источника (ЦБ РФ)**, не вручную (в отличие от процентной **Курс валюты — автоматически, из внешнего источника (ЦБ РФ)**, не вручную (в отличие от процентной
ставки копилки — там осознанно вручную, см. `Account_rates` ниже; для курса валют внешний источник ставки копилки — там осознанно вручную, см. `Account_rates` ниже; для курса валют внешний источник
есть и он надёжный, незачем дублировать руками). Не спроектировано пока: какой конкретно endpoint ЦБ РФ есть и он надёжный, незачем дублировать руками).
(daily XML/JSON), куда класть клиент — скорее всего новый `Services\CurrencyRate` (по аналогии с
`Services\Elasticsearch`/`Services\Mongo` — точка входа + REST/HTTP-клиент), поверх уже существующего **Endpoint — решено.** `https://www.cbr.ru/scripts/XML_daily.asp?date_req=DD/MM/YYYY` — простой GET,
`System\Classes\HTTP\Client\Curl`. Дашборд показывает курс для валют, которые реально встречаются среди плоский XML, без авторизации. Обычный `.asmx` SOAP-веб-сервис ЦБ РФ (`DailyInfoWebServ/DailyInfo.asmx`)
счетов пользователя (не весь список валют ЦБ РФ огулом). **Для дашборда курс — просто «сегодняшнее рассматривали и отклонили — потребовал бы отдельного SOAP-клиента, которого в проекте нет (только
значение» с кэшем на сутки, историю хранить для этого не нужно** (история нужна в другом месте, см. ниже). `Curl`+REST, см. `Services\Elasticsearch\Client` как образец такого же паттерна).
Формат ответа (проверено на реальном запросе):
```xml
<ValCurs Date="11.08.2026" name="Foreign Currency Market">
<Valute ID="R01235">
<NumCode>840</NumCode>
<CharCode>USD</CharCode>
<Nominal>1</Nominal>
<Name>Доллар США</Name>
<Value>82,6060</Value>
<VunitRate>82,606</VunitRate>
</Valute>
...
</ValCurs>
```
**Подводные камни при парсинге:**
- Кодировка ответа — `windows-1251`, не UTF-8 (проект — `utf8mb4`), нужна конвертация.
- Разделитель дробной части в `Value`/`VunitRate` — запятая, не точка (`82,6060`) — `str_replace(',', '.', …)`
перед `(float)`.
- Нужен `VunitRate` (курс за 1 единицу валюты), не `Value` — у некоторых валют `Nominal` не 1 (например
100 йен), `Value` — курс за весь номинал, не за единицу; `rate_to_rub`/курс на дашборде — всегда за
единицу.
- `CharCode` — это и есть ISO-код, совпадает с форматом `accounts.currency`/`transactions.currency` (`CHAR(3)`).
Клиент — новый `Services\CurrencyRate` (по аналогии с `Services\Elasticsearch`/`Services\Mongo` — точка
входа + REST/HTTP-клиент), поверх уже существующего `System\Classes\HTTP\Client\Curl`. Дашборд
показывает курс для валют, которые реально встречаются среди счетов пользователя (не весь список валют
ЦБ РФ огулом). **Для дашборда курс — просто «сегодняшнее значение» с кэшем на сутки, историю хранить
для этого не нужно** (история нужна в другом месте, см. ниже).
**Общая сумма на дашборде** — не сумма вообще всех счетов, а сумма счетов с `include_in_total = true`, **Общая сумма на дашборде** — не сумма вообще всех счетов, а сумма счетов с `include_in_total = true`,
сконвертированная в рубли по текущему курсу для не-рублёвых. Рубль — базовая валюта для агрегации сконвертированная в рубли по текущему курсу для не-рублёвых. Рубль — базовая валюта для агрегации
@ -250,25 +279,78 @@ ALTER TABLE transactions ADD COLUMN rate_to_rub DECIMAL(10, 4) NULL; -- курс
«пересчитать баланс с нуля из истории» — не для обычной работы, а как инструмент сверки/восстановления «пересчитать баланс с нуля из истории» — не для обычной работы, а как инструмент сверки/восстановления
при подозрении на расхождение (аналог банковской реконсиляции). при подозрении на расхождение (аналог банковской реконсиляции).
**Разбивка одной операции на несколько статей — подтверждено, нужно.** Один чек/покупка → несколько **Разбивка одной операции на несколько статей — решено, никакой отдельной сущности не нужно.**
пар (статья, сумма) в рамках одной операции (пример: поход в магазин — 500 ₽ Питание + 300 ₽ Изначальная гипотеза про дочернюю таблицу `transaction_items` (шапка + детали) — **неверна,
Хозтовары). Это меняет схему `transactions`: `categorie_id`/`summa` на самой строке `transactions` отклонена**. По факту «разбивка» — это просто **две (или больше) обычных, полностью независимых
достаточно только для НЕразбитых операций; для разбитых нужна отдельная дочерняя таблица (условно строки в `transactions`** (пример: поход в магазин на 800 ₽ → строка 500 ₽ Питание + строка 300 ₽
`transaction_items`: `id`, `transaction_id`, `categorie_id`, `summa`) — шапка `transactions` держит Хозтовары, каждая сама по себе). Никакой связи между ними в БД не хранится — они не привязаны друг к
дату/счета/общую сумму/тип, детали разбивки — в дочерних строках. Не спроектировано окончательно: другу как «части одного чека», просто совпадают по дате/счёту. Схема `transactions` уже полностью
нужна ли разбивка для `income`/`transfer` тоже, или только для `expense` (вероятно только `expense` — это поддерживает как есть, менять её под эту фичу не нужно.
transfer и так без категории, доход обычно один источник). **Два способа ввода:**
1. **Голосовой** — пользователь надиктовывает через AI API (например «Питание 500 рублей, Хозтовары
300 рублей»), система распознаёт речь и разбирает текст на пары статья/сумма автоматически.
2. **Ручной** — то же самое, но вводится руками, пара за парой.
Не спроектировано: какой конкретно AI API для голоса (Claude API уже упоминался в старом
`бюджет_проект_план.md` как ИИ-помощник — возможно, тот же), как обрабатывать ошибки распознавания.
**Лог правок/удалений — отдельная таблица в БД** (не общий файловый `System\Classes\Log` — тот **Два способа ввода — оба просто заводят обычные транзакции:**
подходит для аудита запросов вообще, но не для точечных «покажи все правки операции №123»). Правка и 1. **Голосовой** — пользователь надиктовывает через ИИ (например «Питание 500 рублей, Хозтовары 300
удаление разрешены всегда, без ограничений по давности — но каждое изменение логируется: кто, когда, рублей»), ИИ присылает JSON на несколько транзакций, каждая заводится как обычная строка
что именно поменялось. Не спроектировано: точная структура (`transaction_history`: id, transaction_id, `transactions`.
user_id, changed_at, action, old_values/new_values — набросок, не финал). 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 (план/факт) ## Budgets (план/факт)
@ -281,13 +363,48 @@ user_id, changed_at, action, old_values/new_values — набросок, не ф
вводится план по «Проезду» на весь 2027-й — 12 чисел за один заход). Влияет на форму ввода — нужна не вводится план по «Проезду» на весь 2027-й — 12 чисел за один заход). Влияет на форму ввода — нужна не
«план на текущий месяц», а «план на год» с 12 полями (по одному на месяц) на каждую статью. «план на текущий месяц», а «план на год» с 12 полями (по одному на месяц) на каждую статью.
**Открыто: формульные статьи бюджета.** Пользователь пояснил про «10%»: план = ЗП × 0,1, факт = ## Формульные статьи бюджета
факт ЗП × 0,1 — то есть не фиксированное число и не сумма собственных транзакций, а процент от
другой категории (дохода). Концепция обсуждалась, не спроектирована окончательно: скорее всего У статьи дохода (например «ЗП Егор») может быть привязана статья расхода (например «Курица») с
`budgets` получит необязательные `formula_source_categorie_id` + `formula_pct`, и для таких строк настраиваемым процентом — **связь настраивается со стороны статьи дохода**, не расхода (пример:
план/факт вычисляются на лету от категории-источника, а не читаются напрямую. Не решено: один «ЗП Егор» → «Курица» 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` одинаково для всех
строк, формульных и обычных — везде просто число, разница только в том, кто его туда положил.
## Регулярные/автоматические операции — РЕШЕНО НЕ ДЕЛАТЬ ## Регулярные/автоматические операции — РЕШЕНО НЕ ДЕЛАТЬ
@ -324,8 +441,16 @@ user_id, changed_at, action, old_values/new_values — набросок, не ф
причина оставить таблицу, а не одно поле `rate_percent` на `accounts` (более ранняя версия этого причина оставить таблицу, а не одно поле `rate_percent` на `accounts` (более ранняя версия этого
раздела ошибочно связывала историю только с будущим кредитом — кредит по-прежнему вне скоупа, но раздела ошибочно связывала историю только с будущим кредитом — кредит по-прежнему вне скоупа, но
раз есть более близкий, реальный повод, обоснование хранения истории теперь этот, не кредитный). раз есть более близкий, реальный повод, обоснование хранения истории теперь этот, не кредитный).
Сам расчёт начисленных процентов за период (простой или сложный, как разбивать по датам) — ещё не
спроектирован, отдельная задача на будущее. **Период капитализации — день** (решено, актуально и для расчёта начисленных процентов, и для
формулы прогноза в `Goals` ниже — один и тот же вопрос на обе задачи).
**Фактическое начисление процентов на копилку — без автоматического расчёта.** Пользователь сам
вводит сумму вручную по кнопке «начислить проценты» (см. `Goals` ниже) — система не считает и не
подставляет её сама. Расчёт «сколько капнуло за период» по дневной ставке (с разбивкой по
`account_rates`, если ставка менялась внутри периода) нужен только для **прогноза/отчёта**
(показать пользователю ориентир перед тем, как он введёт сумму сам), не для автозаполнения
транзакции.
**Ставка вводится вручную, без интеграции с банком** (это личный трекер, не агрегатор счетов) — **Ставка вводится вручную, без интеграции с банком** (это личный трекер, не агрегатор счетов) —
план, ЕЩЁ НЕ РЕАЛИЗОВАНО, возвращаемся к нему, когда дойдём до формы счёта: план, ЕЩЁ НЕ РЕАЛИЗОВАНО, возвращаемся к нему, когда дойдём до формы счёта:
@ -343,9 +468,8 @@ user_id, changed_at, action, old_values/new_values — набросок, не ф
- Метод под это — `AccountRateRepository::setRate(int $account_id, float $rate_percent, string $valid_from)`. - Метод под это — `AccountRateRepository::setRate(int $account_id, float $rate_percent, string $valid_from)`.
Сейчас в `AccountRateRepository` есть только `getCurrentRate()` (чтение, уже реализовано и Сейчас в `AccountRateRepository` есть только `getCurrentRate()` (чтение, уже реализовано и
протестировано на реальной БД) — `setRate()` ещё не написан. протестировано на реальной БД) — `setRate()` ещё не написан.
- Прогноз «надо ≈X ₽/мес» у цели — открытый вопрос: линейно (без процента, проще) или честной - Прогноз «надо ≈X ₽/мес» у цели — **решено**: формула аннуитета со сложным процентом по текущей
формулой аннуитета со сложным процентом (то, что буквально написано в `Goals` ниже — «по текущей ставке, дневная капитализация (не линейно) — см. выше.
ставке») — не решено, обсудить перед реализацией расчёта.
## Goals (цель для копилки) ## Goals (цель для копилки)
@ -354,16 +478,64 @@ user_id, changed_at, action, old_values/new_values — набросок, не ф
что она не изменится до дедлайна; пересчитывается при просмотре/смене ставки. что она не изменится до дедлайна; пересчитывается при просмотре/смене ставки.
**Проценты, начисленные на копилку** — `income`-транзакция на счёт-копилку с одной системной **Проценты, начисленные на копилку** — `income`-транзакция на счёт-копилку с одной системной
категорией «Проценты по вкладам/целям» (одна на все копилки, не по одной на каждую) — заводится категорией «Проценты по вкладам/целям» (одна на все копилки, не по одной на каждую, заводится
пользователю автоматически при первом использовании. пользователю автоматически при первом использовании). **Сумма — вручную, кнопкой «начислить
проценты»**: пользователь сам вписывает число (система не считает и не подставляет его сама, см.
`Account_rates` выше — расчёт по дневной ставке нужен только как ориентир для прогноза, не для
автозаполнения этой транзакции).
**Просроченный `deadline`, цель не достигнута — без особой обработки.** Просто показываем прогресс
как есть (например «10 000 из 15 000»), без подсветки/предупреждения и без предложения сдвинуть
дедлайн.
**UI — на дашборде, не отдельная страница/форма.** Карточки счетов на дашборде: кнопка «+» —
добавить счёт; на каждой карточке-копилке — меню «три точки» → карандаш (редактировать) / корзина
(удалить), как в типичных современных интерфейсах. Создание и редактирование — через модалку, не
отдельную страницу. Раз цель — надстройка над `savings`-счётом (не отдельная сущность), вероятно
поля цели (`target_summa`/`deadline`) — в той же модалке, что и сам счёт; отдельной модалки под
цель не обсуждали.
**Корзина на карточке копилки — архивация счёта целиком** (`is_delete = 1`, тот же приём, что и у
обычных счетов), не просто снятие цели. **Если на счёте остались деньги** (`summa > 0`) — перед
архивацией спросить, на какой счёт перевести остаток.
**В форме закрытия — два отдельных поля суммы: «остаток» и «проценты»**, не одно. Перевод на
счёт-получатель уходит одной суммой (сложение обеих), но перед этим переводом система заводит на
саму копилку `income`-транзакцию на сумму из поля «проценты» с категорией «Проценты по вкладам/целям»
(та же, что и при обычном ручном начислении, см. выше) — это финальное начисление процентов перед
закрытием, совмещённое с самим переводом в один шаг формы. Раздельные поля нужны именно ради этого:
не потерять проценты как отдельную статью дохода в отчётах (transfer — всегда без категории, см.
`Transactions`), просто слив всё в одну сумму перевода это бы стёр.
## Быстрый ввод операции — замена идее Telegram-бота ## Быстрый ввод операции — замена идее Telegram-бота
В старом `бюджет_проект_план.md` (заметки ДО сброса, источник идей) был пункт «Telegram-бот для В старом `бюджет_проект_план.md` (заметки ДО сброса, источник идей) был пункт «Telegram-бот для
быстрого ввода трат». **Переиграно**: вместо внешнего бота — страница быстрого добавления операции быстрого ввода трат». **Переиграно**: вместо внешнего бота — страница быстрого добавления операции
внутри самого Bicycle (тот же стек, без бот-токена/вебхука/парсинга свободного текста от Telegram API). внутри самого Bicycle (тот же стек, без бот-токена/вебхука/парсинга свободного текста от Telegram API).
**Не решено** — закреплять ли её как PWA-иконку на экране телефона (`View::setManifest()` уже **PWA-иконка — решено, не делаем.** Обычная страница в браузере, без `View::setManifest()`.
поддержан в проекте, см. CLAUDE.md) — пользователь пока не уверен, обсудить отдельно перед реализацией.
## Онбординг нового пользователя — создание из шаблона
Когда у только что созданного пользователя всё пусто (0 счетов) — предложить создать данные не с
нуля, а из готового шаблона (пока только один шаблон, без выбора):
**Решено:**
- **1 счёт**: тип `bank`, название «Карта» (дефолт шаблона; тип и название свободно правятся позже —
название всегда, `cash`↔`bank`↔`savings` тоже просто смена одного поля `type_acc`, без каскадных
правок `account_rates`/`goals` — это необязательные доп. таблицы, не часть самого счёта, см.
`Accounts` выше). **Сумма — не хардкодится в шаблоне, спрашивается у пользователя** прямо в процессе
создания (как и при обычном создании любого счёта — `summa` вводится как есть).
- **1 статья дохода**: «Зарплата».
- **4 статьи расхода**: «Питание», «ЖКХ», «Проезд», «Развлечение».
- Схема БД под это **менять не нужно** — шаблон это просто набор `INSERT` в уже существующие
`accounts`/`categories` через `AccountRepository`/`CategoryRepository`, никаких новых полей/таблиц.
**Не решено:**
- **Где кнопка/предложение** — в пустом состоянии дашборда рядом с «+» (сейчас там просто текст «Пока
нет ни одного счёта»), или отдельный экран сразу после первого входа/регистрации?
- **План (`budgets`)** — шаблон заводит только счёт + 5 статей без сумм плана, или сразу дефолтные
плановые суммы на текущий месяц по каждой статье?
- Сама кнопка/логика создания из шаблона — не реализована, только идея и часть решений выше.
## Приоритет фич ## Приоритет фич

View File

@ -8,41 +8,39 @@
namespace App\Controller; namespace App\Controller;
use App\Repositories\AccountRepository;
use App\Repositories\UserRepository;
use Services\Auth; use Services\Auth;
use System\Classes\Controller; use System\Classes\Controller;
use System\Classes\HTTP\Request as HTTPRequest;
use System\Classes\MyException; use System\Classes\MyException;
use System\Classes\Request;
use System\Classes\Validation;
/** /**
* Главная страница = страница входа (пока в проекте нет ничего, кроме авторизации). * Дашборд — главная страница после входа (защищена, см. $_auth_protection).
*/ */
class IndexController extends Controller class IndexController extends Controller
{ {
protected bool $_auth_protection = true;
/** /**
* @return string * @return string
* @throws MyException * @throws MyException
*/ */
public function indexAction(): string public function indexAction(): string
{ {
$request = Request::$current; $auth_user = Auth::instance()->getUser();
$error = null; $user = (new UserRepository())->getByLogin($auth_user['login']);
if ($request->method() === HTTPRequest::POST) { $accounts = $user ? (new AccountRepository())->getList('*', ['user_id' => $user->id, 'is_delete' => 0], '`order`') : [];
$validation = Validation::factory($request->post())
->label('login', 'Логин')
->label('password', 'Пароль')
->rule('login', 'required')
->rule('password', 'required');
if ($validation->check() && Auth::instance()->login($request->post('login'), $request->post('password'))) { $total = array_sum(array_map(
return $this->renderContent('index', ['error' => null, 'user' => Auth::instance()->getUser()]); static fn ($account) => $account->include_in_total && $account->currency === 'RUB' ? (float)$account->summa : 0,
} $accounts ?: []
));
$error = 'Неверный логин или пароль'; return $this->render('index', [
} 'user' => $auth_user,
'accounts' => $accounts ?: [],
return $this->renderContent('index', ['error' => $error, 'user' => Auth::instance()->getUser()]); 'total' => $total,
]);
} }
} }

View File

@ -0,0 +1,118 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description LoginController.php
* @copyright (c) 12/08/2026
*/
namespace App\Controller;
use Services\Auth;
use System\Classes\Controller;
use System\Classes\HTTP;
use System\Classes\HTTP\Request as HTTPRequest;
use System\Classes\MyException;
use System\Classes\Request;
use System\Classes\Session;
use System\Classes\Validation;
/**
* Вход/выход, в два шага (логин/пароль → код с почты, пока заглушка '1234' —
* реальной отправки письма нет). /login — форма и обработка, /login/logout — выход.
*/
class LoginController extends Controller
{
/** @var string Ключ сессии — логин, прошедший первый шаг, ждёт код */
protected const PENDING_KEY = 'pending_2fa_login';
/** @var string Ключ сессии — «запомнить меня», выбрано на первом шаге, применяется на втором */
protected const REMEMBER_KEY = 'pending_2fa_remember';
/** @var string Заглушка кода из письма — реальная отправка не реализована */
protected const STUB_CODE = '1234';
/**
* @return string
* @throws MyException
*/
public function indexAction(): string
{
if (Auth::instance()->loggedIn()) {
HTTP::redirect('/');
}
$session = Session::instance();
$request = Request::$current;
if ($request->method() === HTTPRequest::POST) {
return $session->get(self::PENDING_KEY) !== null
? $this->handleCodeStep($session)
: $this->handleCredentialsStep($session);
}
return $this->renderContent('login', [
'error' => null,
'step' => $session->get(self::PENDING_KEY) !== null ? 'code' : 'credentials',
]);
}
/**
* Первый шаг — логин/пароль. При успехе не авторизует сразу, а переводит
* на шаг с кодом (см. Auth::verifyCredentials() — проверка без сессии).
*
* @param Session $session
* @return string
* @throws MyException
*/
protected function handleCredentialsStep(Session $session): string
{
$request = Request::$current;
$validation = Validation::factory($request->post())
->label('login', 'Логин')
->label('password', 'Пароль')
->rule('login', 'required')
->rule('password', 'required');
if ($validation->check() && Auth::instance()->verifyCredentials($request->post('login'), $request->post('password'))) {
$session->set(self::PENDING_KEY, $request->post('login'));
$session->set(self::REMEMBER_KEY, $request->post('remember') !== null);
return $this->renderContent('login', ['error' => null, 'step' => 'code']);
}
return $this->renderContent('login', ['error' => 'Неверный логин или пароль', 'step' => 'credentials']);
}
/**
* Второй шаг — код с почты (заглушка STUB_CODE). При успехе — Auth::forceLogin()
* (второй фактор пройден, пароль повторно не проверяется), с учётом «запомнить меня».
*
* @param Session $session
* @return string
* @throws MyException
*/
protected function handleCodeStep(Session $session): string
{
$request = Request::$current;
$login = $session->get(self::PENDING_KEY);
if ($request->post('code') === self::STUB_CODE) {
Auth::instance()->forceLogin($login, (bool)$session->get(self::REMEMBER_KEY, false));
$session->delete(self::PENDING_KEY)->delete(self::REMEMBER_KEY);
HTTP::redirect('/');
}
return $this->renderContent('login', ['error' => 'Неверный код', 'step' => 'code']);
}
/**
* @return never
*/
public function logoutAction(): never
{
Auth::instance()->logout();
HTTP::redirect('/login');
}
}

View File

@ -22,4 +22,17 @@ class UserRepository extends Repository
{ {
parent::__construct('users', connection: $connection); parent::__construct('users', connection: $connection);
} }
/**
* Строка users по логину Services\Auth (сопоставление авторизованного пользователя
* с владельцем бюджета — см. бюджет_текущий_план.md, users.login отдельно от реальной
* авторизации, только для связи с этой строкой).
*
* @param string $login
* @return object|false
*/
public function getByLogin(string $login): mixed
{
return $this->getItemWhere('login = ' . $this->pdo->pdo()->quote($login));
}
} }

274
App/media/css/dashboard.css Normal file
View File

@ -0,0 +1,274 @@
:root {
--bg: #0b0b0f;
--surface: #17171d;
--surface-2: #1e1e26;
--surface-3: #24242d;
--ink: #f2f2f5;
--ink-soft: #c4c4cc;
--muted: #85858f;
--shadow: rgba(0, 0, 0, 0.5);
--teal: #2dd4bf;
--green: #34d399;
--purple: #a78bfa;
--blue: #4c8dff;
--orange: #fb923c;
--pink: #f472b6;
--red: #f87171;
--amber: #fbbf24;
--gray: #6b7280;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
body {
background: var(--bg);
color: var(--ink);
font-family: var(--sans);
margin: 0;
}
main {
max-width: 72rem;
margin: 0 auto;
padding: 1.5rem 1.5rem 6.5rem;
}
.num { font-variant-numeric: tabular-nums; }
.ico { width: 1.05em; height: 1.05em; flex-shrink: 0; }
/* ---------- шапка: приветствие + баланс ---------- */
.top-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
flex-wrap: wrap;
margin-bottom: 2rem;
}
.hello {
font-size: 0.95rem;
color: var(--ink-soft);
}
.balance-label {
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
margin-top: 0.6rem;
}
.balance {
font-size: 3rem;
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1;
}
.balance span { font-size: 1.4rem; font-weight: 600; color: var(--muted); }
.balance-total {
font-size: 1.15rem;
font-weight: 700;
margin-top: 0.4rem;
color: var(--ink-soft);
}
.balance-total .label {
font-size: 0.78rem;
font-weight: 500;
color: var(--muted);
margin-right: 0.4rem;
}
/* ---------- заголовок секции ---------- */
.section-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
margin: 2rem 0 0.9rem;
}
.section-label:first-of-type { margin-top: 0; }
/* ---------- карточки счетов ---------- */
.grid-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 0.9rem;
}
.room-card {
position: relative;
background: var(--surface);
border-radius: 18px;
padding: 1rem 1.1rem 1.15rem;
overflow: hidden;
box-shadow: 0 2px 10px var(--shadow);
}
.room-card::after {
content: "";
position: absolute;
left: 14%;
right: 14%;
bottom: 0;
height: 3px;
border-radius: 3px;
background: linear-gradient(90deg, transparent, var(--card-accent, var(--blue)), transparent);
opacity: 0.9;
}
.room-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.room-icon {
width: 2.1rem;
height: 2.1rem;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
background: color-mix(in srgb, var(--card-accent, var(--blue)) 20%, transparent);
color: var(--card-accent, var(--blue));
margin-bottom: 0.7rem;
}
.room-title { font-size: 0.92rem; font-weight: 600; }
.room-sub {
font-size: 0.76rem;
color: var(--muted);
margin-top: 0.15rem;
}
.room-value {
font-size: 1.3rem;
font-weight: 700;
margin-top: 0.6rem;
color: var(--card-accent, var(--ink));
}
/* три точки на карточке счёта */
.room-menu { position: relative; }
.room-menu-btn {
border: none;
background: transparent;
color: var(--muted);
padding: 0.15rem 0.3rem;
border-radius: 6px;
cursor: pointer;
}
.room-menu-btn:hover { color: var(--ink); background: var(--surface-3); }
.room-menu-list {
display: none;
position: absolute;
right: 0;
top: 1.8rem;
z-index: 5;
min-width: 160px;
background: var(--surface-2);
border-radius: 12px;
box-shadow: 0 8px 24px var(--shadow);
padding: 0.35rem;
}
.room-menu.open .room-menu-list { display: block; }
.room-menu-list a {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.6rem;
border-radius: 8px;
font-size: 0.82rem;
color: var(--ink-soft);
}
.room-menu-list a:hover { background: var(--surface-3); color: var(--ink); }
.room-menu-list a.danger { color: var(--red); }
/* пустое состояние + кнопка добавить */
.empty-state {
border: 1px dashed var(--surface-3);
border-radius: 18px;
padding: 2.5rem 1rem;
text-align: center;
color: var(--muted);
}
.btn-add {
width: 2.1rem;
height: 2.1rem;
border-radius: 50%;
border: none;
background: var(--surface-3);
color: var(--ink);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.btn-add:hover { background: var(--blue); }
/* ---------- нижний dock-навбар ---------- */
.dock {
position: fixed;
left: 50%;
bottom: 1.1rem;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.15rem;
background: rgba(23, 23, 29, 0.92);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 999px;
padding: 0.4rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
z-index: 30;
max-width: calc(100vw - 2rem);
overflow-x: auto;
}
.dock-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
padding: 0.45rem 0.9rem;
border-radius: 999px;
font-size: 0.68rem;
color: var(--muted);
white-space: nowrap;
}
.dock-item.active { background: var(--ink); color: #0b0b0f; font-weight: 700; }
@media (max-width: 640px) {
.top-row { flex-direction: column; }
.balance { font-size: 2.4rem; }
}

View File

@ -0,0 +1,33 @@
$(function () {
var $resend = $('#resend-code');
if ($resend.length === 0) {
return;
}
var seconds = 30;
function tick() {
if (seconds <= 0) {
$resend.prop('disabled', false).text('Отправить код ещё раз');
return;
}
$resend.prop('disabled', true).text('Отправить код ещё раз через ' + seconds + ' сек');
seconds--;
setTimeout(tick, 1000);
}
$resend.on('click', function (e) {
e.preventDefault();
if ($resend.prop('disabled')) {
return;
}
seconds = 30;
tick();
});
tick();
});

View File

@ -1,56 +1,114 @@
<?php <?php
/** /**
* @package Bicycle * @package Bicycle
* @description index.html — главная страница = вход. Без общего layout: пока единственная страница проекта, своя HTML-обвязка прямо тут. * @description index.html — дашборд/«Обзор» (главная страница после входа). Дизайн — по
* согласованному макету (тёмная тема, room-card, dock-навбар), рендерится
* внутри layout.html через Controller::render().
* *
* @var string|null $error Сообщение об ошибке (неверные данные) * @var array $user Текущий авторизованный пользователь (без пароля)
* @var array|null $user Текущий авторизованный пользователь (без пароля), null — гость * @var object[] $accounts Счета пользователя (accounts, не архивные)
* @var float $total Сумма счетов с include_in_total=1 в рублях
*/ */
use System\Classes\CSRF; $type_icons = [
use System\Classes\ProfilerToolbar; 'cash' => 'ic-wallet',
'bank' => 'ic-card',
'savings' => 'ic-piggy',
];
?> ?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Бюджет</title>
<link rel="stylesheet" href="/vendor/twbs/bootstrap/dist/css/bootstrap.min.css">
</head>
<body class="d-flex align-items-center justify-content-center bg-body-secondary" style="min-height: 100vh;">
<div class="card shadow-sm" style="width: 100%; max-width: 400px;">
<div class="card-body p-4">
<h1 class="h3 mb-3">Вход</h1>
<?php if ($user !== null): ?> <svg width="0" height="0" style="position:absolute">
<p>Вы вошли как <strong><?= htmlspecialchars($user['login'], ENT_QUOTES, 'UTF-8') ?></strong>.</p> <defs>
<?= ProfilerToolbar::render() ?> <symbol id="ic-wallet" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<?php else: ?> <rect x="2" y="6" width="20" height="14" rx="2"/>
<?php if ($error !== null): ?> <path d="M16 12h4v4h-4a2 2 0 0 1 0-4Z"/>
<div class="alert alert-danger"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div> </symbol>
<?php endif; ?> <symbol id="ic-card" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2"/>
<line x1="2" y1="10" x2="22" y2="10"/>
</symbol>
<symbol id="ic-piggy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="9" width="18" height="9" rx="4.5"/>
<line x1="9" y1="13.5" x2="14" y2="13.5"/>
<circle cx="17" cy="6" r="2"/>
<line x1="17" y1="8" x2="17" y2="9"/>
</symbol>
<symbol id="ic-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</symbol>
<symbol id="ic-dots" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="1.6"/>
<circle cx="12" cy="12" r="1.6"/>
<circle cx="12" cy="19" r="1.6"/>
</symbol>
<symbol id="ic-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>
</symbol>
<symbol id="ic-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/>
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
</symbol>
<symbol id="ic-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 10.5 12 3l9 7.5"/>
<path d="M5 9.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V9.5"/>
</symbol>
<symbol id="ic-tag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2 2 12l9 9 11-11V2Z"/>
<circle cx="7.5" cy="7.5" r="1.5"/>
</symbol>
<symbol id="ic-swap" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 7h11l-3-3"/>
<path d="M17 17H6l3 3"/>
</symbol>
<symbol id="ic-target" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9"/>
<circle cx="12" cy="12" r="5"/>
<circle cx="12" cy="12" r="1.5"/>
</symbol>
</defs>
</svg>
<form method="post" action="/"> <div class="top-row">
<?= CSRF::field() ?> <div>
<div class="hello">Здравствуйте, <?= htmlspecialchars($user['full_name'] ?? $user['login'], ENT_QUOTES, 'UTF-8') ?></div>
<div class="mb-3"> <div class="balance-label">Доступно сейчас</div>
<label class="form-label">Логин <div class="balance num"><?= number_format($total, 0, ',', ' ') ?> <span>₽</span></div>
<input type="text" name="login" class="form-control" autocomplete="username" autofocus>
</label>
</div>
<div class="mb-3">
<label class="form-label">Пароль
<input type="password" name="password" class="form-control" autocomplete="current-password">
</label>
</div>
<button type="submit" class="btn btn-primary">Войти</button>
</form>
<?php endif; ?>
</div> </div>
</div> </div>
</body>
</html> <div class="section-label">
<span>Счета</span>
<button type="button" class="btn-add" title="Добавить счёт"><svg class="ico"><use href="#ic-plus"/></svg></button>
</div>
<?php if (empty($accounts)): ?>
<div class="empty-state">Пока нет ни одного счёта — нажмите «+», чтобы добавить первый</div>
<?php else: ?>
<div class="grid-cards">
<?php foreach ($accounts as $account): ?>
<div class="room-card" style="--card-accent:var(--blue)">
<div class="room-card-head">
<span class="room-icon"><svg class="ico"><use href="#<?= $type_icons[$account->type_acc] ?? 'ic-wallet' ?>"/></svg></span>
<div class="room-menu">
<button type="button" class="room-menu-btn" onclick="this.parentElement.classList.toggle('open')">
<svg class="ico"><use href="#ic-dots"/></svg>
</button>
<div class="room-menu-list">
<a href="#"><svg class="ico ico-sm"><use href="#ic-pencil"/></svg>Редактировать</a>
<a href="#" class="danger"><svg class="ico ico-sm"><use href="#ic-trash"/></svg>Удалить</a>
</div>
</div>
</div>
<div class="room-title"><?= htmlspecialchars($account->title, ENT_QUOTES, 'UTF-8') ?></div>
<div class="room-value num">
<?= number_format((float)$account->summa, 2, ',', ' ') ?>
<?= htmlspecialchars($account->currency, ENT_QUOTES, 'UTF-8') ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>

178
App/view/Login/login.html Normal file
View File

@ -0,0 +1,178 @@
<?php
/**
* @package Bicycle
* @description login.html — страница входа, два шага (логин/пароль → код с почты).
* Без общего layout — своя HTML-обвязка, страница входа не показывает
* навигацию сайта.
*
* @var string|null $error Сообщение об ошибке
* @var string $step 'credentials' | 'code'
*/
use System\Classes\CSRF;
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Вход — Бюджет</title>
<link rel="stylesheet" href="/vendor/twbs/bootstrap-icons/font/bootstrap-icons.css">
<style>
:root {
--bg: #0b0b0f;
--surface: #17171d;
--ink: #f2f2f5;
--ink-soft: #c4c4cc;
--muted: #85858f;
--blue: #4c8dff;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.login-card {
width: 100%;
max-width: 360px;
background: var(--surface);
border-radius: 18px;
padding: 2.5rem 2rem;
box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, .5);
}
.login-icon {
width: 56px;
height: 56px;
border-radius: 50%;
background: color-mix(in srgb, var(--blue) 20%, transparent);
color: var(--blue);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1rem;
font-size: 1.5rem;
}
h1 { font-size: 1.1rem; text-align: center; margin: 0 0 .5rem; }
.step-hint { font-size: .82rem; color: var(--muted); text-align: center; margin-bottom: 1.5rem; }
.field { margin-bottom: 1rem; }
label { display: block; font-size: .8rem; color: var(--ink-soft); margin-bottom: .35rem; }
input[type=text], input[type=password] {
width: 100%;
background: #0b0b0f;
border: 1px solid #2a2a33;
border-radius: 10px;
padding: .65rem .8rem;
color: var(--ink);
font-size: .95rem;
}
input:focus { outline: none; border-color: var(--blue); }
.remember { display: flex; align-items: center; gap: .5rem; font-size: .85rem; color: var(--ink-soft); margin-bottom: 1.25rem; }
button[type=submit] {
width: 100%;
padding: .7rem;
border: none;
border-radius: 10px;
background: var(--blue);
color: #fff;
font-size: .95rem;
font-weight: 600;
cursor: pointer;
}
button[type=submit]:hover { opacity: .9; }
.back-link {
display: block;
text-align: center;
margin-top: 1rem;
font-size: .82rem;
color: var(--muted);
text-decoration: none;
background: none;
border: none;
cursor: pointer;
}
.alert {
background: color-mix(in srgb, #f87171 20%, transparent);
color: #f87171;
border-radius: 10px;
padding: .6rem .8rem;
font-size: .85rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="login-card">
<div class="login-icon"><i class="bi bi-piggy-bank"></i></div>
<h1>Бюджет</h1>
<?php if ($error !== null): ?>
<div class="alert"><?= htmlspecialchars($error, ENT_QUOTES, 'UTF-8') ?></div>
<?php endif; ?>
<?php if ($step === 'code'): ?>
<div class="step-hint">Код отправлен на почту</div>
<form method="post" action="/login">
<?= CSRF::field() ?>
<div class="field">
<label for="code">Код из письма</label>
<input type="text" id="code" name="code" inputmode="numeric" autocomplete="one-time-code" autofocus>
</div>
<button type="submit">Подтвердить</button>
</form>
<button type="button" id="resend-code" class="back-link" disabled>Отправить код ещё раз</button>
<script src="/vendor/components/jquery/jquery.min.js"></script>
<script src="/App/media/js/login-code.js"></script>
<?php else: ?>
<div class="step-hint">Введите логин и пароль</div>
<form method="post" action="/login">
<?= CSRF::field() ?>
<div class="field">
<label for="login">Логин</label>
<input type="text" id="login" name="login" autocomplete="username" autofocus>
</div>
<div class="field">
<label for="password">Пароль</label>
<input type="password" id="password" name="password" autocomplete="current-password">
</div>
<label class="remember">
<input type="checkbox" name="remember" value="1">
Запомнить меня
</label>
<button type="submit">Войти</button>
</form>
<?php endif; ?>
</div>
</body>
</html>

View File

@ -7,8 +7,10 @@
* @var string $content Содержимое страницы * @var string $content Содержимое страницы
*/ */
use Services\Auth;
use System\Classes\ProfilerToolbar; use System\Classes\ProfilerToolbar;
$auth_user = Auth::instance()->getUser();
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
@ -18,17 +20,58 @@ use System\Classes\ProfilerToolbar;
<title>Бюджет</title> <title>Бюджет</title>
<link rel="stylesheet" href="/vendor/twbs/bootstrap/dist/css/bootstrap.min.css"> <link rel="stylesheet" href="/vendor/twbs/bootstrap/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="/vendor/twbs/bootstrap-icons/font/bootstrap-icons.css"> <link rel="stylesheet" href="/vendor/twbs/bootstrap-icons/font/bootstrap-icons.css">
<link rel="stylesheet" href="/App/media/css/dashboard.css">
<?php $this->getStyles(); ?> <?php $this->getStyles(); ?>
</head> </head>
<body> <body>
<nav class="navbar navbar-expand navbar-dark bg-dark"> <svg width="0" height="0" style="position:absolute">
<div class="container"> <defs>
<a class="navbar-brand" href="/">Бюджет</a> <symbol id="ic-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</div> <path d="M3 10.5 12 3l9 7.5"/>
</nav> <path d="M5 9.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V9.5"/>
<main class="container py-3"> </symbol>
<symbol id="ic-card" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2"/>
<line x1="2" y1="10" x2="22" y2="10"/>
</symbol>
<symbol id="ic-tag" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2 2 12l9 9 11-11V2Z"/>
<circle cx="7.5" cy="7.5" r="1.5"/>
</symbol>
<symbol id="ic-swap" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 7h11l-3-3"/>
<path d="M17 17H6l3 3"/>
</symbol>
<symbol id="ic-target" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9"/>
<circle cx="12" cy="12" r="5"/>
<circle cx="12" cy="12" r="1.5"/>
</symbol>
<symbol id="ic-logout" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</symbol>
</defs>
</svg>
<?php if ($auth_user !== null): ?>
<a href="/login/logout" title="Выйти (<?= htmlspecialchars($auth_user['login'], ENT_QUOTES, 'UTF-8') ?>)"
style="position:fixed;top:1rem;right:1rem;z-index:30;color:#85858f;">
<svg style="width:1.3rem;height:1.3rem;"><use href="#ic-logout"/></svg>
</a>
<?php endif; ?>
<main>
<?= $content ?> <?= $content ?>
</main> </main>
<?php if ($auth_user !== null): ?>
<nav class="dock">
<a href="/" class="dock-item active"><span class="ic"><svg class="ico"><use href="#ic-home"/></svg></span>Обзор</a>
<div class="dock-item"><span class="ic"><svg class="ico"><use href="#ic-card"/></svg></span>Счета</div>
<div class="dock-item"><span class="ic"><svg class="ico"><use href="#ic-tag"/></svg></span>Категории</div>
<div class="dock-item"><span class="ic"><svg class="ico"><use href="#ic-swap"/></svg></span>Операции</div>
<div class="dock-item"><span class="ic"><svg class="ico"><use href="#ic-target"/></svg></span>Цели</div>
</nav>
<?php endif; ?>
<script src="/vendor/components/jquery/jquery.min.js"></script> <script src="/vendor/components/jquery/jquery.min.js"></script>
<script src="/vendor/twbs/bootstrap/dist/js/bootstrap.bundle.min.js"></script> <script src="/vendor/twbs/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<?php $this->getScripts(); ?> <?php $this->getScripts(); ?>

View File

@ -36,7 +36,7 @@ index.php — точка входа; константы
System/Classes/ — ядро фреймворка (namespace System\Classes\) System/Classes/ — ядро фреймворка (namespace System\Classes\)
System/Classes/HTTP/ — HTTP-инфраструктура: интерфейсы, заголовки, исключения System/Classes/HTTP/ — HTTP-инфраструктура: интерфейсы, заголовки, исключения
System/Classes/HTTP/Client/ — исходящие HTTP-запросы (Curl, Request, Response) System/Classes/HTTP/Client/ — исходящие HTTP-запросы (Curl, Request, Response)
System/Classes/HTTP/Exception/ — HTTPException_302/403/404 System/Classes/HTTP/Exception/ — HTTPException_403/404
App/Controller/ — контроллеры приложения (namespace App\Controller\) App/Controller/ — контроллеры приложения (namespace App\Controller\)
App/Repositories/ — конкретные репозитории (extends System\Classes\Repository), например BudgetRepository App/Repositories/ — конкретные репозитории (extends System\Classes\Repository), например BudgetRepository
App/config/ — конфиги (config.php; config.local.php — в .gitignore) App/config/ — конфиги (config.php; config.local.php — в .gitignore)
@ -99,8 +99,7 @@ PhpStorm может показывать предупреждение «Namespac
| `System\Classes\HTTP\Header` | Extends `ArrayObject`; `send()`, `__toString()` → RFC-формат | | `System\Classes\HTTP\Header` | Extends `ArrayObject`; `send()`, `__toString()` → RFC-формат |
| `System\Classes\HTTP\Message` | Интерфейс: `protocol()`, `headers()`, `body()`, `render()` | | `System\Classes\HTTP\Message` | Интерфейс: `protocol()`, `headers()`, `body()`, `render()` |
| `System\Classes\HTTP\Request` | Интерфейс входящего запроса; константы методов GET/POST/PUT/... | | `System\Classes\HTTP\Request` | Интерфейс входящего запроса; константы методов GET/POST/PUT/... |
| `System\Classes\HTTP\HTTPException` | HTTP-исключение; `factory(int $code)`, `getResponse(): Response` | | `System\Classes\HTTP\HTTPException` | HTTP-исключение для действительно исключительных кодов (throw + `MyException::handler()`); `factory(int $code)`, `getResponse(): Response`. Редиректы (302) через неё не делаются — не исключение, а штатный поток, см. `HTTP::redirect()` |
| `System\Classes\HTTP\Exception\HTTPException_302` | Редирект через `HTTP::redirect()` |
| `System\Classes\HTTP\Exception\HTTPException_403` | 403 Forbidden | | `System\Classes\HTTP\Exception\HTTPException_403` | 403 Forbidden |
| `System\Classes\HTTP\Exception\HTTPException_404` | 404 Not Found | | `System\Classes\HTTP\Exception\HTTPException_404` | 404 Not Found |
| `System\Classes\HTTP\Client\Curl` | Исполнитель исходящих HTTP-запросов через cURL | | `System\Classes\HTTP\Client\Curl` | Исполнитель исходящих HTTP-запросов через cURL |
@ -296,10 +295,13 @@ return [
из конфига). `$_auth_roles` — список разрешённых ролей (`['admin']`, `['admin', 'manager']`); из конфига). `$_auth_roles` — список разрешённых ролей (`['admin']`, `['admin', 'manager']`);
пустой массив (по умолчанию) — любой авторизованный, без проверки роли. пустой массив (по умолчанию) — любой авторизованный, без проверки роли.
Проверка — в `Controller::before()`: нет авторизации → редирект на `/login` (**не** `throw`: Проверка — в `Controller::before()`: нет авторизации → `HTTP::redirect('/login')` напрямую (не через
неперехваченные исключения уходят в `MyException::handler()`, который не вызывает `getResponse()`, `HTTPException` — редирект гостя на логин не исключительная ситуация, а штатное поведение; раньше это
поэтому для 302 вызывается `HTTPException::factory(302, url)->getResponse()` напрямую — она сама делалось через несуществующий больше `HTTPException_302`, но `throw` этого исключения ушёл бы в
делает `Location` + `exit`); авторизован, но роль не подходит → `throw HTTPException::factory(403)`. `MyException::handler()`, который не вызывает `getResponse()` и настоящий редирект не отправил бы —
сам класс требовал прямого вызова `->getResponse()` без throw, что нарушало контракт остальных
подклассов `HTTPException` и вводило в заблуждение; убран целиком); авторизован, но роль не подходит →
`throw HTTPException::factory(403)`.
`App\Controller\Admin\AdminController` включает `$_auth_protection = true` и `$_auth_roles = ['admin']` `App\Controller\Admin\AdminController` включает `$_auth_protection = true` и `$_auth_roles = ['admin']`
для всей админки. для всей админки.
@ -544,7 +546,9 @@ throw HTTPException::factory(404, 'Страница :url не найдена', [
$response = HTTPException::factory(403)->getResponse(); $response = HTTPException::factory(403)->getResponse();
``` ```
`factory(int $code)` создаёт `HTTPException_302/403/404` или базовый класс для неизвестных кодов. `factory(int $code)` создаёт `HTTPException_403/404` или базовый класс для неизвестных кодов. Только
для действительно исключительных ситуаций (throw); для редиректов — `System\Classes\HTTP::redirect()`
напрямую, не через эту иерархию (см. раздел Auth выше — почему).
### Routing (Route::resolve) ### Routing (Route::resolve)

View File

@ -25,6 +25,28 @@ interface AuthDriver
*/ */
public function login(string $login, string $password): bool; public function login(string $login, string $password): bool;
/**
* Проверяет логин/пароль, не авторизуя (не трогает сессию) — первый шаг
* двухфакторной авторизации: логин/пароль верны, но сессия ещё не
* выставляется, пока не пройдёт второй фактор (см. forceLogin()).
*
* @param string $login
* @param string $password
* @return bool
*/
public function verifyCredentials(string $login, string $password): bool;
/**
* Авторизует пользователя без проверки пароля — второй шаг двухфакторной
* авторизации (после verifyCredentials() и успешной проверки кода).
*
* @param string $login
* @param bool $remember «Запомнить меня» — не спрашивать логин повторно
* до явного logout() (см. logout())
* @return void
*/
public function forceLogin(string $login, bool $remember = false): void;
/** /**
* Разлогинивает пользователя (очищает состояние сессии). * Разлогинивает пользователя (очищает состояние сессии).
* *

View File

@ -9,6 +9,7 @@
namespace Services\Auth; namespace Services\Auth;
use System\Classes\Config; use System\Classes\Config;
use System\Classes\Cookie;
use System\Classes\Session; use System\Classes\Session;
/** /**
@ -21,6 +22,12 @@ use System\Classes\Session;
*/ */
class FileAuthDriver implements AuthDriver class FileAuthDriver implements AuthDriver
{ {
/** @var string Имя cookie «запомнить меня» */
protected const REMEMBER_COOKIE = 'remember_me';
/** @var int Время жизни cookie «запомнить меня», сек (90 дней) */
protected const REMEMBER_LIFETIME = 90 * 24 * 3600;
/** @var string|null Путь к файлу пользователей (если не передан — берётся из конфига) */ /** @var string|null Путь к файлу пользователей (если не передан — берётся из конфига) */
protected ?string $_users_file; protected ?string $_users_file;
@ -52,13 +59,21 @@ class FileAuthDriver implements AuthDriver
} }
/** /**
* Логин текущего авторизованного пользователя из сессии. * Логин текущего авторизованного пользователя: из сессии, либо (если сессии
* нет) восстанавливается из cookie «запомнить меня» — тогда же выставляется
* обратно и в сессию, чтобы не проверять cookie на каждом обращении.
* *
* @return string|null * @return string|null
*/ */
protected function currentLogin(): ?string protected function currentLogin(): ?string
{ {
return Session::instance()->get($this->_session_key); $login = Session::instance()->get($this->_session_key);
if ($login !== null) {
return $login;
}
return $this->restoreFromRememberCookie();
} }
/** /**
@ -66,9 +81,7 @@ class FileAuthDriver implements AuthDriver
*/ */
public function login(string $login, string $password): bool public function login(string $login, string $password): bool
{ {
$user = $this->users()[$login] ?? null; if (!$this->verifyCredentials($login, $password)) {
if ($user === null || !password_verify($password, $user['password'] ?? '')) {
return false; return false;
} }
@ -77,12 +90,81 @@ class FileAuthDriver implements AuthDriver
return true; return true;
} }
/**
* @inheritDoc
*/
public function verifyCredentials(string $login, string $password): bool
{
$user = $this->users()[$login] ?? null;
return $user !== null && password_verify($password, $user['password'] ?? '');
}
/**
* @inheritDoc
*/
public function forceLogin(string $login, bool $remember = false): void
{
Session::instance()->set($this->_session_key, $login);
if ($remember) {
$this->setRememberCookie($login);
}
}
/** /**
* @inheritDoc * @inheritDoc
*/ */
public function logout(): void public function logout(): void
{ {
Session::instance()->delete($this->_session_key); Session::instance()->delete($this->_session_key);
Cookie::delete(self::REMEMBER_COOKIE);
}
/**
* Ставит cookie «запомнить меня»: логин + HMAC-подпись на хэше пароля
* пользователя (не отдельный секрет — при смене пароля хэш меняется,
* старые cookie сами становятся недействительными). Без серверного
* хранилища токенов — не нужно для файлового драйвера.
*
* @param string $login
* @return void
*/
protected function setRememberCookie(string $login): void
{
$hash = $this->users()[$login]['password'] ?? null;
if ($hash === null) {
return;
}
Cookie::set(self::REMEMBER_COOKIE, $login . '|' . hash_hmac('sha256', $login, $hash), self::REMEMBER_LIFETIME);
}
/**
* Проверяет cookie «запомнить меня» и, если подпись верна, восстанавливает
* сессию для этого логина.
*
* @return string|null Логин, если cookie валиден
*/
protected function restoreFromRememberCookie(): ?string
{
$cookie = Cookie::get(self::REMEMBER_COOKIE);
if ($cookie === null || !str_contains($cookie, '|')) {
return null;
}
[$login, $signature] = explode('|', $cookie, 2);
$hash = $this->users()[$login]['password'] ?? null;
if ($hash === null || !hash_equals(hash_hmac('sha256', $login, $hash), $signature)) {
return null;
}
Session::instance()->set($this->_session_key, $login);
return $login;
} }
/** /**

View File

@ -58,10 +58,10 @@ class Controller extends BaseController
$user = Auth::instance($this->_auth_driver)->getUser(); $user = Auth::instance($this->_auth_driver)->getUser();
if ($user === null) { if ($user === null) {
// getResponse() 302 сам делает Location + exit — throw здесь не подходит: // Не исключительная ситуация — штатное поведение (гостя отправляем логиниться),
// неперехваченные исключения уходят в MyException::handler(), который // поэтому обычный редирект, а не throw HTTPException: throw ушёл бы в
// getResponse() не вызывает и настоящий редирект не отправит. // MyException::handler() и отрендерил бы страницу ошибки вместо перехода на /login.
HTTPException::factory(302, '/login')->getResponse(); HTTP::redirect('/login');
} }
if ($this->_auth_roles && !in_array($user['role'] ?? null, $this->_auth_roles, true)) { if ($this->_auth_roles && !in_array($user['role'] ?? null, $this->_auth_roles, true)) {

View File

@ -1,33 +0,0 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description HTTPException_302.php
* @copyright (c) 04/06/2026
*/
namespace System\Classes\HTTP\Exception;
use System\Classes\HTTP;
use System\Classes\HTTP\HTTPException;
use System\Classes\Response;
/**
* 302 Found: редирект на адрес из сообщения исключения.
*/
class HTTPException_302 extends HTTPException
{
/** @var int HTTP-код */
protected int $_code = 302;
/**
* Выполняет редирект через HTTP::redirect() и завершает выполнение.
*
* @return Response Никогда не возвращается (редирект завершает скрипт)
*/
public function getResponse(): Response
{
$location = $this->getMessage() ?: '/';
HTTP::redirect($location, 302);
}
}

View File

@ -56,19 +56,24 @@ CREATE TABLE goals (
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
CREATE TABLE categories ( CREATE TABLE categories (
id INT UNSIGNED NOT NULL AUTO_INCREMENT, id INT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT UNSIGNED NOT NULL, user_id INT UNSIGNED NOT NULL,
parent_id INT UNSIGNED NULL, parent_id INT UNSIGNED NULL,
title VARCHAR(128) NOT NULL, title VARCHAR(128) NOT NULL,
type ENUM ('income', 'expense') NOT NULL, type ENUM ('income', 'expense') NOT NULL,
is_delete TINYINT(1) NOT NULL DEFAULT 0, -- архивация вместо удаления, тот же приём что у accounts — сохраняет историю транзакций is_delete TINYINT(1) NOT NULL DEFAULT 0, -- архивация вместо удаления, тот же приём что у accounts — сохраняет историю транзакций
`order` INT NOT NULL DEFAULT 0, `order` INT NOT NULL DEFAULT 0,
-- формульные статьи (см. бюджет_текущий_план.md): настраиваются на статье ДОХОДА, ссылаются на
-- привязанную статью РАСХОДА + дефолтный процент; у остальных категорий оба поля NULL
linked_expense_categorie_id INT UNSIGNED NULL,
formula_pct DECIMAL(5, 2) NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
KEY ix_categories_user_id (user_id), KEY ix_categories_user_id (user_id),
KEY ix_categories_parent_id (parent_id), KEY ix_categories_parent_id (parent_id),
CONSTRAINT fk_categories_user FOREIGN KEY (user_id) REFERENCES users (id), CONSTRAINT fk_categories_user FOREIGN KEY (user_id) REFERENCES users (id),
-- удаление родителя не должно тянуть за собой подкатегории — они просто станут верхнего уровня -- удаление родителя не должно тянуть за собой подкатегории — они просто станут верхнего уровня
CONSTRAINT fk_categories_parent FOREIGN KEY (parent_id) REFERENCES categories (id) ON DELETE SET NULL CONSTRAINT fk_categories_parent FOREIGN KEY (parent_id) REFERENCES categories (id) ON DELETE SET NULL,
CONSTRAINT fk_categories_linked_expense FOREIGN KEY (linked_expense_categorie_id) REFERENCES categories (id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
-- categorie_id NULL — обычный transfer (в т.ч. пополнение копилки/цели): деньги просто -- categorie_id NULL — обычный transfer (в т.ч. пополнение копилки/цели): деньги просто
@ -96,14 +101,38 @@ CREATE TABLE transactions (
CONSTRAINT fk_transactions_account_in FOREIGN KEY (account_in_id) REFERENCES accounts (id) CONSTRAINT fk_transactions_account_in FOREIGN KEY (account_in_id) REFERENCES accounts (id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
-- Лог правок/удалений transactions — цепочка снимков (не old/new-пары), достаточно для отката:
-- откат edit — snapshot предыдущей записи этой же transaction_id; восстановление после delete —
-- snapshot записи, предшествующей записи delete (сама delete-запись snapshot не хранит); откат
-- create — просто удалить операцию, snapshot не нужен. Механизм самого отката — не реализован,
-- здесь только лог.
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),
-- без FK на transactions.id намеренно: после физического удаления операции строка transactions
-- пропадает, а история по ней должна остаться читаемой
KEY ix_transaction_history_transaction_id (transaction_id),
CONSTRAINT fk_transaction_history_user FOREIGN KEY (user_id) REFERENCES users (id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
-- Факт не хранится — считается на лету суммой transactions по категории за месяц. -- Факт не хранится — считается на лету суммой transactions по категории за месяц.
-- plan_summ хранится всегда (и у формульных строк тоже) — у формульных пересчитывается и
-- перезаписывается автоматически при правке суммы/процента статьи-источника (кэш, как accounts.summa).
CREATE TABLE budgets ( CREATE TABLE budgets (
id INT UNSIGNED NOT NULL AUTO_INCREMENT, id INT UNSIGNED NOT NULL AUTO_INCREMENT,
categorie_id INT UNSIGNED NOT NULL, categorie_id INT UNSIGNED NOT NULL,
plan_summ DECIMAL(15, 2) NOT NULL, plan_summ DECIMAL(15, 2) NOT NULL,
year SMALLINT UNSIGNED NOT NULL, year SMALLINT UNSIGNED NOT NULL,
month TINYINT UNSIGNED NOT NULL, month TINYINT UNSIGNED NOT NULL,
type ENUM ('income', 'expense') NOT NULL, type ENUM ('income', 'expense') NOT NULL,
-- каким процентом посчитан plan_summ этого месяца — только для строк формульной статьи расхода,
-- NULL для обычных строк (см. категорию categories.linked_expense_categorie_id)
formula_pct_used DECIMAL(5, 2) NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uq_budgets_categorie_period (categorie_id, year, month), UNIQUE KEY uq_budgets_categorie_period (categorie_id, year, month),
CONSTRAINT fk_budgets_categorie FOREIGN KEY (categorie_id) REFERENCES categories (id) CONSTRAINT fk_budgets_categorie FOREIGN KEY (categorie_id) REFERENCES categories (id)