332 lines
13 KiB
PHP
332 lines
13 KiB
PHP
<?php
|
||
/**
|
||
* @package Bicycle
|
||
* @author Egor Isaev
|
||
* @description AccountsController.php
|
||
* @copyright (c) 12/08/2026
|
||
*/
|
||
|
||
namespace App\Controller;
|
||
|
||
use App\Classes\Currency;
|
||
use App\Repositories\AccountRateRepository;
|
||
use App\Repositories\AccountRepository;
|
||
use App\Repositories\GoalRepository;
|
||
use App\Repositories\UserRepository;
|
||
use Services\Auth;
|
||
use System\Classes\Controller;
|
||
use System\Classes\HTTP\HTTPException;
|
||
use System\Classes\MyException;
|
||
use System\Classes\Request;
|
||
use System\Classes\Validation;
|
||
|
||
/**
|
||
* CRUD счетов (accounts) — создание/правка/архивация. Ответы — JSON, вызывается
|
||
* ajax'ом с модалки на дашборде (см. App/view/Index/index.html, App/media/js/accounts.js) —
|
||
* своей страницы у счетов нет, см. бюджет_текущий_план.md → Goals → "UI — на дашборде".
|
||
*/
|
||
class AccountsController extends Controller
|
||
{
|
||
protected bool $_auth_protection = true;
|
||
|
||
/**
|
||
* @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);
|
||
}
|
||
|
||
$summa = (float)$request->post('summa');
|
||
|
||
if ($summa < 0) {
|
||
return $this->json(['success' => false, 'errors' => ['summa' => 'Сумма не может быть отрицательной']], 422);
|
||
}
|
||
|
||
$type_acc = $request->post('type_acc');
|
||
$rate_percent = $request->post('rate_percent');
|
||
|
||
[$goal_target, $goal_deadline, $goal_error] = $this->parseGoal($type_acc, $request->post('goal_target_summa'), $request->post('goal_deadline'));
|
||
|
||
if ($goal_error !== null) {
|
||
return $this->json(['success' => false, 'errors' => ['goal_target_summa' => $goal_error]], 422);
|
||
}
|
||
|
||
$data = [
|
||
'user_id' => $user->id,
|
||
'type_acc' => $type_acc,
|
||
'title' => $request->post('title'),
|
||
'summa' => $summa,
|
||
'currency' => strtoupper((string)$request->post('currency')),
|
||
'description' => $request->post('description') !== '' ? $request->post('description') : null,
|
||
'color' => $request->post('color') !== '' ? $request->post('color') : null,
|
||
'privacy' => in_array($request->post('privacy'), ['shared', 'personal'], true) ? $request->post('privacy') : 'shared',
|
||
'include_in_total' => $request->post('include_in_total') === '1',
|
||
'is_delete' => false,
|
||
'order' => 0,
|
||
];
|
||
|
||
$account_repo = new AccountRepository();
|
||
|
||
$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 !== '') {
|
||
(new AccountRateRepository())->create([
|
||
'account_id' => $account_id,
|
||
'rate_percent' => (float)$rate_percent,
|
||
'valid_from' => date('Y-m-d'),
|
||
'valid_to' => null,
|
||
]);
|
||
}
|
||
|
||
if ($type_acc === 'savings' && $goal_target !== null && $goal_deadline !== null) {
|
||
(new GoalRepository())->create([
|
||
'account_id' => $account_id,
|
||
'target_summa' => $goal_target,
|
||
'deadline' => $goal_deadline,
|
||
]);
|
||
}
|
||
|
||
return $account_id;
|
||
});
|
||
|
||
return $this->json(['success' => true]);
|
||
}
|
||
|
||
/**
|
||
* @return string JSON
|
||
* @throws HTTPException|MyException
|
||
*/
|
||
public function editAction(): string
|
||
{
|
||
$request = Request::$current;
|
||
$account_repo = new AccountRepository();
|
||
$account = $this->ownAccount($account_repo, (int)$request->param(0));
|
||
|
||
$validation = $this->buildValidation($request->post());
|
||
|
||
if (!$validation->check()) {
|
||
return $this->json(['success' => false, 'errors' => $validation->errors()], 422);
|
||
}
|
||
|
||
$summa = (float)$request->post('summa');
|
||
$type_acc = $request->post('type_acc');
|
||
|
||
if ($summa < 0) {
|
||
return $this->json(['success' => false, 'errors' => ['summa' => 'Сумма не может быть отрицательной']], 422);
|
||
}
|
||
|
||
[$goal_target, $goal_deadline, $goal_error] = $this->parseGoal($type_acc, $request->post('goal_target_summa'), $request->post('goal_deadline'));
|
||
|
||
if ($goal_error !== null) {
|
||
return $this->json(['success' => false, 'errors' => ['goal_target_summa' => $goal_error]], 422);
|
||
}
|
||
|
||
$account_repo->update([
|
||
'id' => $account->id,
|
||
'type_acc' => $type_acc,
|
||
'title' => $request->post('title'),
|
||
'summa' => $summa,
|
||
'currency' => strtoupper((string)$request->post('currency')),
|
||
'description' => $request->post('description') !== '' ? $request->post('description') : null,
|
||
'color' => $request->post('color') !== '' ? $request->post('color') : null,
|
||
'privacy' => in_array($request->post('privacy'), ['shared', 'personal'], true) ? $request->post('privacy') : 'shared',
|
||
'include_in_total' => $request->post('include_in_total') === '1',
|
||
]);
|
||
|
||
$goal_repo = new GoalRepository();
|
||
$goal = $goal_repo->getByAccountId($account->id);
|
||
|
||
if ($type_acc === 'savings' && $goal_target !== null && $goal_deadline !== null) {
|
||
if ($goal) {
|
||
$goal_repo->update(['id' => $goal->id, 'target_summa' => $goal_target, 'deadline' => $goal_deadline]);
|
||
} else {
|
||
$goal_repo->create(['account_id' => $account->id, 'target_summa' => $goal_target, 'deadline' => $goal_deadline]);
|
||
}
|
||
} elseif ($goal) {
|
||
// Тип сменили с savings, либо оба поля цели очистили в форме — цель больше не нужна.
|
||
$goal_repo->delete($goal->id);
|
||
}
|
||
|
||
return $this->json(['success' => true]);
|
||
}
|
||
|
||
/**
|
||
* Архивация (is_delete = 1), не физическое удаление — см. бюджет_текущий_план.md → Accounts.
|
||
*
|
||
* @return string JSON
|
||
* @throws HTTPException|MyException
|
||
*/
|
||
public function deleteAction(): string
|
||
{
|
||
$request = Request::$current;
|
||
$account_repo = new AccountRepository();
|
||
$account = $this->ownAccount($account_repo, (int)$request->param(0));
|
||
|
||
$account_repo->update(['id' => $account->id, 'is_delete' => true]);
|
||
|
||
return $this->json(['success' => true]);
|
||
}
|
||
|
||
/**
|
||
* Возврат из архива (is_delete = 0).
|
||
*
|
||
* @return string JSON
|
||
* @throws HTTPException|MyException
|
||
*/
|
||
public function restoreAction(): string
|
||
{
|
||
$request = Request::$current;
|
||
$account_repo = new AccountRepository();
|
||
$account = $this->ownAccount($account_repo, (int)$request->param(0));
|
||
|
||
$account_repo->update(['id' => $account->id, 'is_delete' => false]);
|
||
|
||
return $this->json(['success' => true]);
|
||
}
|
||
|
||
/**
|
||
* Сохраняет ручной порядок карточек счетов (режим drag&drop на дашборде, см.
|
||
* App/media/js/accounts.js) — id[] уже в новом порядке, просто проставляем order по позиции
|
||
* в массиве. Чужие/несуществующие id молча пропускаются (не валят весь запрос), это не
|
||
* критичная операция — максимум, что может пойти не так, порядок не сохранится полностью.
|
||
*
|
||
* @return string JSON
|
||
* @throws MyException
|
||
*/
|
||
public function reorderAction(): string
|
||
{
|
||
$request = Request::$current;
|
||
$user = $this->currentUser();
|
||
$account_repo = new AccountRepository();
|
||
|
||
$ids = $request->post('ids');
|
||
|
||
if (!is_array($ids)) {
|
||
return $this->json(['success' => false, 'errors' => ['ids' => 'Некорректный список']], 422);
|
||
}
|
||
|
||
foreach (array_values($ids) as $position => $id) {
|
||
$account = $account_repo->get((int)$id);
|
||
|
||
if (!$account || (int)$account->user_id !== (int)$user->id) {
|
||
continue;
|
||
}
|
||
|
||
$account_repo->update(['id' => $account->id, 'order' => $position]);
|
||
}
|
||
|
||
return $this->json(['success' => true]);
|
||
}
|
||
|
||
/**
|
||
* Общие правила валидации формы счёта (create/edit).
|
||
*
|
||
* @param array $data
|
||
* @return Validation
|
||
*/
|
||
protected function buildValidation(array $data): Validation
|
||
{
|
||
return Validation::factory($data)
|
||
->label('title', 'Название')
|
||
->label('type_acc', 'Тип')
|
||
->label('summa', 'Сумма')
|
||
->label('currency', 'Валюта')
|
||
->rule('title', 'required')
|
||
->rule('title', 'max_length', [128])
|
||
->rule('type_acc', 'required')
|
||
->rule('type_acc', 'in', [['cash', 'bank', 'savings']])
|
||
->rule('summa', 'required')
|
||
->rule('summa', 'numeric')
|
||
->rule('currency', 'required')
|
||
->rule('currency', 'in', [array_keys(Currency::LIST)])
|
||
->rule('description', 'max_length', [255])
|
||
->rule('color', 'max_length', [20])
|
||
->rule('goal_target_summa', 'numeric')
|
||
->rule('goal_deadline', 'regex', ['/^\d{4}-\d{2}-\d{2}$/']);
|
||
}
|
||
|
||
/**
|
||
* «Цель накопления» + «Дата цели» — необязательная пара полей формы счёта, доступна только
|
||
* для type_acc=savings (см. дизайн-хендофф, App/design_handoff_personal_finance). Оба поля
|
||
* заполняются вместе — тот же паттерн, что и формульная связь категорий
|
||
* (App\Controller\CategoriesController::parseFormulaLink).
|
||
*
|
||
* @param string|null $type_acc
|
||
* @param mixed $raw_target
|
||
* @param mixed $raw_deadline
|
||
* @return array{0:float|null,1:string|null,2:string|null} [target_summa, deadline, error]
|
||
*/
|
||
protected function parseGoal(?string $type_acc, mixed $raw_target, mixed $raw_deadline): array
|
||
{
|
||
$has_target = $raw_target !== null && $raw_target !== '';
|
||
$has_deadline = $raw_deadline !== null && $raw_deadline !== '';
|
||
|
||
if (!$has_target && !$has_deadline) {
|
||
return [null, null, null];
|
||
}
|
||
|
||
if ($type_acc !== 'savings') {
|
||
return [null, null, 'Цель доступна только для накопительного счёта'];
|
||
}
|
||
|
||
if ($has_target !== $has_deadline) {
|
||
return [null, null, 'Укажите и сумму цели, и дату'];
|
||
}
|
||
|
||
if (!is_numeric($raw_target) || (float)$raw_target <= 0) {
|
||
return [null, null, 'Сумма цели должна быть больше 0'];
|
||
}
|
||
|
||
return [(float)$raw_target, (string)$raw_deadline, null];
|
||
}
|
||
|
||
/**
|
||
* Бюджетный пользователь (App\Repositories\UserRepository), связанный с текущим логином
|
||
* Services\Auth — см. App\Controller\IndexController за тем же паттерном.
|
||
*
|
||
* @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, чтобы не подтверждать
|
||
* чужому пользователю сам факт существования id).
|
||
*
|
||
* @param AccountRepository $account_repo
|
||
* @param int $id
|
||
* @return object
|
||
* @throws HTTPException|MyException
|
||
*/
|
||
protected function ownAccount(AccountRepository $account_repo, int $id): object
|
||
{
|
||
$user = $this->currentUser();
|
||
$account = $account_repo->get($id);
|
||
|
||
if (!$account || (int)$account->user_id !== (int)$user->id) {
|
||
throw HTTPException::factory(404);
|
||
}
|
||
|
||
return $account;
|
||
}
|
||
}
|