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; } }