Initial commit

This commit is contained in:
Ronald Huynen
2026-03-23 21:37:59 +01:00
commit 2547717edb
2193 changed files with 972171 additions and 0 deletions

View 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(),
]);
}
}