440 lines
17 KiB
PHP
440 lines
17 KiB
PHP
<?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;
|
||
}
|
||
}
|