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