'searchAccountDispatched', 'amount' => 'amountDispatched', ]; protected $rules = [ 'search' => 'nullable|string|min:3|max:100', 'searchAmount' => 'nullable|integer', 'fromDate' => 'nullable|date', 'toDate' => 'nullable|after_or_equal:fromDate', 'searchTypes' => 'nullable|array', 'searchTypes.*' => 'integer', ]; // TODO: translate protected $messages = [ 'fromDate.date' => 'The from date must be a valid date.', 'toDate.date' => 'The to date must be a valid date.', ]; public function mount($toAccountType = null) { $profile = getActiveProfile(); if (!$profile) { abort(403, 'No active profile'); } // CRITICAL SECURITY: Validate user has ownership/access to this profile // This prevents unauthorized access to transaction data via session manipulation \App\Helpers\ProfileAuthorizationHelper::authorize($profile); // $toAccountType is optional for transactiontables that should show transactions to a certain account during first page load $activeProfileType = strtolower(class_basename(session('activeProfileType'))); $canPay = timebank_config('permissions.' . $activeProfileType . '.payment_types', []); if ($toAccountType != null) { $canReceive = timebank_config('accounts.' . $toAccountType . '.receiving_types', []); // Merge the two transaction type groups to find all available options, use only unique values $typeIds = array_unique(array_merge($canPay, $canReceive)); } else { $typeIds = $canPay; } $this->typeOptions = TransactionType::whereIn('id', $typeIds)->get()->map(function ($type) { $type->name = __(ucfirst(strtolower($type->name))); return $type; }); } public function amountDispatched($amount) { $this->searchAmount = $amount; } public function fromAccountId($selectedAccount) { $this->fromAccountId = $selectedAccount['id']; $title = [ 'header'=> $selectedAccount['name'], 'sub' => __('Current balance') . ': ' . tbFormat($selectedAccount['balance']) ]; $this->dispatch('tableTitle', $title); } public function searchAccountDispatched($accountDetails) { $this->searchAccount = $accountDetails['accountId']; } /** * Get all transactions with running balance. * Returns a paginator object that only loads the transactions for the selected page. * * IMPORTANT: This method requires MySQL 8.0+ or MariaDB 10.2+ for window function support. * * @return \Illuminate\Pagination\LengthAwarePaginator */ public function getTransactions() { // Hide the balance column if transactions are skipped because of a search filter if (!empty($this->search) || !empty($this->searchAmount) || !empty($this->amountType) || !empty($this->searchAccount) || !empty($this->fromDate) || !empty($this->searchTypes)) { $this->hideBalance = true; $this->searchStatus = true; } else { $this->searchStatus = false; $this->hideBalance = false; } if (!empty($this->toDate)) { $this->searchStatus = true; } $accountId = $this->fromAccountId; if (!isset($accountId)) { return ; // return empty if accountId is not set yet } $accountId = $this->fromAccountId; // Check if accountId is owned by active profile $check = $this->checkAccountHolder($accountId); if (!$check) { return ; } $search = $this->search; $searchAccount = $this->searchAccount; $searchAmount = $this->searchAmount !== null ? $this->searchAmount : null; $fromDate = $this->fromDate; $toDate = $this->toDate; $searchTypes = $this->searchTypes; $this->validate(); // Fetch the account with its accountable relationship $account = Account::with(['accountable:id,name,full_name'])->find($accountId); // Use window function to calculate running balance for each transaction // This function requires MySQL 8.0+ or MariaDB 10.2+ for window function support. $query = Transaction::selectRaw(" transactions.*, SUM( CASE WHEN to_account_id = ? THEN amount WHEN from_account_id = ? THEN -amount ELSE 0 END ) OVER (ORDER BY created_at ASC) AS balance ", [$accountId, $accountId]) ->with(['accountTo.accountable:id,name,full_name,profile_photo_path', 'accountFrom.accountable:id,name,full_name,profile_photo_path']) ->where(function ($query) use ($accountId) { $query->where('to_account_id', $accountId) ->orWhere('from_account_id', $accountId); }); // Apply search filters if (!empty($search)) { $query->where(function ($query) use ($search) { $query->whereRaw('LOWER(description) LIKE ?', ["%{$search}%"]) ->orWhereHas('accountFrom.accountable', function ($query) use ($search) { $query->whereRaw('LOWER(name) LIKE ?', ["%{$search}%"]); }) ->orWhereHas('accountTo.accountable', function ($query) use ($search) { $query->whereRaw('LOWER(name) LIKE ?', ["%{$search}%"]); }); }); } if (!empty($searchAccount)) { $query->where(function ($query) use ($searchAccount) { $query->where('from_account_id', $searchAccount) ->orWhere('to_account_id', $searchAccount); }); } if (!empty($searchAmount)) { $query->where('amount', $searchAmount); } if ($this->amountType == 'credit' || $this->amountType == 'debit') { if ($this->amountType == 'credit') { $query->where('to_account_id', $accountId); } else { $query->where('from_account_id', $accountId); } } if (!empty($fromDate)) { $query->whereDate('created_at', '>=', $fromDate); } if (!empty($toDate)) { $query->whereDate('created_at', '<=', $toDate); } if (!empty($searchTypes)) { $query->whereIn('transaction_type_id', $searchTypes); } // Get total records before pagination, if no results, return empty $transaction // This is needed because the paginator does not refresh if no results. $totalRecords = $query->count(); if ($totalRecords === 0) { $this->resetPage(); return $transactions = null; } // Paginate the search results $transactions = $query ->orderBy('created_at', 'desc') ->paginate($this->perPage); // Transform the transactions to include necessary data $transactionsCollection = $transactions->getCollection()->map(function ($t) use ($accountId, $account) { $transaction = [ 'trans_id' => $t->id, 'datetime' => $t->created_at, 'amount' => $t->amount, 'c/d' => $t->to_account_id === $accountId ? 'Credit' : 'Debit', 'account_id' => $account->id, 'account_name' => $account->name, 'account_holder_name' => $account->accountable->name, 'account_holder_full_name' => $account->accountable->full_name, 'account_holder_location' => $account->accountable->getLocationFirst()['name_short'], 'description' => $t->description, 'type' => $t->transactionType->name ?? '', 'balance' => $t->balance, // Running balance from window function ]; if ($t->to_account_id === $accountId) { // Credit transaction $transaction += [ 'account_from' => $t->from_account_id, 'account_counter_id' => $t->from_account_id, 'account_from_name' => $t->accountFrom->name ?? '', 'account_counter_name' => $t->accountFrom->name ?? '', 'relation' => $t->accountFrom->accountable->name ?? '', 'relation_full_name' => $t->accountFrom->accountable->full_name ?? '', 'relation_location' => $t->accountFrom->accountable->getLocationFirst()['name_short'] ?? '', 'profile_photo' => $t->accountFrom->accountable->profile_photo_path ?? '', ]; } else { // Debit transaction $transaction += [ 'account_to' => $t->to_account_id, 'account_counter_id' => $t->to_account_id, 'account_to_name' => $t->accountTo->name ?? '', 'account_counter_name' => $t->accountTo->name ?? '', 'relation' => $t->accountTo->accountable->name ?? '', 'relation_full_name' => $t->accountTo->accountable->full_name ?? '', 'relation_location' => $t->accountTo->accountable->getLocationFirst()['name_short'] ?? '', 'profile_photo' => $t->accountTo->accountable->profile_photo_path ?? '', ]; } return $transaction; }); // Set the transformed collection back to the paginator $transactions->setCollection($transactionsCollection); // Return the paginated items return $transactions; } public function exportTransactions($type) { set_time_limit(0); // Hide the balance column if transactions are skipped because of a search filter if (!empty($this->search) || !empty($this->searchAmount) || !empty($this->searchAccount || !empty($this->amountType) || !empty($this->fromDate) || !empty($this->searchTypes))) { $this->hideBalance = true; } else { $this->hideBalance = false; } $accountId = $this->fromAccountId; if (!isset($accountId)) { return ; // return empty if accountId is not set yet } $accountId = $this->fromAccountId; // Check if accountId is owned by active profile $check = $this->checkAccountHolder($accountId); if (!$check) { return ; } $this->validate(); // Fetch the account with its accountable relationship $account = Account::with(['accountable:id,name,full_name'])->find($accountId); // Build the query $query = Transaction::with([ 'accountTo.accountable:id,name,full_name,profile_photo_path', 'accountFrom.accountable:id,name,full_name,profile_photo_path', 'transactionType:id,name' ])->where(function ($query) use ($accountId) { $query->where('to_account_id', $accountId) ->orWhere('from_account_id', $accountId); }); // Apply search filters if any if (!empty($this->search)) { $search = strtolower(trim($this->search)); $query->where(function ($query) use ($search) { $query->whereRaw('LOWER(description) LIKE ?', ["%{$search}%"]) ->orWhereHas('accountFrom.accountable', function ($query) use ($search) { $query->whereRaw('LOWER(name) LIKE ?', ["%{$search}%"]) ->orWhereRaw('LOWER(full_name) LIKE ?', ["%{$search}%"]); }) ->orWhereHas('accountTo.accountable', function ($query) use ($search) { $query->whereRaw('LOWER(name) LIKE ?', ["%{$search}%"]) ->orWhereRaw('LOWER(full_name) LIKE ?', ["%{$search}%"]); }); }); } if (!empty($this->searchAccount)) { $query->where(function ($query) { $query->where('from_account_id', $this->searchAccount) ->orWhere('to_account_id', $this->searchAccount); }); } if (!empty($this->searchAmount)) { $query->where('amount', $this->searchAmount); } if ($this->amountType == 'credit' || $this->amountType == 'debit') { if ($this->amountType == 'credit') { $query->where('to_account_id', $accountId); } else { $query->where('from_account_id', $accountId); } } if (!empty($this->fromDate)) { $query->whereDate('created_at', '>=', $this->fromDate); } if (!empty($this->toDate)) { $query->whereDate('created_at', '<=', $this->toDate); } if (!empty($this->searchTypes)) { $query->whereIn('transaction_type_id', $this->searchTypes); } // Get all transactions without pagination and balance as this is for export // Running balance is not calculated as for accounts with many transactions this would take too long // to query and php's time limit would throw an error. $transactions = $query->orderBy('created_at', 'desc')->get(); // Transform the transactions as needed $data = $transactions->map(function ($t) use ($account, $accountId) { $transaction = [ 'trans_id' => $t->id, 'datetime' => $t->created_at, 'amount' => $t->amount, 'c/d' => $t->to_account_id === $accountId ? 'Credit' : 'Debit', 'account_id' => $account->id, 'account_name' => $account->name, 'account_holder_name' => $account->accountable->name, 'account_holder_full_name' => $account->accountable->full_name, 'description' => $t->description, 'type' => $t->transactionType ? $t->transactionType->name : '', ]; if ($t->to_account_id === $accountId) { // Credit transaction details $transaction += [ 'account_from' => $t->from_account_id, 'account_counter_id' => $t->from_account_id, 'account_from_name' => $t->accountFrom->name ?? '', 'account_counter_name' => $t->accountFrom->name ?? '', 'relation' => $t->accountFrom->accountable->name ?? '', 'relation_full_name' => $t->accountFrom->accountable->full_name ?? '', ]; } else { // Debit transaction details $transaction += [ 'account_to' => $t->to_account_id, 'account_counter_id' => $t->to_account_id, 'account_to_name' => $t->accountTo->name ?? '', 'account_counter_name' => $t->accountTo->name ?? '', 'relation' => $t->accountTo->accountable->name ?? '', 'relation_full_name' => $t->accountTo->accountable->full_name ?? '', ]; } return $transaction; }); // Use the TransactionsExport to export data return (new TransactionsExport($data))->download('transactions.' . $type); } private function checkAccountHolder($accountId) { //TODO: remove test comment for production //Uncomment below to test Log and Report // $accountId = 999999; // NOTICE: Livewire public properties can be changed / hacked on the client side! // Check therefore check again ownership of the fromAccountId. // The getAccountsInfo() from the AccountInfoTrait checks the active profile sessions. $transactionController = new TransactionController(); $accountsInfo = collect($transactionController->getAccountsInfo()); // Check if the session's active profile owns the submitted fromAccountId // TODO: translate warningMessage if (!$accountsInfo->contains('id', $accountId)) { $warningMessage = 'Unauthorized transactions table access attempt'; $this->logAndReport($warningMessage, $accountId); return false; // check failed } return true; // check passed } /** * Logs a warning message and reports it via email to the system administrator. * * This method logs a warning message with detailed information about the event, * including account details, user details, IP address, and location. It also * sends an email to the system administrator with the same information. */ private function logAndReport($warningMessage, $accountId, $error = '') { $account = Account::find($accountId); $accountHolder = $account ? $account->accountable()->value('name') : ''; $ip = request()->ip(); $ipLocationInfo = IpLocation::get($ip); // Escape ipLocation errors when not in production if (!$ipLocationInfo || App::environment(['local', 'development', 'staging'])) { $ipLocationInfo = (object) [ 'cityName' => 'local City', 'regionName' => 'local Region', 'countryName' => 'local Country', ]; } $eventTime = now()->toDateTimeString(); // Log this event and mail to admin Log::warning($warningMessage, [ 'accountId_notOwnedByActiveProfile' => $accountId, 'accountHolder' => $accountHolder, 'userId' => Auth::id(), 'userName' => Auth::user()->name, 'activeProfileId' => session('activeProfileId'), 'activeProfileType' => session('activeProfileType'), 'activeProfileName' => session('activeProfileName'), 'IP address' => $ip, 'IP location' => $ipLocationInfo->cityName . ', ' . $ipLocationInfo->regionName . ', ' . $ipLocationInfo->countryName, 'Event Time' => $eventTime, 'Message' => $error, ]); Mail::raw( $warningMessage . '.' . "\n\n" . 'Account ID (not owned by active profile): ' . $accountId . "\n" . 'Account Holder: ' . $accountHolder . "\n" . 'User ID: ' . Auth::id() . "\n" . 'User Name: ' . Auth::user()->name . "\n" . 'Active Profile ID: ' . session('activeProfileId') . "\n" . 'Active Profile Type: ' . session('activeProfileType') . "\n" . 'Active Profile Name: ' . session('activeProfileName') . "\n" . 'IP address: ' . $ip . "\n" . 'IP location: ' . $ipLocationInfo->cityName . ', ' . $ipLocationInfo->regionName . ', ' . $ipLocationInfo->countryName . "\n" . 'Event Time: ' . $eventTime . "\n\n" . $error, function ($message) use ($warningMessage) { $message->to(timebank_config('mail.system_admin.email'))->subject($warningMessage); }, ); session()->flash('error', __($warningMessage) . '. ' . __('This event has been logged and reported to our system administrator') . '.'); } // This method is called whenever any property is updated. public function updated($propertyName) { $this->resetPage(); } public function resetSearch() { $this->resetPage(); $this->searchStatus = false; $this->hideBalance = false; $this->showSearchSection = false; $this->search = null; $this->searchAmount = null; $this->searchAccount = null; $this->dispatch('resetForm'); $this->amountType = null; $this->fromDate = null; $this->toDate = null; $this->typeOptions = []; $this->searchTypes = []; } public function updatedPage() { $this->dispatch('scroll-to-top'); } public function render() { return view('livewire.transactions-table', [ 'transactions' => $this->getTransactions(), ]); } }