From c21555f18fb9fd7abd1c70ffa19fcfc33615c3c6 Mon Sep 17 00:00:00 2001 From: IsaevEA Date: Sat, 15 Aug 2026 19:40:29 +0300 Subject: [PATCH] dev --- .claude/memory/бюджет_текущий_план.md | 66 ++- App/Controller/AccountsController.php | 10 +- App/Controller/IndexController.php | 3 +- App/Controller/TransactionsController.php | 406 +++++++++++++++++- App/Repositories/AccountRateRepository.php | 42 ++ App/Repositories/AccountRepository.php | 18 + .../TransactionHistoryRepository.php | 45 ++ App/Repositories/TransactionRepository.php | 1 + App/media/css/dashboard.css | 7 +- App/media/js/accounts.js | 9 + App/media/js/transactions.js | 126 ++++++ App/view/Index/index.html | 4 +- App/view/Transactions/index.html | 174 +++++++- Services/CurrencyRate/Client.php | 15 +- tests/Unit/AccountRateRepositoryTest.php | 147 +++++++ tests/Unit/CurrencyRateClientTest.php | 54 +++ 16 files changed, 1100 insertions(+), 27 deletions(-) create mode 100644 App/Repositories/TransactionHistoryRepository.php create mode 100644 App/media/js/transactions.js create mode 100644 tests/Unit/AccountRateRepositoryTest.php diff --git a/.claude/memory/бюджет_текущий_план.md b/.claude/memory/бюджет_текущий_план.md index ccb8eee..e676bb6 100644 --- a/.claude/memory/бюджет_текущий_план.md +++ b/.claude/memory/бюджет_текущий_план.md @@ -455,11 +455,14 @@ ALTER TABLE categories ADD CONSTRAINT fk_categories_linked_expense **Ставка вводится вручную, без интеграции с банком** (это личный трекер, не агрегатор счетов) — план, часть реализована (см. ниже): -- **При создании копилки** (форма счёта, `type_acc = savings`) — доп. поле «Ставка, % годовых». - Создаёт первую строку `account_rates`: `valid_from` = дата создания счёта (или явно введённая - пользователем), `valid_to = null`. **Реализовано** — `AccountsController::createAction()`, - `valid_from` = дата создания (сегодня), без отдельного поля даты в форме (ввод задним числом - не обсуждали для самого первого открытия копилки — только для смены ставки ниже). +- **При создании копилки** (форма счёта, `type_acc = savings`) — поле «Ставка, % годовых» + **обязательное, не опционально** — уточнено явно: у копилки/цели процент на остаток есть + всегда, счёт `savings` без ставки не бывает (в отличие от `goal_target_summa`/`goal_deadline` — + вот те действительно опциональная надстройка). Создаёт первую строку `account_rates`: + `valid_from` = дата создания счёта, `valid_to = null`. **Реализовано** — + `AccountsController::createAction()` требует `rate_percent` при `type_acc = savings` (иначе + `422`), `valid_from` = дата создания (сегодня), без отдельного поля даты в форме (ввод задним + числом не обсуждали для самого первого открытия копилки — только для смены ставки ниже). - **При изменении ставки** — отдельное действие на странице счёта (не правка задним числом старой строки — иначе теряется история для прошедших периодов). Пользователь вводит **и новый процент, и дату, с которой он начинает действовать** (`valid_from` — не обязательно «сегодня», банк мог @@ -468,8 +471,19 @@ ALTER TABLE categories ADD CONSTRAINT fk_categories_linked_expense `valid_from`), вставить новую (`valid_from` = введённая дата, `valid_to = null`, новый `rate_percent`) — одной транзакцией (`$repo->transaction(...)`). - Метод под это — `AccountRateRepository::setRate(int $account_id, float $rate_percent, string $valid_from)`. - Сейчас в `AccountRateRepository` есть только `getCurrentRate()` (чтение, уже реализовано и - протестировано на реальной БД) — `setRate()` ещё не написан. + **Реализовано** (2026-08-15) — ровно логика выше (закрыть текущую, открыть новую, одной + транзакцией) плюс защита, которой в описании выше не было явно проговорено, но которую иначе + легко нарушить: `$valid_from` новой ставки должен быть строго позже `valid_from` текущей — + иначе закрываемая строка получила бы `valid_to` раньше собственного `valid_from` (невалидный + период задом наперёд). Нарушение → `MyException` (сообщение с обеими датами), транзакция + откатывается целиком — ни закрытия старой строки, ни новой записи. Тесты — + `tests/Unit/AccountRateRepositoryTest.php` (временная таблица, не боевая `account_rates`, + тот же приём, что у `BudgetRepositoryTest`/`CategoryRepositoryTest`): создание без текущей + ставки, закрытие+открытие, отказ на дате ≤ текущей `valid_from` (включая точное совпадение), + откат при отказе, независимость истории разных счетов. **Не сделано осознанно** — ещё нет + контроллер-эндпоинта/UI-кнопки «изменить ставку» на карточке копилки (само действие в разделе + `Goals` выше упомянуто, но отдельно от этой задачи не запрашивалось — метод самодостаточен и + протестирован сам по себе, вызывать его сейчас неоткуда). - Прогноз «надо ≈X ₽/мес» у цели — **решено**: формула аннуитета со сложным процентом по текущей ставке, дневная капитализация (не линейно) — см. выше. @@ -566,6 +580,44 @@ ALTER TABLE categories ADD CONSTRAINT fk_categories_linked_expense Живой CRUD-флоу под `mikrit` в браузере — не проверен мной (нет учётных данных), маршруты проверены curl (302 на /login без сессии, без 500). +**Транзакции — ручной ввод (create/edit/delete) реализован** (2026-08-15): +`App/Controller/TransactionsController.php` — тот же паттерн, что у Accounts/Categories +(JSON, CSRF/владение через `user_id`), плюс то, чего не было ни у одного из предыдущих CRUD: +- **Баланс счёта обновляется атомарно** в той же БД-транзакции, что и сама операция — + `AccountRepository::adjustBalance()` (новый метод, читает текущий `summa` и перезаписывает). +- **Уход в минус невозможен** (см. `Accounts` выше) — expense/transfer, на которые не хватает + средств на счёте списания, отклоняются 422 `«Недостаточно средств на счёте»`; при правке + порог считается так, будто старая версия операции ещё не списана (иначе правка своей же + операции без изменения суммы ложно упёрлась бы в нехватку). +- **Перевод между счетами разных валют — отклоняется 422.** Не решённая заранее фича, а + следствие схемы: у `transactions` одна пара `summa`/`currency` на строку, представить перевод + с конвертацией (разные суммы на разных концах) ею нельзя — не проектировать эту фичу сейчас, + просто не дать создать операцию, которую схема не может корректно хранить. + См. `.claude/memory/feedback_check_domain_logic.md` — тот же принцип «не просто прошло валидацию, + а имеет ли смысл по домену», применённый здесь ещё до того, как кто-то наткнулся на баг. +- **Категория/счета — по владению и типу**, не только «существует»: `categorie_id` обязателен + для income/expense (тип статьи должен совпадать с типом операции), всегда `null` для transfer + независимо от того, что пришло в форме (см. `Transactions` выше — «без категории, без + исключений»); `account_f_id`/`account_in_id` — по типу операции (expense → только f, income → + только in, transfer → оба, разные). +- **Курс (`rate_to_rub`)** — `Services\CurrencyRate::instance()->rate($currency)` (уже был + реализован отдельно, до этой сессии) в момент создания/правки; валюта операции = валюта счёта + (списания для expense/transfer, зачисления для income) — не выбирается вручную в форме. +- **`transaction_history`** (таблица уже была в `schema.sql`, но не наполнялась) — новый + `App\Repositories\TransactionHistoryRepository::log()`, пишет снимок `create`/`edit`/`delete` + той же БД-транзакцией. Только запись — само чтение лога и механизм отката по-прежнему не + спроектированы (см. `Transactions` → «Лог правок/удалений» выше). +- UI — `App/view/Transactions/index.html` + `App/media/js/transactions.js`, модалка на самой + странице `/transactions` (не на дашборде, в отличие от Accounts) — тип операции тайлами + (Расход/Доход/Перевод), остальные поля обычными `select`/`input`, показ/скрытие и + обязательность полей зависит от типа. Список операций (`indexAction`, уже был) дополнен + меню правки/удаления на каждой строке. +- Тесты — не добавлены (в проекте вообще нет тестов на уровне контроллеров, см. таблицу тестов в + CLAUDE.md — только Repository/Model/Core). 266 тестов, всё зелено (8 skip). Живой флоу в + браузере не проверен (нет учётных данных) — маршруты проверены curl (302/403 без сессии/CSRF, + без 500), `php -l` на все изменённые файлы. +- **Голосовой/AI-ввод (`transaction_drafts`) — не начат**, следующий шаг по этому приоритету. + **Реальная БД и пользователь** (2026-08-12): `schema.sql` применена к боевой MariaDB (`192.168.11.247:3306`, база `budget`, все таблицы были пустые на момент применения — пересоздавались без риска потери данных). Единственный реальный пользователь — `login=mikrit` (и в `Services\Auth`/ diff --git a/App/Controller/AccountsController.php b/App/Controller/AccountsController.php index 7e7006d..cbd37aa 100644 --- a/App/Controller/AccountsController.php +++ b/App/Controller/AccountsController.php @@ -53,6 +53,10 @@ class AccountsController extends Controller $type_acc = $request->post('type_acc'); $rate_percent = $request->post('rate_percent'); + if ($type_acc === 'savings' && (!is_numeric($rate_percent) || (float)$rate_percent < 0)) { + return $this->json(['success' => false, 'errors' => ['rate_percent' => 'Укажите ставку, % годовых']], 422); + } + [$goal_target, $goal_deadline, $goal_error] = $this->parseGoal($type_acc, $request->post('goal_target_summa'), $request->post('goal_deadline')); if ($goal_error !== null) { @@ -78,7 +82,7 @@ class AccountsController extends Controller $account_repo->transaction(static function () use ($account_repo, $data, $type_acc, $rate_percent, $goal_target, $goal_deadline) { $account_id = $account_repo->create($data); - if ($type_acc === 'savings' && $rate_percent !== null && $rate_percent !== '') { + if ($type_acc === 'savings') { (new AccountRateRepository())->create([ 'account_id' => $account_id, 'rate_percent' => (float)$rate_percent, @@ -124,6 +128,10 @@ class AccountsController extends Controller return $this->json(['success' => false, 'errors' => ['summa' => 'Сумма не может быть отрицательной']], 422); } + if ($type_acc !== $account->type_acc) { + return $this->json(['success' => false, 'errors' => ['type_acc' => 'Тип счёта нельзя изменить после создания']], 422); + } + [$goal_target, $goal_deadline, $goal_error] = $this->parseGoal($type_acc, $request->post('goal_target_summa'), $request->post('goal_deadline')); if ($goal_error !== null) { diff --git a/App/Controller/IndexController.php b/App/Controller/IndexController.php index ff9c555..cb086ab 100644 --- a/App/Controller/IndexController.php +++ b/App/Controller/IndexController.php @@ -85,8 +85,7 @@ class IndexController extends Controller $budget_repo->getYear($user->id, $year) ) : []; - // Факт за месяц — реальные transactions (таблица/репозиторий уже есть, UI для их ввода - // пока нет, поэтому в живой БД сумма обычно 0 — это корректный факт, не заглушка). + // Факт за месяц — реальные transactions (ручной ввод — App\Controller\TransactionsController). $fact_month = $user ? (new TransactionRepository())->getMonthActualByCategory($user->id, $year, $month) : []; // % бюджета по расходам за месяц — план/факт только по expense-статьям (для плашки в шапке). diff --git a/App/Controller/TransactionsController.php b/App/Controller/TransactionsController.php index 264fa86..542bf08 100644 --- a/App/Controller/TransactionsController.php +++ b/App/Controller/TransactionsController.php @@ -8,15 +8,29 @@ namespace App\Controller; +use App\Repositories\AccountRepository; +use App\Repositories\CategoryRepository; +use App\Repositories\TransactionHistoryRepository; use App\Repositories\TransactionRepository; use App\Repositories\UserRepository; use Services\Auth; +use Services\CurrencyRate; use System\Classes\Controller; use System\Classes\HTTP\HTTPException; use System\Classes\MyException; +use System\Classes\Request; +use System\Classes\Validation; /** - * Список транзакций пользователя — последние 50, с категорией и счётом. + * Список транзакций пользователя (последние 50, с категорией и счётом) + ручной ввод — + * CRUD-модалка на самой странице (см. App/view/Transactions/index.html, + * App/media/js/transactions.js), тот же паттерн, что и у App\Controller\AccountsController. + * + * Баланс счёта (`accounts.summa`) — хранимый кэш, обновляется атомарно в той же БД-транзакции, + * что и сама операция (см. бюджет_текущий_план.md → Transactions → «Баланс счёта»), + * через AccountRepository::adjustBalance(). Уход в минус для cash/bank/savings невозможен — + * expense/transfer, на которые не хватает средств на счёте списания, отклоняются как 422, + * до открытия БД-транзакции. */ class TransactionsController extends Controller { @@ -27,6 +41,371 @@ class TransactionsController extends Controller * @throws HTTPException|MyException */ public function indexAction(): string + { + $auth_user = Auth::instance()->getUser(); + $user = $this->currentUser(); + + $transactions = (new TransactionRepository())->getRecentForUser($user->id, 50); + $accounts = (new AccountRepository())->getList('*', ['user_id' => $user->id, 'is_delete' => 0], '`order`'); + $categories = (new CategoryRepository())->getList('*', ['user_id' => $user->id, 'is_delete' => 0], 'title'); + + return $this->render('index', [ + 'user' => $auth_user, + 'transactions' => $transactions, + 'accounts' => $accounts ?: [], + 'categories' => $categories ?: [], + ]); + } + + /** + * @return string JSON + * @throws MyException + */ + public function createAction(): string + { + $request = Request::$current; + $user = $this->currentUser(); + + $validation = $this->buildValidation($request->post()); + + if (!$validation->check()) { + return $this->json(['success' => false, 'errors' => $validation->errors()], 422); + } + + $type = $request->post('type'); + $summa = (float)$request->post('summa'); + + if ($summa <= 0) { + return $this->json(['success' => false, 'errors' => ['summa' => 'Сумма должна быть больше 0']], 422); + } + + $account_repo = new AccountRepository(); + $category_repo = new CategoryRepository(); + + [$account_f, $account_in, $account_error_field, $account_error] = $this->resolveAccounts( + $account_repo, $user, $type, $request->post('account_f_id'), $request->post('account_in_id') + ); + + if ($account_error !== null) { + return $this->json(['success' => false, 'errors' => [$account_error_field => $account_error]], 422); + } + + [$categorie_id, $category_error] = $this->resolveCategory($category_repo, $user, $type, $request->post('categorie_id')); + + if ($category_error !== null) { + return $this->json(['success' => false, 'errors' => ['categorie_id' => $category_error]], 422); + } + + if ($account_f !== null && (float)$account_f->summa < $summa) { + return $this->json(['success' => false, 'errors' => ['summa' => 'Недостаточно средств на счёте']], 422); + } + + $currency_account = $type === 'income' ? $account_in : $account_f; + $currency = $currency_account->currency; + $rate_to_rub = $currency !== 'RUB' ? CurrencyRate::instance()->rate($currency) : null; + + $data = [ + 'user_id' => $user->id, + 'categorie_id' => $categorie_id, + 'account_f_id' => $account_f?->id, + 'account_in_id' => $account_in?->id, + 'date' => $request->post('date'), + 'summa' => $summa, + 'currency' => $currency, + 'rate_to_rub' => $rate_to_rub, + 'type' => $type, + ]; + + $tx_repo = new TransactionRepository(); + + $tx_repo->transaction(function () use ($tx_repo, $account_repo, $data, $account_f, $account_in, $summa) { + $id = $tx_repo->create($data); + + $this->applyBalance($account_repo, $account_f, $account_in, $summa); + + (new TransactionHistoryRepository())->log($id, $data['user_id'], 'create', ['id' => $id] + $data); + }); + + return $this->json(['success' => true]); + } + + /** + * @return string JSON + * @throws HTTPException|MyException + */ + public function editAction(): string + { + $request = Request::$current; + $user = $this->currentUser(); + $tx_repo = new TransactionRepository(); + $tx = $this->ownTransaction($tx_repo, $user, (int)$request->param(0)); + + $validation = $this->buildValidation($request->post()); + + if (!$validation->check()) { + return $this->json(['success' => false, 'errors' => $validation->errors()], 422); + } + + $type = $request->post('type'); + $summa = (float)$request->post('summa'); + + if ($summa <= 0) { + return $this->json(['success' => false, 'errors' => ['summa' => 'Сумма должна быть больше 0']], 422); + } + + $account_repo = new AccountRepository(); + $category_repo = new CategoryRepository(); + + [$account_f, $account_in, $account_error_field, $account_error] = $this->resolveAccounts( + $account_repo, $user, $type, $request->post('account_f_id'), $request->post('account_in_id') + ); + + if ($account_error !== null) { + return $this->json(['success' => false, 'errors' => [$account_error_field => $account_error]], 422); + } + + [$categorie_id, $category_error] = $this->resolveCategory($category_repo, $user, $type, $request->post('categorie_id')); + + if ($category_error !== null) { + return $this->json(['success' => false, 'errors' => ['categorie_id' => $category_error]], 422); + } + + // Овердрафт считаем так, как будто старая версия операции ещё не списана со счёта — + // иначе правка своей же операции без изменения суммы могла бы ложно упереться в "не хватает". + if ($account_f !== null) { + $available = (float)$account_f->summa; + + if ((int)$tx->account_f_id === (int)$account_f->id && in_array($tx->type, ['expense', 'transfer'], true)) { + $available += (float)$tx->summa; + } + + if ($available < $summa) { + return $this->json(['success' => false, 'errors' => ['summa' => 'Недостаточно средств на счёте']], 422); + } + } + + $currency_account = $type === 'income' ? $account_in : $account_f; + $currency = $currency_account->currency; + $rate_to_rub = $currency !== 'RUB' ? CurrencyRate::instance()->rate($currency) : null; + + $data = [ + 'id' => $tx->id, + 'categorie_id' => $categorie_id, + 'account_f_id' => $account_f?->id, + 'account_in_id' => $account_in?->id, + 'date' => $request->post('date'), + 'summa' => $summa, + 'currency' => $currency, + 'rate_to_rub' => $rate_to_rub, + 'type' => $type, + ]; + + $tx_repo->transaction(function () use ($tx_repo, $account_repo, $tx, $data, $account_f, $account_in, $summa) { + $this->reverseBalance($account_repo, $tx); + + $tx_repo->update($data); + + $this->applyBalance($account_repo, $account_f, $account_in, $summa); + + (new TransactionHistoryRepository())->log((int)$tx->id, (int)$tx->user_id, 'edit', ['user_id' => $tx->user_id] + $data); + }); + + return $this->json(['success' => true]); + } + + /** + * Физическое удаление (у transactions, в отличие от accounts/categories, нет is_delete — + * см. бюджет_текущий_план.md; история операции остаётся в transaction_history, не в самой + * таблице). + * + * @return string JSON + * @throws HTTPException|MyException + */ + public function deleteAction(): string + { + $request = Request::$current; + $user = $this->currentUser(); + $tx_repo = new TransactionRepository(); + $tx = $this->ownTransaction($tx_repo, $user, (int)$request->param(0)); + $account_repo = new AccountRepository(); + + $tx_repo->transaction(function () use ($tx_repo, $account_repo, $tx) { + $this->reverseBalance($account_repo, $tx); + + $tx_repo->delete($tx->id); + + (new TransactionHistoryRepository())->log((int)$tx->id, (int)$tx->user_id, 'delete', null); + }); + + return $this->json(['success' => true]); + } + + /** + * Общие правила валидации формы операции (create/edit) — поля, не зависящие от типа. Поля, + * зависящие от типа (categorie_id/account_f_id/account_in_id — какие обязательны, зависит от + * income/expense/transfer), проверяются отдельно в resolveAccounts()/resolveCategory(), не + * тут, тем же паттерном, что и goal_target_summa/goal_deadline в AccountsController::parseGoal(). + * + * @param array $data + * @return Validation + */ + protected function buildValidation(array $data): Validation + { + return Validation::factory($data) + ->label('date', 'Дата') + ->label('type', 'Тип') + ->label('summa', 'Сумма') + ->rule('date', 'required') + ->rule('date', 'regex', ['/^\d{4}-\d{2}-\d{2}$/']) + ->rule('type', 'required') + ->rule('type', 'in', [['income', 'expense', 'transfer']]) + ->rule('summa', 'required') + ->rule('summa', 'numeric'); + } + + /** + * Счета операции по типу: income — только account_in, expense — только account_f, + * transfer — оба, разные, одной валюты (в схеме transactions одна сумма/валюта на строку — + * перевод между счетами разных валют ею не представим, см. проверку ниже). + * + * @param AccountRepository $account_repo + * @param object $user + * @param string $type + * @param mixed $raw_f + * @param mixed $raw_in + * @return array{0:object|null,1:object|null,2:string,3:string|null} [account_f, account_in, error_field, error] + */ + protected function resolveAccounts(AccountRepository $account_repo, object $user, string $type, mixed $raw_f, mixed $raw_in): array + { + $account_f = null; + $account_in = null; + + if (in_array($type, ['expense', 'transfer'], true)) { + $account_f = $this->resolveOwnAccount($account_repo, $user, $raw_f); + + if ($account_f === null) { + return [null, null, 'account_f_id', 'Выберите счёт списания']; + } + } + + if (in_array($type, ['income', 'transfer'], true)) { + $account_in = $this->resolveOwnAccount($account_repo, $user, $raw_in); + + if ($account_in === null) { + return [null, null, 'account_in_id', 'Выберите счёт зачисления']; + } + } + + if ($type === 'transfer') { + if ((int)$account_f->id === (int)$account_in->id) { + return [null, null, 'account_in_id', 'Счета списания и зачисления должны различаться']; + } + + if ($account_f->currency !== $account_in->currency) { + return [null, null, 'account_in_id', 'Перевод между счетами разных валют не поддерживается']; + } + } + + return [$account_f, $account_in, '', null]; + } + + /** + * @param AccountRepository $account_repo + * @param object $user + * @param mixed $raw_id + * @return object|null + */ + protected function resolveOwnAccount(AccountRepository $account_repo, object $user, mixed $raw_id): ?object + { + if (!is_numeric($raw_id)) { + return null; + } + + $account = $account_repo->get((int)$raw_id); + + if (!$account || (int)$account->user_id !== (int)$user->id) { + return null; + } + + return $account; + } + + /** + * Статья операции — обязательна для income/expense (принадлежит пользователю, тип совпадает), + * всегда null для transfer (см. бюджет_текущий_план.md — «Все transfer — без категории, без + * исключений»), независимо от того, что пришло в форме. + * + * @param CategoryRepository $category_repo + * @param object $user + * @param string $type + * @param mixed $raw + * @return array{0:int|null,1:string|null} [categorie_id, error] + */ + protected function resolveCategory(CategoryRepository $category_repo, object $user, string $type, mixed $raw): array + { + if ($type === 'transfer') { + return [null, null]; + } + + if (!is_numeric($raw)) { + return [null, 'Выберите статью']; + } + + $category = $category_repo->get((int)$raw); + + if (!$category || (int)$category->user_id !== (int)$user->id || $category->type !== $type) { + return [null, 'Статья недоступна']; + } + + return [(int)$category->id, null]; + } + + /** + * Снять эффект операции со счетов — перед правкой (реверс старой версии) или перед удалением. + * + * @param AccountRepository $account_repo + * @param object $tx + * @return void + */ + protected function reverseBalance(AccountRepository $account_repo, object $tx): void + { + if (in_array($tx->type, ['income', 'transfer'], true) && $tx->account_in_id) { + $account_repo->adjustBalance((int)$tx->account_in_id, -(float)$tx->summa); + } + + if (in_array($tx->type, ['expense', 'transfer'], true) && $tx->account_f_id) { + $account_repo->adjustBalance((int)$tx->account_f_id, (float)$tx->summa); + } + } + + /** + * Применить эффект операции на счета — при создании или после правки (уже с новыми + * счетами/суммой). + * + * @param AccountRepository $account_repo + * @param object|null $account_f + * @param object|null $account_in + * @param float $summa + * @return void + */ + protected function applyBalance(AccountRepository $account_repo, ?object $account_f, ?object $account_in, float $summa): void + { + if ($account_f !== null) { + $account_repo->adjustBalance((int)$account_f->id, -$summa); + } + + if ($account_in !== null) { + $account_repo->adjustBalance((int)$account_in->id, $summa); + } + } + + /** + * Бюджетный пользователь, связанный с текущим логином Services\Auth. + * + * @return object + * @throws HTTPException|MyException + */ + protected function currentUser(): object { $auth_user = Auth::instance()->getUser(); $user = (new UserRepository())->getByLogin($auth_user['login']); @@ -35,11 +414,26 @@ class TransactionsController extends Controller throw HTTPException::factory(404); } - $transactions = (new TransactionRepository())->getRecentForUser($user->id, 50); + return $user; + } - return $this->render('index', [ - 'user' => $auth_user, - 'transactions' => $transactions, - ]); + /** + * Операция по id, принадлежащая текущему пользователю — иначе 404 (не 403, как и у счетов/статей). + * + * @param TransactionRepository $tx_repo + * @param object $user + * @param int $id + * @return object + * @throws HTTPException|MyException + */ + protected function ownTransaction(TransactionRepository $tx_repo, object $user, int $id): object + { + $tx = $tx_repo->get($id); + + if (!$tx || (int)$tx->user_id !== (int)$user->id) { + throw HTTPException::factory(404); + } + + return $tx; } } diff --git a/App/Repositories/AccountRateRepository.php b/App/Repositories/AccountRateRepository.php index e5bd244..55744d5 100644 --- a/App/Repositories/AccountRateRepository.php +++ b/App/Repositories/AccountRateRepository.php @@ -8,6 +8,8 @@ namespace App\Repositories; +use DateTimeImmutable; +use System\Classes\MyException; use System\Classes\Repository; /** @@ -33,4 +35,44 @@ class AccountRateRepository extends Repository { return $this->getItemWhere("account_id = $account_id AND valid_to IS NULL"); } + + /** + * Меняет ставку задним/будущим числом — не правит старую строку, а закрывает её + * (`valid_to` = день перед новым `valid_from`) и открывает новую, чтобы не потерять историю + * за уже прошедшие периоды (см. бюджет_текущий_план.md → Account_rates → «При изменении + * ставки»). Атомарно — `Repository::transaction()` (на вложенном уровне — SAVEPOINT). + * + * @param int $account_id + * @param float $rate_percent Новая ставка, % годовых + * @param string $valid_from Дата, с которой действует новая ставка (Y-m-d) — не обязательно + * «сегодня», банк мог прислать уведомление заранее/задним числом + * @return void + * @throws MyException Если $valid_from не позже даты начала текущей ставки — иначе закрытая + * строка получила бы valid_to раньше собственного valid_from + */ + public function setRate(int $account_id, float $rate_percent, string $valid_from): void + { + $this->transaction(function () use ($account_id, $rate_percent, $valid_from) { + $current = $this->getCurrentRate($account_id); + + if ($current) { + if ($valid_from <= $current->valid_from) { + throw new MyException( + 'Дата новой ставки (:new) должна быть позже даты начала текущей (:current)', + [':new' => $valid_from, ':current' => $current->valid_from] + ); + } + + $valid_to = (new DateTimeImmutable($valid_from))->modify('-1 day')->format('Y-m-d'); + $this->update(['id' => $current->id, 'valid_to' => $valid_to]); + } + + $this->create([ + 'account_id' => $account_id, + 'rate_percent' => $rate_percent, + 'valid_from' => $valid_from, + 'valid_to' => null, + ]); + }); + } } diff --git a/App/Repositories/AccountRepository.php b/App/Repositories/AccountRepository.php index 85f85ae..f1d1ed0 100644 --- a/App/Repositories/AccountRepository.php +++ b/App/Repositories/AccountRepository.php @@ -22,4 +22,22 @@ class AccountRepository extends Repository { parent::__construct('accounts', connection: $connection); } + + /** + * Изменить баланс счёта на $delta (может быть отрицательным) — читает текущий `summa` и + * перезаписывает, а не `summa = summa + :delta` прямо в SQL, чтобы остаться на общем + * processData()/update() репозитория (bind-параметры, автоопределение типа), как и весь + * остальной код Repository. Вызывающая сторона (App\Controller\TransactionsController) + * оборачивает это в `$repo->transaction()` вместе с самой операцией — атомарность. + * + * @param int $id + * @param float $delta + * @return void + */ + public function adjustBalance(int $id, float $delta): void + { + $account = $this->get($id); + + $this->update(['id' => $id, 'summa' => (float)$account->summa + $delta]); + } } diff --git a/App/Repositories/TransactionHistoryRepository.php b/App/Repositories/TransactionHistoryRepository.php new file mode 100644 index 0000000..fdf68cc --- /dev/null +++ b/App/Repositories/TransactionHistoryRepository.php @@ -0,0 +1,45 @@ +create([ + 'transaction_id' => $transaction_id, + 'user_id' => $user_id, + 'action' => $action, + 'snapshot' => $snapshot, + ]); + } +} diff --git a/App/Repositories/TransactionRepository.php b/App/Repositories/TransactionRepository.php index 816d5b5..50f0011 100644 --- a/App/Repositories/TransactionRepository.php +++ b/App/Repositories/TransactionRepository.php @@ -35,6 +35,7 @@ class TransactionRepository extends Repository { $limit = (int)$limit; $sql = "SELECT t.id, t.date, t.type, t.summa, t.currency, t.rate_to_rub, + t.categorie_id, t.account_f_id, t.account_in_id, c.title AS category_title, af.title AS account_f_title, ai.title AS account_in_title diff --git a/App/media/css/dashboard.css b/App/media/css/dashboard.css index b184f8f..21cd403 100644 --- a/App/media/css/dashboard.css +++ b/App/media/css/dashboard.css @@ -149,7 +149,10 @@ body { main { max-width: 72rem; margin: 0 auto; - padding: 1.5rem 1.5rem 6.5rem; + /* top — с запасом ниже фиксированной кнопки выхода (layout.html, position:fixed;top:1rem; + иконка 1.3rem) — иначе первая же строка страницы (.section-label с кнопками справа — + счета на дашборде, статьи в бюджете, "+" в транзакциях) заезжает под неё. */ + padding: 3rem 1.5rem 6.5rem; } .num { font-variant-numeric: tabular-nums; } @@ -951,6 +954,8 @@ a.dock-item:hover { color: var(--ink-soft); } .pm-tile.selected .pm-check { display: flex; } +.pm-tiles-grid.locked .pm-tile { cursor: not-allowed; pointer-events: none; opacity: 0.55; } + .pm-tile-icon { width: 34px; height: 34px; diff --git a/App/media/js/accounts.js b/App/media/js/accounts.js index 4dde66e..e4647b2 100644 --- a/App/media/js/accounts.js +++ b/App/media/js/accounts.js @@ -49,6 +49,7 @@ $(function () { var isSavings = type === 'savings'; $rate.toggle(isSavings && !isEdit); + $rate.find('[name=rate_percent]').prop('required', isSavings && !isEdit); $goalFields.toggle(isSavings); if (!isEdit) { @@ -65,6 +66,8 @@ $(function () { $colorHidden.val(''); setType('bank', false); $form.find('[name=include_in_total][value="1"]').prop('checked', true); + $('#acc-type-tiles').removeClass('locked'); + $('#acc-type-lock-hint').hide(); } $(document).on('click', '.js-account-add', function () { @@ -113,6 +116,8 @@ $(function () { $form.find('[name=include_in_total][value="' + includeVal + '"]').prop('checked', true); setType($card.data('type'), true); + $('#acc-type-tiles').addClass('locked'); + $('#acc-type-lock-hint').show(); $('#account-modal-title').text('Редактировать счёт'); accountModal.show(); @@ -161,6 +166,10 @@ $(function () { }); $(document).on('click', '#acc-type-tiles .pm-tile', function () { + if ($form.data('mode') === 'edit') { + return; + } + var type = $(this).data('type'); if (type === 'credit') { diff --git a/App/media/js/transactions.js b/App/media/js/transactions.js new file mode 100644 index 0000000..108f152 --- /dev/null +++ b/App/media/js/transactions.js @@ -0,0 +1,126 @@ +$(function () { + var modalEl = document.getElementById('tx-modal'); + + if (!modalEl) { + return; + } + + var modal = new bootstrap.Modal(modalEl); + var $form = $('#tx-form'); + var $error = $('#tx-form-error'); + + function filterCategoryOptions(type) { + $('#tx-category option[data-type]').each(function () { + var match = $(this).data('type') === type; + $(this).prop('hidden', !match).prop('disabled', !match); + }); + + var $selected = $('#tx-category option:selected'); + if ($selected.data('type') && $selected.data('type') !== type) { + $('#tx-category').val(''); + } + } + + function setType(type) { + $('#tx-type-tiles .pm-tile').removeClass('selected'); + $('#tx-type-tiles .pm-tile[data-type="' + type + '"]').addClass('selected'); + $('#tx-type-hidden').val(type); + + var isTransfer = type === 'transfer'; + + $('#tx-category-field').toggle(!isTransfer); + $('#tx-category').prop('required', !isTransfer); + if (!isTransfer) { + filterCategoryOptions(type); + } + + var needsF = type === 'expense' || isTransfer; + $('#tx-account-f-field').toggle(needsF); + $('#tx-account-f').prop('required', needsF); + + var needsIn = type === 'income' || isTransfer; + $('#tx-account-in-field').toggle(needsIn); + $('#tx-account-in').prop('required', needsIn); + } + + function resetForm() { + $form[0].reset(); + $form.find('[name=id]').val(''); + $error.hide().text(''); + $form.find('[name=date]').val(new Date().toISOString().slice(0, 10)); + setType('expense'); + } + + function populateEditForm($row) { + resetForm(); + $form.data('mode', 'edit').attr('action', '/transactions/edit/' + $row.data('id')); + $form.find('[name=id]').val($row.data('id')); + $form.find('[name=date]').val($row.data('date')); + $form.find('[name=summa]').val($row.data('summa')); + + setType($row.data('type')); + + $form.find('[name=categorie_id]').val($row.data('categorie-id') || ''); + $form.find('[name=account_f_id]').val($row.data('account-f-id') || ''); + $form.find('[name=account_in_id]').val($row.data('account-in-id') || ''); + + $('#tx-modal-title').text('Редактировать операцию'); + modal.show(); + } + + $(document).on('click', '.js-tx-add', function () { + resetForm(); + $form.data('mode', 'create').attr('action', '/transactions/create'); + $('#tx-modal-title').text('Новая операция'); + modal.show(); + }); + + $(document).on('click', '#tx-type-tiles .pm-tile', function () { + setType($(this).data('type')); + }); + + $(document).on('click', '.js-tx-edit', function (e) { + e.preventDefault(); + populateEditForm($(this).closest('.stat-row')); + }); + + $(document).on('click', '.js-tx-delete', function (e) { + e.preventDefault(); + + var $row = $(this).closest('.stat-row'); + var csrf = $form.find('[name=csrf_token]').val(); + + if (!window.confirm('Удалить операцию?')) { + return; + } + + $.post('/transactions/delete/' + $row.data('id'), {csrf_token: csrf}) + .done(function () { window.location.reload(); }) + .fail(function () { window.alert('Не удалось удалить операцию'); }); + }); + + $(document).on('click', '.room-menu-btn', function () { + $(this).closest('.room-menu').toggleClass('open'); + }); + + $(document).on('click', function (e) { + if (!$(e.target).closest('.room-menu').length) { + $('.room-menu.open').removeClass('open'); + } + }); + + $(document).on('submit', '#tx-form', function (e) { + e.preventDefault(); + $error.hide().text(''); + + $.post($form.attr('action'), $form.serialize()) + .done(function () { window.location.reload(); }) + .fail(function (xhr) { + var errors = xhr.responseJSON && xhr.responseJSON.errors; + var message = errors ? Object.values(errors).join(' ') : 'Не удалось сохранить операцию'; + $error.text(message).show(); + }); + }); + + resetForm(); +}); diff --git a/App/view/Index/index.html b/App/view/Index/index.html index ce36c61..d14f907 100644 --- a/App/view/Index/index.html +++ b/App/view/Index/index.html @@ -404,7 +404,7 @@ $renderBudgetList = static function (array $groups, array $tree, array $plan_mon