Initial commit
This commit is contained in:
562
app/Http/Livewire/TransactionsTable.php
Normal file
562
app/Http/Livewire/TransactionsTable.php
Normal file
@@ -0,0 +1,562 @@
|
||||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user