Bicycle/App/Controller/TransactionsController.php
2026-08-15 19:40:29 +03:00

440 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description TransactionsController.php
* @copyright (c) 14/08/2026
*/
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, с категорией и счётом) + ручной ввод —
* 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
{
protected bool $_auth_protection = true;
/**
* @return string
* @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']);
if (!$user) {
throw HTTPException::factory(404);
}
return $user;
}
/**
* Операция по 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;
}
}