'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->isConfirmPaymentModalOpen = 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; } } public function updatedIsPaymentSuccessModalOpen($value) { if (!$value) { // Success modal was closed if (!empty($this->pendingNotification)) { $this->notification()->send($this->pendingNotification); $this->pendingNotification = []; } Log::info('[SUCCESS-MODAL] Closed - resetting form fields'); // Reset to-account related fields (always) $this->toAccountId = null; $this->toAccountName = null; $this->toHolderId = null; $this->toHolderType = null; $this->toHolderName = null; $this->toHolderPhoto = null; $this->transactionTypeSelected = null; $this->isConfirmPaymentModalOpen = false; // Reset amount and description (unless remembering payment data) if (!$this->rememberPaymentData) { $this->hours = null; $this->minutes = null; $this->amount = null; $this->description = null; $this->transactionTypeRemembered = null; Log::info('[SUCCESS-MODAL] Reset all form fields (rememberPaymentData=false)'); // Reset the Amount child component $this->dispatch('resetForm')->to(Amount::class); } else { Log::info('[SUCCESS-MODAL] Reset only to-account fields (rememberPaymentData=true)'); } // Refresh ToAccount component $this->dispatch('resetForm')->to(ToAccount::class); } } /** * 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->isConfirmPaymentModalOpen = 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->isConfirmPaymentModalOpen = 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) { $isInternalTransfer = ( session('activeProfileType') === $details['holderType'] && session('activeProfileId') == $details['holderId'] ); if (!$isInternalTransfer && !$this->hasPriorInteraction($details['holderType'], $details['holderId'])) { $this->pendingToAccountDetails = $details; $this->isNewRecipientModalOpen = true; return; } } $this->applyToAccountDetails($details); } /** * Applies the selected to-account details to the form state. * Extracted from toAccountDispatched() so it can be called either * immediately or after the new-recipient confirmation is accepted. * * @param mixed $details * @return void */ private function applyToAccountDetails($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'); } /** * Whether the active profile has ever exchanged a payment (Transaction, either * direction) or a chat conversation with the given holder. * * @param mixed $holderType * @param mixed $holderId * @return bool */ private function hasPriorInteraction($holderType, $holderId): bool { $allowedHolderTypes = [ \App\Models\User::class, \App\Models\Organization::class, \App\Models\Bank::class, ]; $holderId = filter_var($holderId, FILTER_VALIDATE_INT); $activeType = session('activeProfileType'); $activeId = filter_var(session('activeProfileId'), FILTER_VALIDATE_INT); if ( !in_array($holderType, $allowedHolderTypes, true) || !in_array($activeType, $allowedHolderTypes, true) || $holderId === false || $activeId === false ) { // Unrecognised holder/profile type or id — treat as unfamiliar so the // confirmation modal is shown rather than silently trusting bad input. return false; } $hasPayment = Transaction::whereHas('accountFrom', function ($q) use ($activeType, $activeId) { $q->where('accountable_type', $activeType)->where('accountable_id', $activeId); })->whereHas('accountTo', function ($q) use ($holderType, $holderId) { $q->where('accountable_type', $holderType)->where('accountable_id', $holderId); })->exists() || Transaction::whereHas('accountFrom', function ($q) use ($holderType, $holderId) { $q->where('accountable_type', $holderType)->where('accountable_id', $holderId); })->whereHas('accountTo', function ($q) use ($activeType, $activeId) { $q->where('accountable_type', $activeType)->where('accountable_id', $activeId); })->exists(); if ($hasPayment) { return true; } // Only a message actually sent BY the active profile counts as interaction — // a conversation the holder started (or only ever wrote in) without a reply // from the active profile should still trigger the confirmation. return \Namu\WireChat\Models\Message::withoutGlobalScopes() ->where('sendable_type', $activeType) ->where('sendable_id', $activeId) ->whereHas('conversation', function ($q) use ($holderType, $holderId) { $q->where('type', \Namu\WireChat\Enums\ConversationType::PRIVATE) ->whereHas('participants', function ($q2) use ($holderType, $holderId) { $q2->where('participantable_type', $holderType)->where('participantable_id', $holderId); }); }) ->exists(); } /** * Confirms selection of a recipient with no prior chat/payment history. * * @return void */ public function confirmNewRecipient() { $this->applyToAccountDetails($this->pendingToAccountDetails); $this->pendingToAccountDetails = null; $this->isNewRecipientModalOpen = false; } /** * Cancels selection of a recipient with no prior chat/payment history, * clearing both this component's and the ToAccount component's selection state. * * @return void */ public function cancelNewRecipient() { $this->pendingToAccountDetails = null; $this->isNewRecipientModalOpen = false; $this->dispatch('resetForm')->to(ToAccount::class); $this->dispatch('focus-to-account-search'); $this->requiredError = false; $this->toAccountId = null; $this->toAccountName = null; $this->toHolderId = null; $this->toHolderType = null; $this->toHolderName = null; $this->toHolderPhoto = null; $this->typeOptions = null; $this->dispatch('transactionTypeOptions', null, null); $this->resetErrorBag('toAccountId'); } /** * Treat dismissing the modal via backdrop/ESC the same as clicking Cancel. * * @param mixed $value * @return void */ public function updatedIsNewRecipientModalOpen($value) { if (!$value && $this->pendingToAccountDetails) { $this->cancelNewRecipient(); } } /** * 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 showConfirmPaymentModal() { // If success modal is already open, ignore form submission // (the form may auto-submit during re-rendering while success modal is showing) if ($this->isPaymentSuccessModalOpen) { Log::info('[CONFIRM-MODAL] Ignored (success modal already open) - ignoring'); return; } Log::info('[CONFIRM-MODAL] Entry', [ 'fromAccountId' => $this->fromAccountId, 'toAccountId' => $this->toAccountId, 'amount' => $this->amount, ]); 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; } Log::info('[CONFIRM-MODAL] Balance check', [ 'balanceFrom' => $balanceFrom, 'limitMinFrom' => $limitMinFrom, 'transferBudgetFrom' => $transferBudgetFrom, 'amount' => $amount, 'transferBudgetTo' => $transferBudgetTo, ]); $this->checkBalanceLimits($amount, $transferBudgetTo, $transferBudgetFrom, $limitMinFrom, $balanceToPublic); Log::info('[CONFIRM-MODAL] After balance check', [ 'limitError' => $this->limitError, 'isPaymentLimitErrorModalOpen' => $this->isPaymentLimitErrorModalOpen, 'isConfirmPaymentModalOpen' => $this->isConfirmPaymentModalOpen, ]); $this->isConfirmPaymentModalOpen = true; } } /** * Create transfer, output success / error message and reset from. * * @return void */ public function doTransfer() { Log::info('[TRANSFER] entry - clearing error states'); $this->isConfirmPaymentModalOpen = false; $this->isPaymentLimitErrorModalOpen = false; $this->limitError = null; $fromAccountId = $this->fromAccountId; $toAccountId = $this->toAccountId; $amount = $this->amount; $description = $this->description; $transactionTypeId = $this->transactionTypeSelected['id']; Log::info('[TRANSFER] transaction details', [ 'fromAccountId' => $fromAccountId, 'toAccountId' => $toAccountId, 'amount' => $amount, 'transactionTypeId' => $transactionTypeId, ]); // Security: Validate amount is a valid positive number if (!is_numeric($amount) || $amount <= 0) { $warningMessage = 'Security: Invalid payment amount (must be positive number)'; return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description); } // Security: Verify fromAccountId is an integer (prevent tampering) if (!is_int($fromAccountId) || $fromAccountId <= 0) { $warningMessage = 'Security: Invalid From account ID'; return $this->logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description); } // 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); } // Security: Verify toAccountId is an integer (prevent tampering) if (!is_int($toAccountId) || $toAccountId <= 0) { $warningMessage = 'Security: Invalid To account ID'; 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. The allowed types are derived on the server from // config and the stored account holders: typeOptionsProtected is a public property and can be // rewritten by the client. Internal transfers between accounts of the same holder are Migration (6). $isInternalTransfer = ( $toAccountExists->accountable_type === $fromAccountExists->accountable_type && $toAccountExists->accountable_id == $fromAccountExists->accountable_id ); // Same rules as applyToAccountDetails(), which fills the type options in the form $canPay = timebank_config('permissions.' . strtolower(class_basename(session('activeProfileType'))) . '.payment_types') ?? []; $canReceive = timebank_config('accounts.' . strtolower(class_basename($toAccountExists->accountable_type)) . '.receiving_types') ?? []; $allowedTypeIds = array_intersect($canPay, $canReceive); if ($isInternalTransfer && !in_array(6, $allowedTypeIds)) { $allowedTypeIds = [6]; } if (!in_array($transactionTypeId, $allowedTypeIds)) { $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 - MUST pass before proceeding with payment // This prevents tampered front-end requests from bypassing validation $limitsOk = $this->checkBalanceLimits($amount, $transferBudgetTo, $transferBudgetFrom, $limitMinFrom, $balanceToPublic); if (!$limitsOk) { $warningMessage = 'Security: Payment attempt with exceeded balance limits detected'; Log::warning('[TRANSFER] ' . $warningMessage, [ 'fromAccountId' => $fromAccountId, 'toAccountId' => $toAccountId, 'amount' => $amount, 'transferBudgetFrom' => $transferBudgetFrom, 'transferBudgetTo' => $transferBudgetTo, ]); return; } // 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(); Log::info('[PAY] Transaction committed to database'); Log::info('[PAY] TransactionObserver will automatically clear caches on transaction creation'); // Store WireUI notification — sent after the success modal is closed $this->pendingNotification = [ 'icon' => 'success', 'title' => __('Transaction done!'), 'description' => __('messages.payment.success', [ 'amount' => tbFormat($amount), 'account_name' => $this->toAccountName, 'holder_name' => $this->toHolderName, 'transaction_url' => route('transaction.show', ['transactionId' => $transfer->id]), 'transaction_id' => $transfer->id, ]), 'timeout' => 20000, ]; // Dispatch refresh to FromAccount to clear its stale property cache // The dispatch will trigger updatedRefreshTrigger which calls resetForm() $this->dispatch('fromAccountRefresh'); Log::info('[TRANSFER] Dispatched FromAccount refresh'); // THEN: Prepare and show the success modal $transactionUrl = route('transaction.show', ['transactionId' => $transfer->id]); $this->successHolderName = $this->toHolderName ?? ''; $this->successHolderPhoto = $this->toHolderPhoto ?? ''; $this->successAmount = tbFormat($amount); $this->successAccountName = $this->toAccountName ?? ''; $this->successDescription = $description ?? ''; $this->successTransactionUrl = $transactionUrl; $this->successQrSvg = (string) QrCode::size(200)->errorCorrection('L')->color(17, 24, 39)->generate($transactionUrl); $this->isPaymentSuccessModalOpen = true; Log::info('[SUCCESS-MODAL] Opening with refreshed account balances'); // Don't reset form here - wait until success modal closes to avoid triggering showModal() again // Store which transaction type to remember for next payment if ($this->rememberPaymentData) { $this->transactionTypeRemembered = $this->transactionTypeSelected; Log::info('[SUCCESS-MODAL] Stored transaction type for next payment (rememberPaymentData=true)'); } else { Log::info('[SUCCESS-MODAL] Form will be reset when modal closes (rememberPaymentData=false)'); } // 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; $payChatMessageKey = payChatMessageKey($sender); if (!Lang::has($payChatMessageKey, $messageLocale)) { // Check if the translation key exists for the selected locale $messageLocale = config('app.fallback_locale'); // Fallback to the app's default locale } $chatMessage = __($payChatMessageKey, [ 'amount' => tbFormat($amount), 'account_name' => $this->toAccountName, ], $messageLocale); $chatTransactionStatement = LaravelLocalization::getURLFromRouteNameTranslated($messageLocale, 'routes.statement', array('transactionId' => $transfer->id)); // Get or create conversation once, reuse for both messages $conversation = $sender->createConversationWith($recipient); // Send text notification message $message = $sender->sendMessageTo($recipient, $chatMessage); broadcast(new \Namu\WireChat\Events\MessageCreated($message)); \Namu\WireChat\Jobs\NotifyParticipants::dispatch($conversation, $message); // Send QR code as image attachment (SVG format, renders in tags) $qrStatementUrl = route('transaction.show', ['transactionId' => $transfer->id]); $qrSvg = QrCode::size(150)->errorCorrection('L')->color(17, 24, 39)->generate($qrStatementUrl); $storageDisk = config('wirechat.attachments.storage_disk', 'public'); $storageFolder = config('wirechat.attachments.storage_folder', 'attachments'); $qrFileName = 'qr-transaction-' . $transfer->id . '-' . time() . '.svg'; $qrFilePath = $storageFolder . '/' . $qrFileName; Storage::disk($storageDisk)->put($qrFilePath, $qrSvg); $qrMessage = Message::create([ 'conversation_id' => $conversation->id, 'sendable_type' => $sender->getMorphClass(), 'sendable_id' => $sender->id, 'body' => $chatTransactionStatement, 'type' => MessageType::ATTACHMENT, ]); $qrMessage->attachment()->create([ 'file_path' => $qrFilePath, 'file_name' => $qrFileName, 'original_name' => $qrFileName, 'mime_type' => 'image/svg+xml', 'url' => Storage::disk($storageDisk)->url($qrFilePath), ]); $conversation->updated_at = now(); $conversation->save(); broadcast(new \Namu\WireChat\Events\MessageCreated($qrMessage)); \Namu\WireChat\Jobs\NotifyParticipants::dispatch($conversation, $qrMessage); // 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) { Log::error('[TRANSFER] exception', [ 'exception' => get_class($e), 'message' => $e->getMessage(), 'line' => $e->getLine(), ]); 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. */ /** * Check balance limits and return whether payment is allowed. * Returns true if limits are OK, false if limits exceeded. */ private function checkBalanceLimits($amount, $transferBudgetTo, $transferBudgetFrom, $limitMinFrom, $balanceToPublic): bool { Log::info('[BALANCE-CHECK] Entry', [ 'amount' => $amount, 'transferBudgetTo' => $transferBudgetTo, 'transferBudgetFrom' => $transferBudgetFrom, ]); if ($amount > $transferBudgetFrom && $amount > $transferBudgetTo && $transferBudgetFrom <= $transferBudgetTo) { $this->limitError = __('messages.pay_limit_error_budget_from', [ 'limitMinFrom' => tbFormat($limitMinFrom), 'transferBudgetFrom' => tbFormat($transferBudgetFrom), ]); Log::warning('[ERROR-MODAL] Limit exceeded: budget_from', ['limitError' => $this->limitError]); $this->isPaymentLimitErrorModalOpen = true; return false; } 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), ]); } Log::warning('[ERROR-MODAL] Limit exceeded: budget_from_and_to', ['limitError' => $this->limitError]); $this->isPaymentLimitErrorModalOpen = true; return false; } if ($amount > $transferBudgetFrom) { $this->limitError = __('messages.pay_limit_error_budget_from', [ 'limitMinFrom' => tbFormat($limitMinFrom), 'transferBudgetFrom' => tbFormat($transferBudgetFrom), ]); Log::warning('[ERROR-MODAL] Limit exceeded: budget_from', ['limitError' => $this->limitError]); $this->isPaymentLimitErrorModalOpen = true; return false; } 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, ]); } Log::warning('[ERROR-MODAL] Limit exceeded: budget_to', ['limitError' => $this->limitError]); $this->isPaymentLimitErrorModalOpen = true; return false; } Log::info('[BALANCE-CHECK] Passed - no limit exceeded'); $this->limitError = null; $this->isPaymentLimitErrorModalOpen = false; return true; } /** * 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); }, ); // redirect()->back() is a no-op inside a Livewire action and its flash is cleared, // so tell the user directly. Failures with an exception have already notified. if ($error === '') { $this->notification()->send([ 'title' => __('Transaction failed!'), 'description' => __($warningMessage) . '. ' . __('This event has been logged and reported to our system administrator') . '.', 'icon' => 'error', ]); } } 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->hours = null; $this->minutes = null; $this->amount = null; $this->description = null; $this->transactionTypeRemembered = null; } $this->isConfirmPaymentModalOpen = 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'); } }