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

340 lines
13 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 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');
if ($type_acc === 'savings' && (!is_numeric($rate_percent) || (float)$rate_percent < 0)) {
return $this->json(['success' => false, 'errors' => ['rate_percent' => 'Укажите ставку, % годовых']], 422);
}
[$goal_target, $goal_deadline, $goal_error] = $this->parseGoal($type_acc, $request->post('goal_target_summa'), $request->post('goal_deadline'));
if ($goal_error !== null) {
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') {
(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);
}
if ($type_acc !== $account->type_acc) {
return $this->json(['success' => false, 'errors' => ['type_acc' => 'Тип счёта нельзя изменить после создания']], 422);
}
[$goal_target, $goal_deadline, $goal_error] = $this->parseGoal($type_acc, $request->post('goal_target_summa'), $request->post('goal_deadline'));
if ($goal_error !== null) {
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;
}
}