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

691
app/Http/Livewire/Pay.php Normal file
View File

@@ -0,0 +1,691 @@
<?php
namespace App\Http\Livewire;
use App\Http\Controllers\TransactionController;
use App\Mail\TransferReceived;
use App\Models\Account;
use App\Models\Transaction;
use App\Models\TransactionType;
use App\Models\User;
use Cog\Laravel\Love\ReactionType\Models\ReactionType as LoveReactionType;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Lang;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Livewire\Component;
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
use Namu\WireChat\Events\NotifyParticipant;
use Stevebauman\Location\Facades\Location as IpLocation;
use WireUi\Traits\WireUiActions;
use function Laravel\Prompts\error;
class Pay extends Component
{
use WireUiActions;
use \App\Traits\ProfilePermissionTrait;
public $hours;
public $minutes;
public $amount;
public $fromAccountId;
public $fromAccountName;
public $fromAccountBalance;
public $toAccountId;
public $toAccountName;
public $toHolderId;
public $toHolderType;
public $toHolderName;
public $toHolderPhoto;
public $type;
public $typeOptions = [];
public $description;
public $transactionTypeSelected;
public $limitError;
public $requiredError = false;
public $submitEnabled = false;
public $modalVisible = false;
public $modalErrorVisible = false;
public $rememberPaymentData = false;
public $transactionTypeRemembered;
public $typeOptionsProtected;
protected $listeners = [
'amount' => 'amountValidation',
'fromAccountId',
'toAccountId',
'toAccountDetails' => 'toAccountDispatched',
'description',
'transactionTypeSelected',
'resetForm',
'removeSelectedAccount',
];
protected function rules()
{
return [
'amount' => timebank_config('payment.amount_rule'),
'fromAccountId' => 'nullable|integer|exists:accounts,id',
'toAccountId' => 'required|integer',
'description' => timebank_config('payment.description_rule'),
'transactionTypeSelected.name' => 'required|string|exists:transaction_types,name',
];
}
protected function messages()
{
return [
'transactionTypeSelected.name.required' => __('messages.Transaction type is required'),
];
}
public function mount($amount = null, $hours = null, $minutes = null)
{
$this->modalVisible = false;
$profile = getActiveProfile();
if (!$profile) {
abort(403, 'No active profile');
}
// CRITICAL SECURITY: Validate user has ownership/access to this profile
// This prevents unauthorized access to the payment form via session manipulation
\App\Helpers\ProfileAuthorizationHelper::authorize($profile);
if ($amount !== null && is_numeric($amount) && $amount > 0) {
$this->amount = $amount;
} else {
$hours = is_numeric($this->hours) ? (int) $this->hours : 0;
$minutes = is_numeric($this->minutes) ? (int) $this->minutes : 0;
$this->amount = $hours * 60 + $minutes;
}
}
/**
* Clear remembered transaction type when checkbox is unchecked
*/
public function updatedRememberPaymentData($value)
{
if (!$value) {
$this->transactionTypeRemembered = null;
}
}
/**
* Extra validation when amount looses focus
*
* @param mixed $toAccountId
* @return void
*/
public function amountValidation($amount = null)
{
$this->amount = $amount ?? $this->amount;
$this->validateOnly('amount');
}
/**
* Sets fromAccountId after From Account drop down is selected
*
* @param mixed $toAccount
* @return void
*/
public function fromAccountId($selectedAccount)
{
$this->modalVisible = false;
// Handle case where no account is selected (e.g., Admin profiles)
if (!$selectedAccount || !isset($selectedAccount['id'])) {
$this->fromAccountId = null;
$this->fromAccountName = null;
$this->fromAccountBalance = null;
return;
}
$this->fromAccountId = $selectedAccount['id'];
$this->fromAccountName = $selectedAccount['name'];
$this->fromAccountBalance = $selectedAccount['balance'];
$this->validateOnly('fromAccountId');
if ($this->fromAccountId == $this->toAccountId) {
$this->dispatch('resetForm')->to(ToAccount::class);
$this->dispatch('fromAccountId', $this->fromAccountId)->to(ToAccount::class);
}
}
/**
* Sets fromAccountId after To Account drop down is selected
*
* @param mixed $toAccount
* @return void
*/
public function toAccountId($toAccountId)
{
$this->modalVisible = false;
$this->toAccountId = $toAccountId;
$this->validateOnly('toAccountId');
}
/**
* Sets To account details after it is selected
*
* @param mixed $details
* @return void
*/
public function toAccountDispatched($details)
{
if ($details) {
// Check if we have a to account
$this->requiredError = false;
$this->toAccountId = $details['accountId'];
$this->toAccountName = __(ucfirst(strtolower($details['accountName'])));
$this->toHolderId = $details['holderId'];
$this->toHolderType = $details['holderType'];
$this->toHolderName = $details['holderName'];
$this->toHolderPhoto = url($details['holderPhoto']);
// Look up in config what transaction types are possible / allowed
$canReceive = timebank_config('accounts.' . strtolower(class_basename($details['holderType'])) . '.receiving_types');
$canPayConfig = timebank_config('permissions.' . strtolower(class_basename(session('activeProfileType'))) . '.payment_types');
$canPay = $canPayConfig ?? [];
$allowedTypes = array_intersect($canPay, $canReceive);
// Check if this is an internal transfer (same accountable holder)
$isInternalTransfer = (
session('activeProfileType') === $details['holderType'] &&
session('activeProfileId') == $details['holderId']
);
// If it's an internal transfer, only allow type 6 (Migration)
if ($isInternalTransfer && !in_array(6, $allowedTypes)) {
$allowedTypes = [6];
}
$this->typeOptionsProtected = $allowedTypes;
$this->typeOptions = $this->typeOptionsProtected;
// Pass remembered transaction type to the dispatch if remembering payment data
$rememberedType = ($this->rememberPaymentData && $this->transactionTypeRemembered)
? $this->transactionTypeRemembered
: null;
$this->dispatch('transactionTypeOptions', $this->typeOptions, $rememberedType);
} else {
// if no to account is present, set id to null and validate so the user received an error
$this->typeOptions = null;
$this->dispatch('transactionTypeOptions', $this->typeOptions, null);
$this->toAccountId = null;
}
$this->validateOnly('toAccountId');
}
/**
* Sets description after it is updated
*
* @param mixed $content
* @return void
*/
public function description($description)
{
$this->description = $description;
$this->validateOnly('description');
}
/**
* Sets transactionTypeSelected after it is updated
*
* @param mixed $content
* @return void
*/
public function transactionTypeSelected($selected)
{
$this->transactionTypeSelected = $selected;
$this->validateOnly('transactionTypeSelected');
}
public function showModal()
{
try {
$this->validate();
} catch (\Illuminate\Validation\ValidationException $errors) {
// dump($errors); //TODO! Replace dump and render error message nicely for user
$this->validate();
// Execution stops here if validation fails.
}
$fromAccountId = $this->fromAccountId;
$toAccountId = $this->toAccountId;
$amount = $this->amount;
// Check if fromAccountId is null (e.g., Admin profiles without accounts)
if (!$fromAccountId) {
$this->notification()->error(
__('No account available'),
__('Your profile does not have any accounts to make payments from.')
);
return;
}
$transactionController = new TransactionController();
$balanceFrom = $transactionController->getBalance($fromAccountId);
$balanceTo = $transactionController->getBalance($toAccountId);
if ($toAccountId === $fromAccountId) {
return redirect()->back()->with('error', 'You cannot transfer Hours from and to the same account');
} else {
$fromAccountExists = Account::where('id', $toAccountId)->first();
if (!$fromAccountExists) {
return redirect()->back()->with('error', 'Account not found.');
} else {
$transferToAccount = $fromAccountExists->id;
}
$f = Account::where('id', $fromAccountId)->select('limit_min')->first();
$limitMinFrom = $f->limit_min;
$t = Account::where('id', $transferToAccount)->select('limit_max', 'limit_min')->first();
$limitMaxTo = $t->limit_max - $t->limit_min;
$transferBudgetFrom = $balanceFrom - $limitMinFrom;
if (timebank_config('account_info.' . strtolower(class_basename($this->toHolderType)) . '.balance_public')) {
$transferBudgetTo = $limitMaxTo - $balanceTo;
$balanceToPublic = true;
} else {
$transferBudgetTo = $limitMaxTo - $balanceTo;
$balanceToPublic = false;
}
$this->checkBalanceLimits($amount, $transferBudgetTo, $transferBudgetFrom, $limitMinFrom, $balanceToPublic);
$this->modalVisible = true;
}
}
/**
* Create transfer, output success / error message and reset from.
*
* @return void
*/
public function doTransfer()
{
$fromAccountId = $this->fromAccountId;
$toAccountId = $this->toAccountId;
$amount = $this->amount;
$description = $this->description;
$transactionTypeId = $this->transactionTypeSelected['id'];
// Block payment if the active user only has coordinator role (no payment rights)
if (!$this->getCanCreatePayments()) {
$warningMessage = 'Unauthorized payment attempt: coordinator role has no payment rights';
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description);
}
// 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
if (!$accountsInfo->contains('id', $fromAccountId)) {
$warningMessage = 'Unauthorized payment attempt: illegal access of From account';
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description);
}
// Check if From and To Account is different
if ($toAccountId === $fromAccountId) {
$warningMessage = 'Impossible payment attempt: To and From account are the same';
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description);
}
// Check if the To Account exists and is not removed
$toAccountExists = Account::where('id', $toAccountId)->notRemoved()->first();
if (!$toAccountExists) {
$warningMessage = 'Impossible payment attempt: To account not found';
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description);
}
$transferToAccount = $toAccountExists->id;
// Check if the To Accountable exists and is not removed
$toAccountableExists = Account::find($toAccountId)->accountable()->notRemoved()->first();
if (!$toAccountableExists) {
$warningMessage = 'Impossible payment attempt: To account holder not found';
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description);
}
// Check if the From Account exists and is not removed
$fromAccountExists = Account::where('id', $fromAccountId)->notRemoved()->first();
if (!$fromAccountExists) {
$warningMessage = 'Impossible payment attempt: From account not found';
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description);
}
// Check if the From Accountable exists and is not removed
$fromAccountableExists = Account::find($fromAccountId)->accountable()->notRemoved()->first();
if (!$fromAccountableExists) {
$warningMessage = 'Impossible payment attempt: From account holder not found';
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description);
}
// Check if the transaction type is allowed, with an exception for internal transfers of type 6 (Migration)
$fromAccount = Account::find($fromAccountId);
$isInternalTransferType = (
$fromAccount->accountable_type === $fromAccountExists->accountable_type &&
$fromAccount->accountable_id === $fromAccountExists->accountable_id &&
$transactionTypeId == 6
);
// Check if the To transactionTypeSelected is allowed, unless it's a specific internal transfer
if (!$isInternalTransferType && !in_array($transactionTypeId, $this->typeOptionsProtected)) {
$transactionType = TransactionType::find($transactionTypeId)->name ?? 'id: '. $transactionTypeId;
$warningMessage = 'Impossible payment attempt: transaction type not allowed';
return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description, $transactionType);
}
$f = Account::where('id', $fromAccountId)->select('limit_min')->first();
$limitMinFrom = $f->limit_min;
$t = Account::where('id', $transferToAccount)->select('limit_max', 'limit_min')->first();
$limitMaxTo = $t->limit_max - $t->limit_min;
$balanceFrom = $transactionController->getBalance($fromAccountId);
$balanceTo = $transactionController->getBalance($toAccountId);
$transferBudgetFrom = $balanceFrom - $limitMinFrom;
if (timebank_config('account_info.' . strtolower(class_basename($this->toHolderType)) . '.balance_public')) {
$transferBudgetTo = $limitMaxTo - $balanceTo;
$balanceToPublic = true;
} else {
$transferBudgetTo = $limitMaxTo - $balanceTo;
$balanceToPublic = false;
}
// Check balance limits
$this->checkBalanceLimits($amount, $transferBudgetTo, $transferBudgetFrom, $limitMinFrom, $balanceToPublic);
// Use a database transaction for saving the payment
DB::beginTransaction();
try {
$transfer = new Transaction();
$transfer->from_account_id = $fromAccountId;
$transfer->to_account_id = $transferToAccount;
$transfer->amount = $amount;
$transfer->description = $description;
$transfer->transaction_type_id = $transactionTypeId;
$transfer->creator_user_id = Auth::user()->id;
$save = $transfer->save();
// TODO: remove testing comment for production
// Uncomment to test a failed transaction
//$save = false;
if ($save) {
// Commit the database transaction
DB::commit();
// WireUI notification
$this->notification()->success(
__('Transaction done!'),
__('messages.payment.success', [
'amount' => tbFormat($amount),
'account_name' => $this->toAccountName,
'holder_name' => $this->toHolderName,
'transaction_url' => route('transaction.show', ['transactionId' => $transfer->id, 'qrModalVisible' => true]),
'transaction_id' => $transfer->id,
])
);
// Store transaction type if remembering payment data
if ($this->rememberPaymentData) {
$this->transactionTypeRemembered = $this->transactionTypeSelected;
}
// Conditionally reset form based on rememberPaymentData setting
if ($this->rememberPaymentData) {
// Only reset to-account related fields, keeping amount and description
$this->dispatch('resetForm')->to(ToAccount::class);
$this->toAccountId = null;
$this->toAccountName = null;
$this->toHolderId = null;
$this->toHolderType = null;
$this->toHolderName = null;
$this->toHolderPhoto = null;
$this->transactionTypeSelected = null;
$this->modalVisible = false;
} else {
// Reset all fields including remembered transaction type
$this->transactionTypeRemembered = null;
$this->dispatch('resetForm');
}
// Send chat message and an email if conditions are met
$recipient = $transfer->accountTo->accountable;
$sender = $transfer->accountFrom->accountable;
$messageLocale = $recipient->lang_preference ?? $sender->lang_preference;
if (!Lang::has('messages.pay_chat_message', $messageLocale)) { // Check if the translation key exists for the selected locale
$messageLocale = config('app.fallback_locale'); // Fallback to the app's default locale
}
$chatMessage = __('messages.pay_chat_message', [
'amount' => tbFormat($amount),
'account_name' => $this->toAccountName,
], $messageLocale);
$chatTransactionStatement = LaravelLocalization::getURLFromRouteNameTranslated($messageLocale, 'routes.statement', array('transactionId' => $transfer->id));
// Send Wirechat message
$message = $sender->sendMessageTo($recipient, $chatMessage);
$message = $sender->sendMessageTo($recipient, $chatTransactionStatement);
// Broadcast the NotifyParticipant event to wirechat messenger
broadcast(new NotifyParticipant($recipient, $message));
// Check if the recipient has message settings for receiving this email and has also an email address
if (isset($recipient->email)) {
$messageSettings = method_exists($recipient, 'message_settings') ? $recipient->message_settings()->first() : null;
// Always send email unless payment_received is explicitly false
if (!$messageSettings || !($messageSettings->payment_received === false || $messageSettings->payment_received === 0)) {
$now = now();
Mail::to($recipient->email)->later($now->addSeconds(5), new TransferReceived($transfer, $messageLocale));
}
}
// Add Love Reaction with transaction type name on both models
$reactionType = TransactionType::find($transactionTypeId)->name;
try {
$reacterFacadeSender = $sender->viaLoveReacter()->reactTo($recipient, $reactionType);
$reacterFacadeRecipient = $recipient->viaLoveReacter()->reactTo($sender, $reactionType);
} catch (\Exception $e) {
// Ignore if reaction type does not exist
}
} else {
throw new \Exception('Transaction could not be saved');
}
} catch (\Exception $e) {
DB::rollBack();
// WireUI notification
$this->notification()->send([
'title' => __('Transaction failed!'),
'description' => __('messages.payment.failed_description', [
'error' => $e->getMessage(),
]),
'icon' => 'error',
'timeout' => 50000
]);
$warningMessage = 'Transaction failed';
$this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description, '', $e);
$this->resetForm();
return back();
}
}
/**
* Check balance limits for a transfer operation.
*
* This method checks if the transfer amount exceeds the allowed budget limits
* for both the source and destination accounts. It sets an appropriate error
* message and makes the error modal visible if any limit is exceeded.
*/
private function checkBalanceLimits($amount, $transferBudgetTo, $transferBudgetFrom, $limitMinFrom, $balanceToPublic)
{
if ($amount > $transferBudgetFrom && $amount > $transferBudgetTo && $transferBudgetFrom <= $transferBudgetTo) {
$this->limitError = __('messages.pay_limit_error_budget_from', [
'limitMinFrom' => tbFormat($limitMinFrom),
'transferBudgetFrom' => tbFormat($transferBudgetFrom),
]);
return $this->modalErrorVisible = true;
}
if ($amount > $transferBudgetFrom && $amount > $transferBudgetTo && $transferBudgetFrom > $transferBudgetTo) {
if ($balanceToPublic) {
$this->limitError = __('messages.pay_limit_error_budget_from_and_to', [
'limitMinFrom' => tbFormat($limitMinFrom),
'transferBudgetTo' => tbFormat($transferBudgetTo),
]);
} else {
$this->limitError = __('messages.pay_limit_error_budget_from_and_to_without_budget_to', [
'limitMinFrom' => tbFormat($limitMinFrom),
]);
}
return $this->modalErrorVisible = true;
}
if ($amount > $transferBudgetFrom) {
$this->limitError = __('messages.pay_limit_error_budget_from', [
'limitMinFrom' => tbFormat($limitMinFrom),
'transferBudgetFrom' => tbFormat($transferBudgetFrom),
]);
return $this->modalErrorVisible = true;
}
if ($amount > $transferBudgetTo) {
if ($balanceToPublic) {
$this->limitError = __('messages.pay_limit_error_budget_to', [
'transferBudgetTo' => tbFormat($transferBudgetTo),
]);
} else {
$this->limitError = __('messages.pay_limit_error_budget_to_without_budget_to', [
'transferBudgetTo' => tbFormat($transferBudgetTo),
'toHolderName' => $this->toHolderName,
]);
}
return $this->modalErrorVisible = true;
}
$this->limitError = null;
}
/**
* 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, $fromAccountId, $toAccountId, $amount, $description, $transactionType = '', $error = '')
{
$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
$fromAccountInfo = $fromAccountId ? Account::find($fromAccountId)?->accountable()?->value('name') : 'N/A';
$toAccountInfo = $toAccountId ? Account::find($toAccountId)?->accountable()?->value('name') : 'N/A';
Log::warning($warningMessage, [
'fromAccountId' => $fromAccountId ?? 'N/A',
'fromAccountHolder' => $fromAccountInfo,
'toAccountId' => $toAccountId ?? 'N/A',
'toAccountHolder' => $toAccountInfo,
'amount' => $amount,
'description' => $description,
'userId' => Auth::id(),
'userName' => Auth::user()->name,
'activeProfileId' => session('activeProfileId'),
'activeProfileType' => session('activeProfileType'),
'activeProfileName' => session('activeProfileName'),
'transactionType' => ucfirst($transactionType),
'IP address' => $ip,
'IP location' => $ipLocationInfo->cityName . ', ' . $ipLocationInfo->regionName . ', ' . $ipLocationInfo->countryName,
'Event Time' => $eventTime,
'Message' => $error,
]);
Mail::raw(
$warningMessage . '.' . "\n\n" .
'From Account ID: ' . ($fromAccountId ?? 'N/A') . "\n" .
'From Account Holder: ' . $fromAccountInfo . "\n" .
'To Account ID: ' . ($toAccountId ?? 'N/A') . "\n" .
'To Account Holder: ' . $toAccountInfo . "\n" .
'Amount: ' . $amount . "\n" .
'Description: ' . $description . "\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" .
'Transaction Type: ' . ucfirst($transactionType) . "\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);
},
);
return redirect()
->back()
->with('error', __($warningMessage) . '. ' . __('This event has been logged and reported to our system administrator') . '.');
}
public function resetForm()
{
// Always reset the to account and transaction type
$this->toAccountId = null;
$this->toAccountName = null;
$this->transactionTypeSelected = null;
// Only reset amount, description, and remembered type if not remembering payment data
if (!$this->rememberPaymentData) {
$this->amount = null;
$this->description = null;
$this->transactionTypeRemembered = null;
}
$this->modalVisible = false;
}
public function removeSelectedAccount()
{
$this->toAccountId = null;
$this->toAccountName = null;
$this->toHolderId = null;
$this->toHolderName = null;
}
/**
* Render the livewire component
*
* @return void
*/
public function render()
{
return view('livewire.pay');
}
}