This commit is contained in:
IsaevEA 2026-08-15 19:40:29 +03:00
parent 913e3eec38
commit c21555f18f
16 changed files with 1100 additions and 27 deletions

View File

@ -455,11 +455,14 @@ ALTER TABLE categories ADD CONSTRAINT fk_categories_linked_expense
**Ставка вводится вручную, без интеграции с банком** (это личный трекер, не агрегатор счетов) — **Ставка вводится вручную, без интеграции с банком** (это личный трекер, не агрегатор счетов) —
план, часть реализована (см. ниже): план, часть реализована (см. ниже):
- **При создании копилки** (форма счёта, `type_acc = savings`) — доп. поле «Ставка, % годовых». - **При создании копилки** (форма счёта, `type_acc = savings`) — поле «Ставка, % годовых»
Создаёт первую строку `account_rates`: `valid_from` = дата создания счёта (или явно введённая **обязательное, не опционально** — уточнено явно: у копилки/цели процент на остаток есть
пользователем), `valid_to = null`. **Реализовано** — `AccountsController::createAction()`, всегда, счёт `savings` без ставки не бывает (в отличие от `goal_target_summa`/`goal_deadline` —
`valid_from` = дата создания (сегодня), без отдельного поля даты в форме (ввод задним числом вот те действительно опциональная надстройка). Создаёт первую строку `account_rates`:
не обсуждали для самого первого открытия копилки — только для смены ставки ниже). `valid_from` = дата создания счёта, `valid_to = null`. **Реализовано** —
`AccountsController::createAction()` требует `rate_percent` при `type_acc = savings` (иначе
`422`), `valid_from` = дата создания (сегодня), без отдельного поля даты в форме (ввод задним
числом не обсуждали для самого первого открытия копилки — только для смены ставки ниже).
- **При изменении ставки** — отдельное действие на странице счёта (не правка задним числом старой - **При изменении ставки** — отдельное действие на странице счёта (не правка задним числом старой
строки — иначе теряется история для прошедших периодов). Пользователь вводит **и новый процент, и строки — иначе теряется история для прошедших периодов). Пользователь вводит **и новый процент, и
дату, с которой он начинает действовать** (`valid_from` — не обязательно «сегодня», банк мог дату, с которой он начинает действовать** (`valid_from` — не обязательно «сегодня», банк мог
@ -468,8 +471,19 @@ ALTER TABLE categories ADD CONSTRAINT fk_categories_linked_expense
`valid_from`), вставить новую (`valid_from` = введённая дата, `valid_to = null`, новый `valid_from`), вставить новую (`valid_from` = введённая дата, `valid_to = null`, новый
`rate_percent`) — одной транзакцией (`$repo->transaction(...)`). `rate_percent`) — одной транзакцией (`$repo->transaction(...)`).
- Метод под это — `AccountRateRepository::setRate(int $account_id, float $rate_percent, string $valid_from)`. - Метод под это — `AccountRateRepository::setRate(int $account_id, float $rate_percent, string $valid_from)`.
Сейчас в `AccountRateRepository` есть только `getCurrentRate()` (чтение, уже реализовано и **Реализовано** (2026-08-15) — ровно логика выше (закрыть текущую, открыть новую, одной
протестировано на реальной БД) — `setRate()` ещё не написан. транзакцией) плюс защита, которой в описании выше не было явно проговорено, но которую иначе
легко нарушить: `$valid_from` новой ставки должен быть строго позже `valid_from` текущей —
иначе закрываемая строка получила бы `valid_to` раньше собственного `valid_from` (невалидный
период задом наперёд). Нарушение → `MyException` (сообщение с обеими датами), транзакция
откатывается целиком — ни закрытия старой строки, ни новой записи. Тесты —
`tests/Unit/AccountRateRepositoryTest.php` (временная таблица, не боевая `account_rates`,
тот же приём, что у `BudgetRepositoryTest`/`CategoryRepositoryTest`): создание без текущей
ставки, закрытие+открытие, отказ на дате ≤ текущей `valid_from` (включая точное совпадение),
откат при отказе, независимость истории разных счетов. **Не сделано осознанно** — ещё нет
контроллер-эндпоинта/UI-кнопки «изменить ставку» на карточке копилки (само действие в разделе
`Goals` выше упомянуто, но отдельно от этой задачи не запрашивалось — метод самодостаточен и
протестирован сам по себе, вызывать его сейчас неоткуда).
- Прогноз «надо ≈X ₽/мес» у цели — **решено**: формула аннуитета со сложным процентом по текущей - Прогноз «надо ≈X ₽/мес» у цели — **решено**: формула аннуитета со сложным процентом по текущей
ставке, дневная капитализация (не линейно) — см. выше. ставке, дневная капитализация (не линейно) — см. выше.
@ -566,6 +580,44 @@ ALTER TABLE categories ADD CONSTRAINT fk_categories_linked_expense
Живой CRUD-флоу под `mikrit` в браузере — не проверен мной (нет учётных данных), маршруты Живой CRUD-флоу под `mikrit` в браузере — не проверен мной (нет учётных данных), маршруты
проверены curl (302 на /login без сессии, без 500). проверены 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 **Реальная БД и пользователь** (2026-08-12): `schema.sql` применена к боевой MariaDB
(`192.168.11.247:3306`, база `budget`, все таблицы были пустые на момент применения — пересоздавались (`192.168.11.247:3306`, база `budget`, все таблицы были пустые на момент применения — пересоздавались
без риска потери данных). Единственный реальный пользователь — `login=mikrit` (и в `Services\Auth`/ без риска потери данных). Единственный реальный пользователь — `login=mikrit` (и в `Services\Auth`/

View File

@ -53,6 +53,10 @@ class AccountsController extends Controller
$type_acc = $request->post('type_acc'); $type_acc = $request->post('type_acc');
$rate_percent = $request->post('rate_percent'); $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')); [$goal_target, $goal_deadline, $goal_error] = $this->parseGoal($type_acc, $request->post('goal_target_summa'), $request->post('goal_deadline'));
if ($goal_error !== null) { 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_repo->transaction(static function () use ($account_repo, $data, $type_acc, $rate_percent, $goal_target, $goal_deadline) {
$account_id = $account_repo->create($data); $account_id = $account_repo->create($data);
if ($type_acc === 'savings' && $rate_percent !== null && $rate_percent !== '') { if ($type_acc === 'savings') {
(new AccountRateRepository())->create([ (new AccountRateRepository())->create([
'account_id' => $account_id, 'account_id' => $account_id,
'rate_percent' => (float)$rate_percent, 'rate_percent' => (float)$rate_percent,
@ -124,6 +128,10 @@ class AccountsController extends Controller
return $this->json(['success' => false, 'errors' => ['summa' => 'Сумма не может быть отрицательной']], 422); 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')); [$goal_target, $goal_deadline, $goal_error] = $this->parseGoal($type_acc, $request->post('goal_target_summa'), $request->post('goal_deadline'));
if ($goal_error !== null) { if ($goal_error !== null) {

View File

@ -85,8 +85,7 @@ class IndexController extends Controller
$budget_repo->getYear($user->id, $year) $budget_repo->getYear($user->id, $year)
) : []; ) : [];
// Факт за месяц — реальные transactions (таблица/репозиторий уже есть, UI для их ввода // Факт за месяц — реальные transactions (ручной ввод — App\Controller\TransactionsController).
// пока нет, поэтому в живой БД сумма обычно 0 — это корректный факт, не заглушка).
$fact_month = $user ? (new TransactionRepository())->getMonthActualByCategory($user->id, $year, $month) : []; $fact_month = $user ? (new TransactionRepository())->getMonthActualByCategory($user->id, $year, $month) : [];
// % бюджета по расходам за месяц — план/факт только по expense-статьям (для плашки в шапке). // % бюджета по расходам за месяц — план/факт только по expense-статьям (для плашки в шапке).

View File

@ -8,15 +8,29 @@
namespace App\Controller; namespace App\Controller;
use App\Repositories\AccountRepository;
use App\Repositories\CategoryRepository;
use App\Repositories\TransactionHistoryRepository;
use App\Repositories\TransactionRepository; use App\Repositories\TransactionRepository;
use App\Repositories\UserRepository; use App\Repositories\UserRepository;
use Services\Auth; use Services\Auth;
use Services\CurrencyRate;
use System\Classes\Controller; use System\Classes\Controller;
use System\Classes\HTTP\HTTPException; use System\Classes\HTTP\HTTPException;
use System\Classes\MyException; 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 class TransactionsController extends Controller
{ {
@ -27,6 +41,371 @@ class TransactionsController extends Controller
* @throws HTTPException|MyException * @throws HTTPException|MyException
*/ */
public function indexAction(): string 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(); $auth_user = Auth::instance()->getUser();
$user = (new UserRepository())->getByLogin($auth_user['login']); $user = (new UserRepository())->getByLogin($auth_user['login']);
@ -35,11 +414,26 @@ class TransactionsController extends Controller
throw HTTPException::factory(404); throw HTTPException::factory(404);
} }
$transactions = (new TransactionRepository())->getRecentForUser($user->id, 50); return $user;
}
return $this->render('index', [ /**
'user' => $auth_user, * Операция по id, принадлежащая текущему пользователю — иначе 404 (не 403, как и у счетов/статей).
'transactions' => $transactions, *
]); * @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;
} }
} }

View File

@ -8,6 +8,8 @@
namespace App\Repositories; namespace App\Repositories;
use DateTimeImmutable;
use System\Classes\MyException;
use System\Classes\Repository; use System\Classes\Repository;
/** /**
@ -33,4 +35,44 @@ class AccountRateRepository extends Repository
{ {
return $this->getItemWhere("account_id = $account_id AND valid_to IS NULL"); 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,
]);
});
}
} }

View File

@ -22,4 +22,22 @@ class AccountRepository extends Repository
{ {
parent::__construct('accounts', connection: $connection); 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]);
}
} }

View File

@ -0,0 +1,45 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description TransactionHistoryRepository.php
* @copyright (c) 15/08/2026
*/
namespace App\Repositories;
use System\Classes\Repository;
/**
* Репозиторий таблицы transaction_history — лог правок/удалений операций (см.
* бюджет_текущий_план.md → Transactions → «Лог правок/удалений»). Только запись — само чтение
* лога/механизм отката не спроектированы, здесь только то, что нужно, чтобы лог начал
* наполняться вместе с CRUD транзакций (App\Controller\TransactionsController).
*/
class TransactionHistoryRepository extends Repository
{
/**
* @param string|null $connection Имя подключения (Database::instance($connection)); null — по умолчанию
*/
public function __construct(?string $connection = null)
{
parent::__construct('transaction_history', connection: $connection);
}
/**
* @param int $transaction_id
* @param int $user_id
* @param string $action create|edit|delete
* @param array|null $snapshot Полный слепок строки transactions после действия; null для delete
* @return void
*/
public function log(int $transaction_id, int $user_id, string $action, ?array $snapshot): void
{
$this->create([
'transaction_id' => $transaction_id,
'user_id' => $user_id,
'action' => $action,
'snapshot' => $snapshot,
]);
}
}

View File

@ -35,6 +35,7 @@ class TransactionRepository extends Repository
{ {
$limit = (int)$limit; $limit = (int)$limit;
$sql = "SELECT t.id, t.date, t.type, t.summa, t.currency, t.rate_to_rub, $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, c.title AS category_title,
af.title AS account_f_title, af.title AS account_f_title,
ai.title AS account_in_title ai.title AS account_in_title

View File

@ -149,7 +149,10 @@ body {
main { main {
max-width: 72rem; max-width: 72rem;
margin: 0 auto; 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; } .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-tile.selected .pm-check { display: flex; }
.pm-tiles-grid.locked .pm-tile { cursor: not-allowed; pointer-events: none; opacity: 0.55; }
.pm-tile-icon { .pm-tile-icon {
width: 34px; width: 34px;
height: 34px; height: 34px;

View File

@ -49,6 +49,7 @@ $(function () {
var isSavings = type === 'savings'; var isSavings = type === 'savings';
$rate.toggle(isSavings && !isEdit); $rate.toggle(isSavings && !isEdit);
$rate.find('[name=rate_percent]').prop('required', isSavings && !isEdit);
$goalFields.toggle(isSavings); $goalFields.toggle(isSavings);
if (!isEdit) { if (!isEdit) {
@ -65,6 +66,8 @@ $(function () {
$colorHidden.val(''); $colorHidden.val('');
setType('bank', false); setType('bank', false);
$form.find('[name=include_in_total][value="1"]').prop('checked', true); $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 () { $(document).on('click', '.js-account-add', function () {
@ -113,6 +116,8 @@ $(function () {
$form.find('[name=include_in_total][value="' + includeVal + '"]').prop('checked', true); $form.find('[name=include_in_total][value="' + includeVal + '"]').prop('checked', true);
setType($card.data('type'), true); setType($card.data('type'), true);
$('#acc-type-tiles').addClass('locked');
$('#acc-type-lock-hint').show();
$('#account-modal-title').text('Редактировать счёт'); $('#account-modal-title').text('Редактировать счёт');
accountModal.show(); accountModal.show();
@ -161,6 +166,10 @@ $(function () {
}); });
$(document).on('click', '#acc-type-tiles .pm-tile', function () { $(document).on('click', '#acc-type-tiles .pm-tile', function () {
if ($form.data('mode') === 'edit') {
return;
}
var type = $(this).data('type'); var type = $(this).data('type');
if (type === 'credit') { if (type === 'credit') {

View File

@ -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();
});

View File

@ -404,7 +404,7 @@ $renderBudgetList = static function (array $groups, array $tree, array $plan_mon
<div class="modal-body"> <div class="modal-body">
<div id="account-form-error" class="alert-error" style="display:none"></div> <div id="account-form-error" class="alert-error" style="display:none"></div>
<div class="dialog-section-label" style="margin-top:0">Тип счёта</div> <div class="dialog-section-label" style="margin-top:0">Тип счёта <span id="acc-type-lock-hint" style="display:none;font-weight:400;text-transform:none;letter-spacing:normal;color:color-mix(in srgb, var(--ink) 55%, transparent)">— нельзя изменить после создания</span></div>
<div class="pm-tiles-grid" id="acc-type-tiles" style="grid-template-columns:1fr 1fr"> <div class="pm-tiles-grid" id="acc-type-tiles" style="grid-template-columns:1fr 1fr">
<div class="pm-tile selected" data-type="bank" data-color="oklch(0.80 0.18 90)" data-icon="ic-card"> <div class="pm-tile selected" data-type="bank" data-color="oklch(0.80 0.18 90)" data-icon="ic-card">
<span class="pm-check">✓</span> <span class="pm-check">✓</span>
@ -475,7 +475,7 @@ $renderBudgetList = static function (array $groups, array $tree, array $plan_mon
<div id="account-rate-field" style="display:none;margin-top:10px"> <div id="account-rate-field" style="display:none;margin-top:10px">
<div class="field" style="margin:0"> <div class="field" style="margin:0">
<label>Ставка, % годовых</label> <label>Ставка, % годовых *</label>
<input class="input" type="number" step="0.01" min="0" name="rate_percent" placeholder="0"> <input class="input" type="number" step="0.01" min="0" name="rate_percent" placeholder="0">
</div> </div>
</div> </div>

View File

@ -1,12 +1,18 @@
<?php <?php
/** /**
* @package Bicycle * @package Bicycle
* @description Transactions/index.html — список последних 50 транзакций. * @description Transactions/index.html — список последних 50 транзакций + модалка ручного
* ввода (создание/правка), см. App/Controller/TransactionsController.php,
* App/media/js/transactions.js.
* *
* @var array $user * @var array $user
* @var object[] $transactions * @var object[] $transactions
* @var object[] $accounts Активные счета пользователя (для селектов счёта списания/зачисления)
* @var object[] $categories Активные статьи пользователя (для селекта категории, income/expense)
*/ */
use System\Classes\CSRF;
$month_names_gen = [ $month_names_gen = [
1 => 'января', 2 => 'февраля', 3 => 'марта', 4 => 'апреля', 5 => 'мая', 6 => 'июня', 1 => 'января', 2 => 'февраля', 3 => 'марта', 4 => 'апреля', 5 => 'мая', 6 => 'июня',
7 => 'июля', 8 => 'августа', 9 => 'сентября', 10 => 'октября', 11 => 'ноября', 12 => 'декабря', 7 => 'июля', 8 => 'августа', 9 => 'сентября', 10 => 'октября', 11 => 'ноября', 12 => 'декабря',
@ -25,12 +31,56 @@ $type_signs = [
'expense' => '−', 'expense' => '−',
'transfer' => '', 'transfer' => '',
]; ];
$this->setScript('transactions.js');
?> ?>
<h1 style="margin:0 0 20px;font-size:24px">Транзакции</h1> <svg width="0" height="0" style="position:absolute">
<defs>
<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-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-arrow-down-circle" 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"/>
<path d="M12 8v8"/>
<path d="M9 13l3 3 3-3"/>
</symbol>
<symbol id="ic-arrow-up-circle" 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"/>
<path d="M12 16V8"/>
<path d="M9 11l3-3 3 3"/>
</symbol>
</defs>
</svg>
<div class="section-label" style="margin-top:0">
<span>Транзакции</span>
<div style="display:flex;gap:0.5rem">
<button type="button" class="btn-add js-tx-add" title="Добавить операцию"><svg class="ico"><use href="#ic-plus"/></svg></button>
</div>
</div>
<?php if (empty($transactions)): ?> <?php if (empty($transactions)): ?>
<div class="empty-state">Нет операций — транзакции появятся здесь после добавления</div> <div class="empty-state">Нет операций — нажмите «+», чтобы добавить первую</div>
<?php else: ?> <?php else: ?>
<div class="card elev-md pm-wrap" style="padding:6px 0"> <div class="card elev-md pm-wrap" style="padding:6px 0">
<?php foreach ($transactions as $tx): ?> <?php foreach ($transactions as $tx): ?>
@ -40,12 +90,23 @@ $type_signs = [
$amount = (float)$tx->summa * ((float)($tx->rate_to_rub ?? 1)); $amount = (float)$tx->summa * ((float)($tx->rate_to_rub ?? 1));
$color = $type_colors[$tx->type] ?? 'var(--muted)'; $color = $type_colors[$tx->type] ?? 'var(--muted)';
$sign = $type_signs[$tx->type] ?? ''; $sign = $type_signs[$tx->type] ?? '';
$category = $tx->category_title ?? ($tx->type === 'transfer' ? 'Перевод' : '—');
$account = $tx->account_f_title ?? $tx->account_in_title ?? ''; $title = match (true) {
$tx->type === 'transfer' => htmlspecialchars(($tx->account_f_title ?? '') . ' → ' . ($tx->account_in_title ?? ''), ENT_QUOTES, 'UTF-8'),
default => htmlspecialchars($tx->category_title ?? '—', ENT_QUOTES, 'UTF-8'),
};
$account = $tx->type === 'income' ? $tx->account_in_title : $tx->account_f_title;
?> ?>
<div class="stat-row"> <div class="stat-row"
data-id="<?= (int)$tx->id ?>"
data-type="<?= htmlspecialchars($tx->type, ENT_QUOTES, 'UTF-8') ?>"
data-date="<?= htmlspecialchars($tx->date, ENT_QUOTES, 'UTF-8') ?>"
data-summa="<?= htmlspecialchars((string)$tx->summa, ENT_QUOTES, 'UTF-8') ?>"
data-categorie-id="<?= (int)($tx->categorie_id ?? 0) ?>"
data-account-f-id="<?= (int)($tx->account_f_id ?? 0) ?>"
data-account-in-id="<?= (int)($tx->account_in_id ?? 0) ?>">
<div style="width:auto;flex:1"> <div style="width:auto;flex:1">
<div style="font-size:13px;font-weight:600"><?= htmlspecialchars($category, ENT_QUOTES, 'UTF-8') ?></div> <div style="font-size:13px;font-weight:600"><?= $title ?></div>
<div class="card-meta" style="font-size:11px;margin-top:2px"> <div class="card-meta" style="font-size:11px;margin-top:2px">
<?= htmlspecialchars($dateLabel, ENT_QUOTES, 'UTF-8') ?> <?= htmlspecialchars($dateLabel, ENT_QUOTES, 'UTF-8') ?>
<?php if ($account): ?> · <?= htmlspecialchars($account, ENT_QUOTES, 'UTF-8') ?><?php endif; ?> <?php if ($account): ?> · <?= htmlspecialchars($account, ENT_QUOTES, 'UTF-8') ?><?php endif; ?>
@ -54,7 +115,106 @@ $type_signs = [
<div class="num" style="width:auto;flex-shrink:0;font-size:14px;font-weight:600;color:<?= $color ?>"> <div class="num" style="width:auto;flex-shrink:0;font-size:14px;font-weight:600;color:<?= $color ?>">
<?= $sign . number_format($amount, 0, ',', ' ') ?> ₽ <?= $sign . number_format($amount, 0, ',', ' ') ?> ₽
</div> </div>
<div class="room-menu" style="flex-shrink:0;margin-left:6px">
<button type="button" class="room-menu-btn">
<svg class="ico"><use href="#ic-dots"/></svg>
</button>
<div class="room-menu-list">
<a href="#" class="js-tx-edit"><svg class="ico ico-sm"><use href="#ic-pencil"/></svg>Редактировать</a>
<a href="#" class="danger js-tx-delete"><svg class="ico ico-sm"><use href="#ic-trash"/></svg>Удалить</a>
</div>
</div>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="modal fade" id="tx-modal" tabindex="-1" data-bs-theme="dark">
<div class="modal-dialog modal-dialog-scrollable" style="max-width:480px">
<div class="modal-content" style="background:var(--surface);color:var(--ink);border-radius:var(--radius-lg)">
<div class="modal-header" style="border-color:var(--surface-3);align-items:flex-start">
<div style="display:flex;gap:12px;align-items:flex-start">
<span class="dialog-icon"><svg class="ico" style="width:1.3em;height:1.3em"><use href="#ic-swap"/></svg></span>
<div>
<h5 class="modal-title" id="tx-modal-title">Новая операция</h5>
<div class="dialog-sub">Доход, расход или перевод между счетами</div>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<form id="tx-form" method="post">
<?= CSRF::field() ?>
<input type="hidden" name="id">
<div class="modal-body">
<div id="tx-form-error" class="alert-error" style="display:none"></div>
<div class="dialog-section-label" style="margin-top:0">Тип операции</div>
<div class="pm-tiles-grid" id="tx-type-tiles" style="grid-template-columns:1fr 1fr 1fr">
<div class="pm-tile selected" data-type="expense">
<span class="pm-check">✓</span>
<span class="pm-tile-icon" style="background:color-mix(in srgb,var(--red) 20%,transparent);color:var(--red)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-arrow-up-circle"/></svg></span>
<div><div class="pm-tile-title">Расход</div></div>
</div>
<div class="pm-tile" data-type="income">
<span class="pm-check">✓</span>
<span class="pm-tile-icon" style="background:color-mix(in srgb,var(--positive) 20%,transparent);color:var(--positive)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-arrow-down-circle"/></svg></span>
<div><div class="pm-tile-title">Доход</div></div>
</div>
<div class="pm-tile" data-type="transfer">
<span class="pm-check">✓</span>
<span class="pm-tile-icon" style="background:color-mix(in srgb,var(--blue) 20%,transparent);color:var(--blue)"><svg class="ico" style="width:1.1em;height:1.1em"><use href="#ic-swap"/></svg></span>
<div><div class="pm-tile-title">Перевод</div></div>
</div>
</div>
<input type="hidden" name="type" id="tx-type-hidden" value="expense">
<div class="field" id="tx-category-field" style="margin-top:10px">
<label>Статья</label>
<select class="input" name="categorie_id" id="tx-category">
<option value="">— выберите статью —</option>
<?php foreach ($categories as $c): ?>
<option value="<?= (int)$c->id ?>" data-type="<?= htmlspecialchars($c->type, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($c->title, ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="display:flex;gap:10px;margin-top:10px">
<div class="field" style="flex:1;margin:0">
<label>Дата</label>
<input class="input" type="date" name="date" required>
</div>
<div class="field" style="flex:1;margin:0">
<label>Сумма</label>
<input class="input" type="number" step="0.01" min="0.01" name="summa" required>
</div>
</div>
<div class="field" id="tx-account-f-field" style="margin-top:10px">
<label>Счёт списания</label>
<select class="input" name="account_f_id" id="tx-account-f">
<option value="">— выберите счёт —</option>
<?php foreach ($accounts as $a): ?>
<option value="<?= (int)$a->id ?>"><?= htmlspecialchars($a->title, ENT_QUOTES, 'UTF-8') ?> (<?= htmlspecialchars($a->currency, ENT_QUOTES, 'UTF-8') ?>)</option>
<?php endforeach; ?>
</select>
</div>
<div class="field" id="tx-account-in-field" style="margin-top:10px">
<label>Счёт зачисления</label>
<select class="input" name="account_in_id" id="tx-account-in">
<option value="">— выберите счёт —</option>
<?php foreach ($accounts as $a): ?>
<option value="<?= (int)$a->id ?>"><?= htmlspecialchars($a->title, ENT_QUOTES, 'UTF-8') ?> (<?= htmlspecialchars($a->currency, ENT_QUOTES, 'UTF-8') ?>)</option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="modal-footer" style="border-color:var(--surface-3)">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
</div>

View File

@ -70,14 +70,27 @@ class Client
} }
} }
$rates = $this->fetch(); // Недавний неудачный fetch() (сеть/ЦБ недоступны) — не бьём в сеть по второму разу на
// каждый следующий запрос страницы, а сразу идём в фолбэк на протухший кэш/пустоту.
// Без этого маркера каждый заход на дашборд/операцию с не-рублёвым счётом ждал бы полный
// timeout (по умолчанию 10 сек, App/config/config.php) при недоступном ЦБ РФ.
$fail_path = $cache_path . '.failed';
$fail_cooldown = (int)($this->config['fail_cooldown'] ?? 300);
$recent_failure = is_file($fail_path) && (time() - filemtime($fail_path)) < $fail_cooldown;
$rates = $recent_failure ? null : $this->fetch();
if ($rates !== null) { if ($rates !== null) {
@file_put_contents($cache_path, json_encode($rates)); @file_put_contents($cache_path, json_encode($rates));
@unlink($fail_path);
return $this->rates = $rates; return $this->rates = $rates;
} }
if (!$recent_failure) {
@touch($fail_path);
}
// Сеть недоступна — отдаём протухший кэш, если он вообще есть, чем ничего. // Сеть недоступна — отдаём протухший кэш, если он вообще есть, чем ничего.
if (is_file($cache_path)) { if (is_file($cache_path)) {
$cached = json_decode((string)file_get_contents($cache_path), true); $cached = json_decode((string)file_get_contents($cache_path), true);

View File

@ -0,0 +1,147 @@
<?php
namespace Tests\Unit;
use App\Repositories\AccountRateRepository;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Services\Database;
use Services\DataBase\Classes\PdoConnection;
use Services\DataBase\Classes\Profiler;
use System\Classes\Config;
use System\Classes\MyException;
use System\Classes\Repository;
/**
* AccountRateRepository жёстко привязан к боевой таблице account_rates конструктором — для
* тестов подменяем её на временную (тот же приём, что и в BudgetRepositoryTest/CategoryRepositoryTest:
* прямой вызов Repository::__construct(), минуя AccountRateRepository::__construct()).
*/
class AccountRateRepositoryTestDouble extends AccountRateRepository
{
public function __construct(?string $connection = null)
{
Repository::__construct('account_rate_repository_test_rates', connection: $connection);
}
}
class AccountRateRepositoryTest extends TestCase
{
private PdoConnection $connection;
private AccountRateRepositoryTestDouble $repository;
protected function setUp(): void
{
$config = Config::get('db', 'default') ?? [];
$this->connection = new PdoConnection($config);
try {
$this->connection->pdo();
} catch (MyException $e) {
$this->markTestSkipped('Нет живого подключения к MariaDB (заполните App/config/config.local.php): ' . $e->getMessage());
}
$this->connection->exec('DROP TABLE IF EXISTS account_rate_repository_test_rates');
$this->connection->exec(
'CREATE TABLE account_rate_repository_test_rates (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
account_id INT UNSIGNED NOT NULL,
rate_percent DECIMAL(5,2) NOT NULL,
valid_from DATE NOT NULL,
valid_to DATE NULL
)'
);
$this->repository = new AccountRateRepositoryTestDouble();
}
protected function tearDown(): void
{
Profiler::reset();
if (isset($this->connection)) {
try {
$this->connection->exec('DROP TABLE IF EXISTS account_rate_repository_test_rates');
} catch (MyException) {
}
}
// Database::instance() кэширует подключение статически — сбрасываем между тестами
$ref = new ReflectionClass(Database::class);
$prop = $ref->getProperty('_instances');
$prop->setAccessible(true);
$prop->setValue(null, []);
}
public function testSetRateWithNoCurrentRateJustCreates(): void
{
$this->repository->setRate(1, 5.5, '2026-01-01');
$current = $this->repository->getCurrentRate(1);
$this->assertNotFalse($current);
$this->assertSame('5.50', $current->rate_percent);
$this->assertSame('2026-01-01', $current->valid_from);
$this->assertNull($current->valid_to);
}
public function testSetRateClosesPreviousAndOpensNew(): void
{
$this->repository->setRate(1, 5.5, '2026-01-01');
$this->repository->setRate(1, 6.0, '2026-03-01');
$rows = $this->repository->getList('*', ['account_id' => 1], 'id');
$this->assertCount(2, $rows);
$this->assertSame('2026-01-01', $rows[0]->valid_from);
$this->assertSame('2026-02-28', $rows[0]->valid_to);
$this->assertSame('2026-03-01', $rows[1]->valid_from);
$this->assertNull($rows[1]->valid_to);
$current = $this->repository->getCurrentRate(1);
$this->assertSame('6.00', $current->rate_percent);
}
public function testSetRateRejectsDateNotAfterCurrentValidFrom(): void
{
$this->repository->setRate(1, 5.5, '2026-03-01');
$this->expectException(MyException::class);
$this->repository->setRate(1, 6.0, '2026-03-01');
}
public function testSetRateRejectsDateBeforeCurrentValidFrom(): void
{
$this->repository->setRate(1, 5.5, '2026-03-01');
$this->expectException(MyException::class);
$this->repository->setRate(1, 6.0, '2026-01-01');
}
public function testSetRateIsolatedPerAccount(): void
{
$this->repository->setRate(1, 5.5, '2026-01-01');
$this->repository->setRate(2, 4.0, '2026-01-01');
$this->assertSame('5.50', $this->repository->getCurrentRate(1)->rate_percent);
$this->assertSame('4.00', $this->repository->getCurrentRate(2)->rate_percent);
}
public function testSetRateRollsBackOnRejectedDate(): void
{
$this->repository->setRate(1, 5.5, '2026-03-01');
try {
$this->repository->setRate(1, 6.0, '2026-01-01');
} catch (MyException) {
}
// Отклонённая правка не должна была ни закрыть текущую строку, ни создать новую.
$rows = $this->repository->getList('*', ['account_id' => 1], 'id');
$this->assertCount(1, $rows);
$this->assertNull($rows[0]->valid_to);
}
}

View File

@ -24,6 +24,10 @@ class CurrencyRateClientTest extends TestCase
if (is_file($this->cache_path)) { if (is_file($this->cache_path)) {
unlink($this->cache_path); unlink($this->cache_path);
} }
if (is_file($this->cache_path . '.failed')) {
unlink($this->cache_path . '.failed');
}
} }
public function testRubAlwaysReturnsOne(): void public function testRubAlwaysReturnsOne(): void
@ -59,4 +63,54 @@ class CurrencyRateClientTest extends TestCase
$this->assertNull($client->rate('XXX')); $this->assertNull($client->rate('XXX'));
} }
/**
* Неудачный fetch() (сеть/ЦБ недоступны) должен оставить .failed-маркер — см. Client::rates().
* fetch() замокан, а не реально бьёт в сеть: тайминг на "недоступном" хосте ненадёжен для
* теста (DNS-отказ мгновенный, а реальный таймаут — секунды, в зависимости от окружения).
*/
public function testFailedFetchWritesFailureMarker(): void
{
$client = $this->getMockBuilder(Client::class)
->setConstructorArgs([['cache_path' => $this->cache_path]])
->onlyMethods(['fetch'])
->getMock();
$client->method('fetch')->willReturn(null);
$client->rate('EUR');
$this->assertFileExists($this->cache_path . '.failed');
}
/**
* Без этого кулдауна каждый следующий запрос (например, каждая загрузка дашборда, пока ЦБ
* недоступен) заново ждал бы полный timeout — см. Client::rates(). Проверяем не временем
* (ненадёжно), а тем, что fetch() вообще не вызывается повторно, пока действует недавний
* .failed-маркер.
*/
public function testFailureCooldownSkipsFetch(): void
{
touch($this->cache_path . '.failed');
$client = $this->getMockBuilder(Client::class)
->setConstructorArgs([['cache_path' => $this->cache_path, 'fail_cooldown' => 300]])
->onlyMethods(['fetch'])
->getMock();
$client->expects($this->never())->method('fetch');
$this->assertNull($client->rate('EUR'));
}
public function testFetchIsRetriedAfterCooldownExpires(): void
{
touch($this->cache_path . '.failed', time() - 10);
$client = $this->getMockBuilder(Client::class)
->setConstructorArgs([['cache_path' => $this->cache_path, 'fail_cooldown' => 5]])
->onlyMethods(['fetch'])
->getMock();
$client->expects($this->once())->method('fetch')->willReturn(null);
$this->assertNull($client->rate('EUR'));
}
} }