563 lines
21 KiB
PHP
563 lines
21 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Livewire;
|
|
|
|
use App\Exports\TransactionsExport;
|
|
use App\Http\Controllers\TransactionController;
|
|
use App\Models\Account;
|
|
use App\Models\Transaction;
|
|
use App\Models\TransactionType;
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Facades\App;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Mail;
|
|
// use Livewire\Attributes\On;
|
|
use Livewire\Component;
|
|
use Livewire\WithPagination;
|
|
use Stevebauman\Location\Facades\Location as IpLocation;
|
|
|
|
class TransactionsTable extends Component
|
|
{
|
|
use WithPagination;
|
|
|
|
public $searchStatus;
|
|
public $hideBalance;
|
|
public $showSearchSection = false;
|
|
public $search;
|
|
public $searchAmount;
|
|
public $searchAccount;
|
|
public $amountType;
|
|
public $fromDate;
|
|
public $toDate;
|
|
public $typeOptions = [];
|
|
public $searchTypes = [];
|
|
public $perPage = 15;
|
|
public $sortField;
|
|
public $sortAsc = true;
|
|
public $fromAccountId;
|
|
|
|
protected $account;
|
|
protected $transactionsForExport;
|
|
|
|
protected $listeners = [
|
|
'fromAccountId',
|
|
'searchTransactions',
|
|
'toAccountDetails' => '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(),
|
|
]);
|
|
}
|
|
}
|