diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 43143ab..0994a63 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -18,3 +18,5 @@ - [Независимость движка от приложения](feedback_engine_app_independence.md) — Services/System не должны импортировать App\* или знать схему конкретного проекта - [Бюджет — актуальный план (2026)](бюджет_текущий_план.md) — что реально согласовано в разговоре; старый бюджет_проект_план.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 diff --git a/.claude/memory/feedback_no_premature_implementation.md b/.claude/memory/feedback_no_premature_implementation.md new file mode 100644 index 0000000..001a539 --- /dev/null +++ b/.claude/memory/feedback_no_premature_implementation.md @@ -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/*`, контроллеры и т.п. под эту фичу, пока пользователь явно не попросит начать +реализацию (не путать с уже завершённым слоем БД/фреймворка — там правки кода это часть текущей +явно запрошенной задачи, речь именно про фичу «Бюджет», которая всё ещё на стадии обсуждения). +Если непонятно, закончилось ли обсуждение — переспросить прямо, не действовать по умолчанию. diff --git a/.claude/memory/feedback_no_unprompted_scope.md b/.claude/memory/feedback_no_unprompted_scope.md index 59bfa1f..a3a48c3 100644 --- a/.claude/memory/feedback_no_unprompted_scope.md +++ b/.claude/memory/feedback_no_unprompted_scope.md @@ -23,4 +23,11 @@ metadata: не расширять объём работы (лишние методы, лишний рефакторинг, лишние файлы) сверх того, что запросили. Если вопрос пользователя похож на «а мы это ещё где-то используем/будем использовать?» — это, как правило, не команда действовать, а проверка моей логики; сначала ответить прямо, не хвататься за -редактирование кода. \ No newline at end of file +редактирование кода. + +**Второй кейс (2026-08-12, реализация логина/2FA):** на шаге ввода кода из письма сам добавил кнопку +«Назад ко входу» (плюс обработку `back` в `LoginController`) — этого никто не просил, ни в обсуждении +2FA, ни в макете дашборда. Пользователь: «просто убери эта кнопка не нужна, она же ни где не пишется +ты сам это придумал?». Тот же паттерн, что и с `contentDir()`, но уже не про код-абстракцию, а про +UI-элемент — вывод: правило касается не только рефакторинга/абстракций, а вообще любого добавления +сверх того, что обсуждалось (лишняя кнопка/поле формы/пункт меню — тот же случай, что и лишний метод). \ No newline at end of file diff --git a/.claude/memory/reference_dashboard_design_artifact.md b/.claude/memory/reference_dashboard_design_artifact.md new file mode 100644 index 0000000..6ed8664 --- /dev/null +++ b/.claude/memory/reference_dashboard_design_artifact.md @@ -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` по ссылке выше), +не изобретать свой стиль карточек/навигации заново. diff --git a/.claude/memory/бюджет_текущий_план.md b/.claude/memory/бюджет_текущий_план.md index d91a9c9..fa569d7 100644 --- a/.claude/memory/бюджет_текущий_план.md +++ b/.claude/memory/бюджет_текущий_план.md @@ -145,12 +145,41 @@ CREATE TABLE budget_access_restrictions ( **Курс валюты — автоматически, из внешнего источника (ЦБ РФ)**, не вручную (в отличие от процентной ставки копилки — там осознанно вручную, см. `Account_rates` ниже; для курса валют внешний источник -есть и он надёжный, незачем дублировать руками). Не спроектировано пока: какой конкретно endpoint ЦБ РФ -(daily XML/JSON), куда класть клиент — скорее всего новый `Services\CurrencyRate` (по аналогии с -`Services\Elasticsearch`/`Services\Mongo` — точка входа + REST/HTTP-клиент), поверх уже существующего -`System\Classes\HTTP\Client\Curl`. Дашборд показывает курс для валют, которые реально встречаются среди -счетов пользователя (не весь список валют ЦБ РФ огулом). **Для дашборда курс — просто «сегодняшнее -значение» с кэшем на сутки, историю хранить для этого не нужно** (история нужна в другом месте, см. ниже). +есть и он надёжный, незачем дублировать руками). + +**Endpoint — решено.** `https://www.cbr.ru/scripts/XML_daily.asp?date_req=DD/MM/YYYY` — простой GET, +плоский XML, без авторизации. Обычный `.asmx` SOAP-веб-сервис ЦБ РФ (`DailyInfoWebServ/DailyInfo.asmx`) +рассматривали и отклонили — потребовал бы отдельного SOAP-клиента, которого в проекте нет (только +`Curl`+REST, см. `Services\Elasticsearch\Client` как образец такого же паттерна). + +Формат ответа (проверено на реальном запросе): +```xml + + + 840 + USD + 1 + Доллар США + 82,6060 + 82,606 + + ... + +``` +**Подводные камни при парсинге:** +- Кодировка ответа — `windows-1251`, не UTF-8 (проект — `utf8mb4`), нужна конвертация. +- Разделитель дробной части в `Value`/`VunitRate` — запятая, не точка (`82,6060`) — `str_replace(',', '.', …)` + перед `(float)`. +- Нужен `VunitRate` (курс за 1 единицу валюты), не `Value` — у некоторых валют `Nominal` не 1 (например + 100 йен), `Value` — курс за весь номинал, не за единицу; `rate_to_rub`/курс на дашборде — всегда за + единицу. +- `CharCode` — это и есть ISO-код, совпадает с форматом `accounts.currency`/`transactions.currency` (`CHAR(3)`). + +Клиент — новый `Services\CurrencyRate` (по аналогии с `Services\Elasticsearch`/`Services\Mongo` — точка +входа + REST/HTTP-клиент), поверх уже существующего `System\Classes\HTTP\Client\Curl`. Дашборд +показывает курс для валют, которые реально встречаются среди счетов пользователя (не весь список валют +ЦБ РФ огулом). **Для дашборда курс — просто «сегодняшнее значение» с кэшем на сутки, историю хранить +для этого не нужно** (история нужна в другом месте, см. ниже). **Общая сумма на дашборде** — не сумма вообще всех счетов, а сумма счетов с `include_in_total = true`, сконвертированная в рубли по текущему курсу для не-рублёвых. Рубль — базовая валюта для агрегации @@ -250,25 +279,78 @@ ALTER TABLE transactions ADD COLUMN rate_to_rub DECIMAL(10, 4) NULL; -- курс «пересчитать баланс с нуля из истории» — не для обычной работы, а как инструмент сверки/восстановления при подозрении на расхождение (аналог банковской реконсиляции). -**Разбивка одной операции на несколько статей — подтверждено, нужно.** Один чек/покупка → несколько -пар (статья, сумма) в рамках одной операции (пример: поход в магазин — 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` как ИИ-помощник — возможно, тот же), как обрабатывать ошибки распознавания. +**Разбивка одной операции на несколько статей — решено, никакой отдельной сущности не нужно.** +Изначальная гипотеза про дочернюю таблицу `transaction_items` (шапка + детали) — **неверна, +отклонена**. По факту «разбивка» — это просто **две (или больше) обычных, полностью независимых +строки в `transactions`** (пример: поход в магазин на 800 ₽ → строка 500 ₽ Питание + строка 300 ₽ +Хозтовары, каждая сама по себе). Никакой связи между ними в БД не хранится — они не привязаны друг к +другу как «части одного чека», просто совпадают по дате/счёту. Схема `transactions` уже полностью +это поддерживает как есть, менять её под эту фичу не нужно. -**Лог правок/удалений — отдельная таблица в БД** (не общий файловый `System\Classes\Log` — тот -подходит для аудита запросов вообще, но не для точечных «покажи все правки операции №123»). Правка и -удаление разрешены всегда, без ограничений по давности — но каждое изменение логируется: кто, когда, -что именно поменялось. Не спроектировано: точная структура (`transaction_history`: id, transaction_id, -user_id, changed_at, action, old_values/new_values — набросок, не финал). +**Два способа ввода — оба просто заводят обычные транзакции:** +1. **Голосовой** — пользователь надиктовывает через ИИ (например «Питание 500 рублей, Хозтовары 300 + рублей»), ИИ присылает JSON на несколько транзакций, каждая заводится как обычная строка + `transactions`. +2. **Ручной** — то же самое, но пользователь сам вводит несколько отдельных транзакций подряд. +**AI-провайдер — решено, Claude API, но с возможностью переключиться (например на ChatGPT).** +Не хардкодить конкретного вендора — по аналогии со `Services\Auth`/`AuthDriver` (интерфейс + сменный +драйвер, см. CLAUDE.md → раздел Auth): скорее всего новый `Services\AI`/`AIDriver`-интерфейс, +`ClaudeAIDriver` как реализация по умолчанию, конкретный провайдер — через конфиг (как +`Config::get('auth', 'driver')`). + +**Запись голоса — не наша забота, решено.** Никакого захвата аудио/распознавания речи в приложении +не нужно — голос в текст переводит штатная голосовая клавиатура Android (диктовка). В приложение +попадает уже готовый **текст** (обычное текстовое поле, куда пользователь надиктовал через клавиатуру +телефона) — именно этот текст целиком уходит в AI API на разбор в JSON с транзакциями. Никакой +интеграции с аудио/speech-to-text на своей стороне не требуется. + +**Ошибки разбора — через отложенную проверку (tmp-таблица), решено.** Более раннее решение «без +подтверждения, сохраняем сразу» — **отменено, заменено этим**. Схема: +1. ИИ разбирает текст диктовки → результат пишется не в `transactions` напрямую, а во временную + таблицу `transaction_drafts` (имя решено, точная структура полей — нет). +2. При заходе на дашборд или страницу бюджета — если в этой таблице есть непросмотренные записи, + всплывает окно с ними. +3. Пользователь просматривает, при необходимости правит (или отклоняет — не уточнено явно, но + логично, что можно и удалить черновик, а не только редактировать). +4. Подтверждённые записи переносятся в `transactions`, черновик по ним удаляется из tmp-таблицы. + +Не спроектировано: точная схема tmp-таблицы, можно ли отклонить/удалить черновик (не только +редактировать), что происходит, если пользователь долго не заходит на дашборд/бюджет (черновики +просто копятся до просмотра — видимо ок, не обсуждали иное). + +**Промпт с контекстом пользователя — решено, нужно.** В запрос к AI API передаётся не только текст +диктовки, но и список реальных статей/счетов пользователя (актуальные `categories`/`accounts`) — чтобы +ИИ подбирал существующие `categorie_id`/`account_id`, а не придумывал названия статей от себя. Точный +формат промпта/что именно передавать (весь список категорий целиком, или как-то отфильтрованный) — +не спроектировано, решать при реализации. + +**Лог правок/удалений — отдельная таблица в БД, структура решена.** Не общий файловый `System\Classes\Log` +(тот — для аудита запросов вообще, не для точечных «покажи все правки операции №123»). Правка и удаление +разрешены всегда, без ограничений по давности. **Цель — возможность откатить операцию по логу**, отсюда +и структура: + +```sql +CREATE TABLE transaction_history ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + transaction_id INT UNSIGNED NOT NULL, + user_id INT UNSIGNED NOT NULL, + changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + action ENUM ('create', 'edit', 'delete') NOT NULL, + snapshot JSON NULL, -- полный слепок transactions на этот момент; NULL для action='delete' + PRIMARY KEY (id), + KEY ix_transaction_history_transaction_id (transaction_id) +); +``` + +**Без отдельных `old_values`/`new_values` — не нужно дублировать «было».** Каждая запись `create`/`edit` +хранит цельный снимок состояния операции **после** действия (`snapshot`). Откат — это переход по цепочке +снимков одной `transaction_id`: +- откатить `edit` → взять `snapshot` из **предыдущей** записи истории этой же операции; +- восстановить после `delete` → взять `snapshot` из записи, предшествующей записи об удалении (сама + запись `delete` данных не хранит, только кто/когда её удалил); +- откатить `create` → просто удалить операцию, данные снимка не нужны. + +Собственно механизм отката (кнопка/логика восстановления) — не спроектирован, только структура лога. ## Budgets (план/факт) @@ -281,13 +363,48 @@ user_id, changed_at, action, old_values/new_values — набросок, не ф вводится план по «Проезду» на весь 2027-й — 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` (более ранняя версия этого раздела ошибочно связывала историю только с будущим кредитом — кредит по-прежнему вне скоупа, но раз есть более близкий, реальный повод, обоснование хранения истории теперь этот, не кредитный). -Сам расчёт начисленных процентов за период (простой или сложный, как разбивать по датам) — ещё не -спроектирован, отдельная задача на будущее. + +**Период капитализации — день** (решено, актуально и для расчёта начисленных процентов, и для +формулы прогноза в `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` есть только `getCurrentRate()` (чтение, уже реализовано и протестировано на реальной БД) — `setRate()` ещё не написан. -- Прогноз «надо ≈X ₽/мес» у цели — открытый вопрос: линейно (без процента, проще) или честной - формулой аннуитета со сложным процентом (то, что буквально написано в `Goals` ниже — «по текущей - ставке») — не решено, обсудить перед реализацией расчёта. +- Прогноз «надо ≈X ₽/мес» у цели — **решено**: формула аннуитета со сложным процентом по текущей + ставке, дневная капитализация (не линейно) — см. выше. ## Goals (цель для копилки) @@ -354,16 +478,64 @@ user_id, changed_at, action, old_values/new_values — набросок, не ф что она не изменится до дедлайна; пересчитывается при просмотре/смене ставки. **Проценты, начисленные на копилку** — `income`-транзакция на счёт-копилку с одной системной -категорией «Проценты по вкладам/целям» (одна на все копилки, не по одной на каждую) — заводится -пользователю автоматически при первом использовании. +категорией «Проценты по вкладам/целям» (одна на все копилки, не по одной на каждую, заводится +пользователю автоматически при первом использовании). **Сумма — вручную, кнопкой «начислить +проценты»**: пользователь сам вписывает число (система не считает и не подставляет его сама, см. +`Account_rates` выше — расчёт по дневной ставке нужен только как ориентир для прогноза, не для +автозаполнения этой транзакции). + +**Просроченный `deadline`, цель не достигнута — без особой обработки.** Просто показываем прогресс +как есть (например «10 000 из 15 000»), без подсветки/предупреждения и без предложения сдвинуть +дедлайн. + +**UI — на дашборде, не отдельная страница/форма.** Карточки счетов на дашборде: кнопка «+» — +добавить счёт; на каждой карточке-копилке — меню «три точки» → карандаш (редактировать) / корзина +(удалить), как в типичных современных интерфейсах. Создание и редактирование — через модалку, не +отдельную страницу. Раз цель — надстройка над `savings`-счётом (не отдельная сущность), вероятно +поля цели (`target_summa`/`deadline`) — в той же модалке, что и сам счёт; отдельной модалки под +цель не обсуждали. + +**Корзина на карточке копилки — архивация счёта целиком** (`is_delete = 1`, тот же приём, что и у +обычных счетов), не просто снятие цели. **Если на счёте остались деньги** (`summa > 0`) — перед +архивацией спросить, на какой счёт перевести остаток. + +**В форме закрытия — два отдельных поля суммы: «остаток» и «проценты»**, не одно. Перевод на +счёт-получатель уходит одной суммой (сложение обеих), но перед этим переводом система заводит на +саму копилку `income`-транзакцию на сумму из поля «проценты» с категорией «Проценты по вкладам/целям» +(та же, что и при обычном ручном начислении, см. выше) — это финальное начисление процентов перед +закрытием, совмещённое с самим переводом в один шаг формы. Раздельные поля нужны именно ради этого: +не потерять проценты как отдельную статью дохода в отчётах (transfer — всегда без категории, см. +`Transactions`), просто слив всё в одну сумму перевода это бы стёр. ## Быстрый ввод операции — замена идее Telegram-бота В старом `бюджет_проект_план.md` (заметки ДО сброса, источник идей) был пункт «Telegram-бот для быстрого ввода трат». **Переиграно**: вместо внешнего бота — страница быстрого добавления операции внутри самого Bicycle (тот же стек, без бот-токена/вебхука/парсинга свободного текста от Telegram API). -**Не решено** — закреплять ли её как PWA-иконку на экране телефона (`View::setManifest()` уже -поддержан в проекте, см. CLAUDE.md) — пользователь пока не уверен, обсудить отдельно перед реализацией. +**PWA-иконка — решено, не делаем.** Обычная страница в браузере, без `View::setManifest()`. + +## Онбординг нового пользователя — создание из шаблона + +Когда у только что созданного пользователя всё пусто (0 счетов) — предложить создать данные не с +нуля, а из готового шаблона (пока только один шаблон, без выбора): + +**Решено:** +- **1 счёт**: тип `bank`, название «Карта» (дефолт шаблона; тип и название свободно правятся позже — + название всегда, `cash`↔`bank`↔`savings` тоже просто смена одного поля `type_acc`, без каскадных + правок `account_rates`/`goals` — это необязательные доп. таблицы, не часть самого счёта, см. + `Accounts` выше). **Сумма — не хардкодится в шаблоне, спрашивается у пользователя** прямо в процессе + создания (как и при обычном создании любого счёта — `summa` вводится как есть). +- **1 статья дохода**: «Зарплата». +- **4 статьи расхода**: «Питание», «ЖКХ», «Проезд», «Развлечение». +- Схема БД под это **менять не нужно** — шаблон это просто набор `INSERT` в уже существующие + `accounts`/`categories` через `AccountRepository`/`CategoryRepository`, никаких новых полей/таблиц. + +**Не решено:** +- **Где кнопка/предложение** — в пустом состоянии дашборда рядом с «+» (сейчас там просто текст «Пока + нет ни одного счёта»), или отдельный экран сразу после первого входа/регистрации? +- **План (`budgets`)** — шаблон заводит только счёт + 5 статей без сумм плана, или сразу дефолтные + плановые суммы на текущий месяц по каждой статье? +- Сама кнопка/логика создания из шаблона — не реализована, только идея и часть решений выше. ## Приоритет фич diff --git a/App/Controller/IndexController.php b/App/Controller/IndexController.php index 11c0264..a7687e2 100644 --- a/App/Controller/IndexController.php +++ b/App/Controller/IndexController.php @@ -8,41 +8,39 @@ namespace App\Controller; +use App\Repositories\AccountRepository; +use App\Repositories\UserRepository; use Services\Auth; use System\Classes\Controller; -use System\Classes\HTTP\Request as HTTPRequest; use System\Classes\MyException; -use System\Classes\Request; -use System\Classes\Validation; /** - * Главная страница = страница входа (пока в проекте нет ничего, кроме авторизации). + * Дашборд — главная страница после входа (защищена, см. $_auth_protection). */ class IndexController extends Controller { + protected bool $_auth_protection = true; + /** * @return string * @throws MyException */ public function indexAction(): string { - $request = Request::$current; - $error = null; + $auth_user = Auth::instance()->getUser(); + $user = (new UserRepository())->getByLogin($auth_user['login']); - if ($request->method() === HTTPRequest::POST) { - $validation = Validation::factory($request->post()) - ->label('login', 'Логин') - ->label('password', 'Пароль') - ->rule('login', 'required') - ->rule('password', 'required'); + $accounts = $user ? (new AccountRepository())->getList('*', ['user_id' => $user->id, 'is_delete' => 0], '`order`') : []; - if ($validation->check() && Auth::instance()->login($request->post('login'), $request->post('password'))) { - return $this->renderContent('index', ['error' => null, 'user' => Auth::instance()->getUser()]); - } + $total = array_sum(array_map( + static fn ($account) => $account->include_in_total && $account->currency === 'RUB' ? (float)$account->summa : 0, + $accounts ?: [] + )); - $error = 'Неверный логин или пароль'; - } - - return $this->renderContent('index', ['error' => $error, 'user' => Auth::instance()->getUser()]); + return $this->render('index', [ + 'user' => $auth_user, + 'accounts' => $accounts ?: [], + 'total' => $total, + ]); } } diff --git a/App/Controller/LoginController.php b/App/Controller/LoginController.php new file mode 100644 index 0000000..e3660dd --- /dev/null +++ b/App/Controller/LoginController.php @@ -0,0 +1,118 @@ +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'); + } +} diff --git a/App/Repositories/UserRepository.php b/App/Repositories/UserRepository.php index 3375221..ea02d83 100644 --- a/App/Repositories/UserRepository.php +++ b/App/Repositories/UserRepository.php @@ -22,4 +22,17 @@ class UserRepository extends Repository { 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)); + } } diff --git a/App/media/css/dashboard.css b/App/media/css/dashboard.css new file mode 100644 index 0000000..0e9238a --- /dev/null +++ b/App/media/css/dashboard.css @@ -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; } +} diff --git a/App/media/js/login-code.js b/App/media/js/login-code.js new file mode 100644 index 0000000..802a468 --- /dev/null +++ b/App/media/js/login-code.js @@ -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(); +}); diff --git a/App/view/Index/index.html b/App/view/Index/index.html index b68b375..39fe244 100644 --- a/App/view/Index/index.html +++ b/App/view/Index/index.html @@ -1,56 +1,114 @@ 'ic-wallet', + 'bank' => 'ic-card', + 'savings' => 'ic-piggy', +]; ?> - - - - - - Бюджет - - - -
-
-

Вход

- -

Вы вошли как .

- - - -
- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -
- - -
- -
- -
- -
- - -
- +
+
+
Здравствуйте,
+
Доступно сейчас
+
₽
- - + + + + +
Пока нет ни одного счёта — нажмите «+», чтобы добавить первый
+ +
+ +
+ +
title, ENT_QUOTES, 'UTF-8') ?>
+
+ summa, 2, ',', ' ') ?> + currency, ENT_QUOTES, 'UTF-8') ?> +
+
+ +
+ diff --git a/App/view/Login/login.html b/App/view/Login/login.html new file mode 100644 index 0000000..1152dd0 --- /dev/null +++ b/App/view/Login/login.html @@ -0,0 +1,178 @@ + + + + + + + Вход — Бюджет + + + + + + + diff --git a/App/view/layout.html b/App/view/layout.html index 6e22410..8b1f3f2 100644 --- a/App/view/layout.html +++ b/App/view/layout.html @@ -7,8 +7,10 @@ * @var string $content Содержимое страницы */ +use Services\Auth; use System\Classes\ProfilerToolbar; +$auth_user = Auth::instance()->getUser(); ?> @@ -18,17 +20,58 @@ use System\Classes\ProfilerToolbar; Бюджет + getStyles(); ?> - -
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + getScripts(); ?> diff --git a/CLAUDE.md b/CLAUDE.md index 48703de..57bf6f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ index.php — точка входа; константы System/Classes/ — ядро фреймворка (namespace System\Classes\) System/Classes/HTTP/ — HTTP-инфраструктура: интерфейсы, заголовки, исключения 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/Repositories/ — конкретные репозитории (extends System\Classes\Repository), например BudgetRepository 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\Message` | Интерфейс: `protocol()`, `headers()`, `body()`, `render()` | | `System\Classes\HTTP\Request` | Интерфейс входящего запроса; константы методов GET/POST/PUT/... | -| `System\Classes\HTTP\HTTPException` | HTTP-исключение; `factory(int $code)`, `getResponse(): Response` | -| `System\Classes\HTTP\Exception\HTTPException_302` | Редирект через `HTTP::redirect()` | +| `System\Classes\HTTP\HTTPException` | HTTP-исключение для действительно исключительных кодов (throw + `MyException::handler()`); `factory(int $code)`, `getResponse(): Response`. Редиректы (302) через неё не делаются — не исключение, а штатный поток, см. `HTTP::redirect()` | | `System\Classes\HTTP\Exception\HTTPException_403` | 403 Forbidden | | `System\Classes\HTTP\Exception\HTTPException_404` | 404 Not Found | | `System\Classes\HTTP\Client\Curl` | Исполнитель исходящих HTTP-запросов через cURL | @@ -296,10 +295,13 @@ return [ из конфига). `$_auth_roles` — список разрешённых ролей (`['admin']`, `['admin', 'manager']`); пустой массив (по умолчанию) — любой авторизованный, без проверки роли. -Проверка — в `Controller::before()`: нет авторизации → редирект на `/login` (**не** `throw`: -неперехваченные исключения уходят в `MyException::handler()`, который не вызывает `getResponse()`, -поэтому для 302 вызывается `HTTPException::factory(302, url)->getResponse()` напрямую — она сама -делает `Location` + `exit`); авторизован, но роль не подходит → `throw HTTPException::factory(403)`. +Проверка — в `Controller::before()`: нет авторизации → `HTTP::redirect('/login')` напрямую (не через +`HTTPException` — редирект гостя на логин не исключительная ситуация, а штатное поведение; раньше это +делалось через несуществующий больше `HTTPException_302`, но `throw` этого исключения ушёл бы в +`MyException::handler()`, который не вызывает `getResponse()` и настоящий редирект не отправил бы — +сам класс требовал прямого вызова `->getResponse()` без throw, что нарушало контракт остальных +подклассов `HTTPException` и вводило в заблуждение; убран целиком); авторизован, но роль не подходит → +`throw HTTPException::factory(403)`. `App\Controller\Admin\AdminController` включает `$_auth_protection = true` и `$_auth_roles = ['admin']` для всей админки. @@ -544,7 +546,9 @@ throw HTTPException::factory(404, 'Страница :url не найдена', [ $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) diff --git a/Services/Auth/AuthDriver.php b/Services/Auth/AuthDriver.php index 7ec6489..9300294 100644 --- a/Services/Auth/AuthDriver.php +++ b/Services/Auth/AuthDriver.php @@ -25,6 +25,28 @@ interface AuthDriver */ 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; + /** * Разлогинивает пользователя (очищает состояние сессии). * diff --git a/Services/Auth/FileAuthDriver.php b/Services/Auth/FileAuthDriver.php index 5347e02..0c43fa9 100644 --- a/Services/Auth/FileAuthDriver.php +++ b/Services/Auth/FileAuthDriver.php @@ -9,6 +9,7 @@ namespace Services\Auth; use System\Classes\Config; +use System\Classes\Cookie; use System\Classes\Session; /** @@ -21,6 +22,12 @@ use System\Classes\Session; */ 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 Путь к файлу пользователей (если не передан — берётся из конфига) */ protected ?string $_users_file; @@ -52,13 +59,21 @@ class FileAuthDriver implements AuthDriver } /** - * Логин текущего авторизованного пользователя из сессии. + * Логин текущего авторизованного пользователя: из сессии, либо (если сессии + * нет) восстанавливается из cookie «запомнить меня» — тогда же выставляется + * обратно и в сессию, чтобы не проверять cookie на каждом обращении. * * @return string|null */ 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 { - $user = $this->users()[$login] ?? null; - - if ($user === null || !password_verify($password, $user['password'] ?? '')) { + if (!$this->verifyCredentials($login, $password)) { return false; } @@ -77,12 +90,81 @@ class FileAuthDriver implements AuthDriver 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 */ public function logout(): void { 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; } /** diff --git a/System/Classes/Controller.php b/System/Classes/Controller.php index fcb29eb..5de6c13 100644 --- a/System/Classes/Controller.php +++ b/System/Classes/Controller.php @@ -58,10 +58,10 @@ class Controller extends BaseController $user = Auth::instance($this->_auth_driver)->getUser(); if ($user === null) { - // getResponse() 302 сам делает Location + exit — throw здесь не подходит: - // неперехваченные исключения уходят в MyException::handler(), который - // getResponse() не вызывает и настоящий редирект не отправит. - HTTPException::factory(302, '/login')->getResponse(); + // Не исключительная ситуация — штатное поведение (гостя отправляем логиниться), + // поэтому обычный редирект, а не throw HTTPException: throw ушёл бы в + // MyException::handler() и отрендерил бы страницу ошибки вместо перехода на /login. + HTTP::redirect('/login'); } if ($this->_auth_roles && !in_array($user['role'] ?? null, $this->_auth_roles, true)) { diff --git a/System/Classes/HTTP/Exception/HTTPException_302.php b/System/Classes/HTTP/Exception/HTTPException_302.php deleted file mode 100644 index c45ebbf..0000000 --- a/System/Classes/HTTP/Exception/HTTPException_302.php +++ /dev/null @@ -1,33 +0,0 @@ -getMessage() ?: '/'; - HTTP::redirect($location, 302); - } -} diff --git a/schema.sql b/schema.sql index a0570d1..98f1efa 100644 --- a/schema.sql +++ b/schema.sql @@ -56,19 +56,24 @@ CREATE TABLE goals ( ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; CREATE TABLE categories ( - id INT UNSIGNED NOT NULL AUTO_INCREMENT, - user_id INT UNSIGNED NOT NULL, - parent_id INT UNSIGNED NULL, - title VARCHAR(128) NOT NULL, - type ENUM ('income', 'expense') NOT NULL, - is_delete TINYINT(1) NOT NULL DEFAULT 0, -- архивация вместо удаления, тот же приём что у accounts — сохраняет историю транзакций - `order` INT NOT NULL DEFAULT 0, + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id INT UNSIGNED NOT NULL, + parent_id INT UNSIGNED NULL, + title VARCHAR(128) NOT NULL, + type ENUM ('income', 'expense') NOT NULL, + is_delete TINYINT(1) NOT NULL DEFAULT 0, -- архивация вместо удаления, тот же приём что у accounts — сохраняет историю транзакций + `order` INT NOT NULL DEFAULT 0, + -- формульные статьи (см. бюджет_текущий_план.md): настраиваются на статье ДОХОДА, ссылаются на + -- привязанную статью РАСХОДА + дефолтный процент; у остальных категорий оба поля NULL + linked_expense_categorie_id INT UNSIGNED NULL, + formula_pct DECIMAL(5, 2) NULL, PRIMARY KEY (id), KEY ix_categories_user_id (user_id), KEY ix_categories_parent_id (parent_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; -- categorie_id NULL — обычный transfer (в т.ч. пополнение копилки/цели): деньги просто @@ -96,14 +101,38 @@ CREATE TABLE transactions ( CONSTRAINT fk_transactions_account_in FOREIGN KEY (account_in_id) REFERENCES accounts (id) ) 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 по категории за месяц. +-- plan_summ хранится всегда (и у формульных строк тоже) — у формульных пересчитывается и +-- перезаписывается автоматически при правке суммы/процента статьи-источника (кэш, как accounts.summa). CREATE TABLE budgets ( - id INT UNSIGNED NOT NULL AUTO_INCREMENT, - categorie_id INT UNSIGNED NOT NULL, - plan_summ DECIMAL(15, 2) NOT NULL, - year SMALLINT UNSIGNED NOT NULL, - month TINYINT UNSIGNED NOT NULL, - type ENUM ('income', 'expense') NOT NULL, + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + categorie_id INT UNSIGNED NOT NULL, + plan_summ DECIMAL(15, 2) NOT NULL, + year SMALLINT UNSIGNED NOT NULL, + month TINYINT UNSIGNED 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), UNIQUE KEY uq_budgets_categorie_period (categorie_id, year, month), CONSTRAINT fk_budgets_categorie FOREIGN KEY (categorie_id) REFERENCES categories (id)