middleware('auth'); } public function pay() { $toName = null; return view('pay.show', compact(['toName'])); } public function payToName($name = null) { return view('pay.show', compact(['name'])); } public function payAmountToName($hours = null, $minutes = null, $name = null) { return view('pay.show', compact(['hours', 'minutes', 'name'])); } public function payAmountToNameWithDescr($hours = null, $minutes = null, $name = null, $description = null) { return view('pay.show', compact(['hours', 'minutes', 'name', 'description'])); } // Legacy Cyclos payment link, as used by Lekkernasuh public function doCyclosPayment(Request $request, $minutes = null, $toAccoundId = null, $name = null, $description = null, $type = null) { $cyclos_id = $request->query('to'); // Amount is in integer minutes $minutes = (int) $request->query('amount'); $toAccountId = null; $name = null; if ($cyclos_id) { $accounts = Account::accountsCyclosMember($cyclos_id); if (count($accounts) > 1) { // More than 1 account found with this cyclos_id — search by name instead $name = $this->getNameByCyclosId($cyclos_id); } else { $toAccountId = $accounts->keys()->first(); } } $description = $request->query('description'); $type = $request->query('type'); return view('pay.show', compact(['minutes', 'toAccountId', 'name', 'description', 'type'])); } public static function getNameByCyclosId($cyclos_id) { $user = User::where('cyclos_id', $cyclos_id)->first(); if ($user) { return $user->name; } $organization = Organization::where('cyclos_id', $cyclos_id)->first(); if ($organization) { return $organization->name; } $bank = Bank::where('cyclos_id', $cyclos_id)->first(); if ($bank) { return $bank->name; } return null; } public function transactions() { $profileAccounts = $this->getAccountsInfo(); return view('transactions.show', compact('profileAccounts')); } public function statement($transactionId) { $results = Transaction::with('accountTo.accountable', 'accountFrom.accountable') ->where('id', $transactionId) ->whereHas('accountTo', function ($query) { $query->where('accountable_type', Session('activeProfileType')) ->where('accountable_id', Session('activeProfileId')); }) ->orWhereHas('accountFrom.accountable', function ($query) { $query->where('accountable_type', Session('activeProfileType')) ->where('accountable_id', Session('activeProfileId')); }) ->find($transactionId); $qrModalVisible = request()->query('qrModalVisible', false); //TODO: add permission check //TODO: if 403, but has permission, redirect with message to switch profile //TODO: replace 403 with custom redirect page incl explanation // Check if the transaction exists if ($transactionId) { // Pass the transactionId and transaction details to the view return view('transactions.statement', compact('transactionId', 'qrModalVisible')); } else { // Abort with a 403 status code if the transaction does not exist // TODO to static page explaining that the transactionId is not accessible for current profile. return abort(403, 'Unauthorized action.'); } } /** * Create a new transaction with all necessary checks. * This is a non-Livewire version of the transfer logic. * * @param int $fromAccountId * @param int $toAccountId * @param int $amount * @param string $description * @param int $transactionTypeId * @return \Illuminate\Http\RedirectResponse */ public function createTransfer($fromAccountId, $toAccountId, $amount, $description, $transactionTypeId) { // 1. SECURITY CHECKS $fromAccountableType = Account::find($fromAccountId)->accountable_type; $fromAccountableId = Account::find($fromAccountId)->accountable_id; $accountsInfo = collect($this->getAccountsInfo($fromAccountableType, $fromAccountableId)); if (!$accountsInfo->contains('id', $fromAccountId)) { return $this->logAndReport('Unauthorized payment attempt: illegal access of From account', $fromAccountId, $toAccountId, $amount, $description); } // Check if From and To Account is different if ($toAccountId == $fromAccountId) { return $this->logAndReport('Impossible payment attempt: To and From account are the same', $fromAccountId, $toAccountId, $amount, $description); } // Check if the To Account exists and is not removed $toAccount = Account::notRemoved()->find($toAccountId); if (!$toAccount) { return $this->logAndReport('Impossible payment attempt: To account not found', $fromAccountId, $toAccountId, $amount, $description); } // 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 $fromAccount = Account::notRemoved()->find($fromAccountId); if (!$fromAccount) { return $this->logAndReport('Impossible payment attempt: From account not found', $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 transactionTypeSelected is allowed, with an exception for internal migration (type 6) $toHolderType = $toAccount->accountable_type; $toHolderId = $toAccount->accountable_id; $isInternalMigration = ( $fromAccountableType === $toHolderType && $fromAccountableId == $toHolderId ); // Only perform the check if it's not the specific internal migration case if (!$isInternalMigration) { $canReceive = timebank_config('accounts.' . strtolower(class_basename($toHolderType)) . '.receiving_types'); $canPay = timebank_config('permissions.' . strtolower(class_basename($fromAccountableType)) . '.payment_types'); $allowedTypeIds = array_intersect($canPay, $canReceive); if (!in_array($transactionTypeId, $allowedTypeIds)) { $transactionTypeName = TransactionType::find($transactionTypeId)->name ?? 'id: ' . $transactionTypeId; return $this->logAndReport('Impossible payment attempt: transaction type not allowed', $fromAccountId, $toAccountId, $amount, $description, $transactionTypeName); } } else { $transactionTypeId = 6; // Enforce transactionTypeId 6 (migration) for internal transactions } // 2. BALANCE LIMIT CHECKS $fromAccount = Account::find($fromAccountId); $limitMinFrom = $fromAccount->limit_min; $effectiveLimitMaxTo = $toAccount->limit_max - $toAccount->limit_min; $balanceFrom = $this->getBalance($fromAccountId); $balanceTo = $this->getBalance($toAccountId); $transferBudgetFrom = $balanceFrom - $limitMinFrom; $balanceToPublic = timebank_config('account_info.' . strtolower(class_basename($toHolderType)) . '.balance_public'); $transferBudgetTo = $effectiveLimitMaxTo - $balanceTo; $limitError = $this->checkBalanceLimits($amount, $transferBudgetTo, $transferBudgetFrom, $limitMinFrom, $balanceToPublic, $toAccount->accountable->name); if ($limitError) { return redirect()->back()->with('error', $limitError); } // 3. CLEAN UP DESCRIPTION // Get the validation rule string from the config $rule = timebank_config('payment.description_rule'); preg_match('/max:(\d+)/', $rule, $matches); $maxLength = $matches[1] ?? 500; // Use Str::limit to truncate the description if it's too long, adding ' ...' $description = \Illuminate\Support\Str::limit($description, $maxLength, ' ...'); // 4. DATABASE TRANSACTION DB::beginTransaction(); try { $transfer = new Transaction(); $transfer->from_account_id = $fromAccountId; $transfer->to_account_id = $toAccountId; $transfer->amount = $amount; $transfer->description = $description; $transfer->transaction_type_id = $transactionTypeId; $transfer->creator_user_id = null; $save = $transfer->save(); if ($save) { DB::commit(); // 4. POST-TRANSACTION ACTIONS // 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 } // Translate the account name using the RECIPIENT'S language $toAccountName = __(ucfirst(strtolower($transfer->accountTo->name)), [], $messageLocale); $chatMessage = __('messages.pay_chat_message', [ 'amount' => tbFormat($amount), 'account_name' => $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(0), new TransferReceived($transfer, $messageLocale)); info(1); } } // 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 } $successMessage = tbFormat($amount) . __('was paid to the ') . $toAccountName . __(' of ') . $recipient->name . '.' . '

' . '' . __('Show Transaction # ') . $transfer->id . ''; return redirect()->back()->with('success', $successMessage); } else { throw new \Exception('Transaction could not be saved'); } } catch (\Exception $e) { DB::rollBack(); $this->logAndReport('Transaction failed', $fromAccountId, $toAccountId, $amount, $description, '', $e->getMessage()); return redirect()->back()->with('error', __('Sorry we have an error: this transaction could not be saved!')); } } /** * Helper to check balance limits for a transfer operation. * Returns an error string if a limit is exceeded, otherwise null. */ private function checkBalanceLimits($amount, $transferBudgetTo, $transferBudgetFrom, $limitMinFrom, $balanceToPublic, $toHolderName) { if ($amount > $transferBudgetFrom && $amount > $transferBudgetTo && $transferBudgetFrom <= $transferBudgetTo) { return __('messages.pay_limit_error_budget_from', [ 'limitMinFrom' => tbFormat($limitMinFrom), 'transferBudgetFrom' => tbFormat($transferBudgetFrom), ]); } if ($amount > $transferBudgetFrom && $amount > $transferBudgetTo && $transferBudgetFrom > $transferBudgetTo) { return $balanceToPublic ? __('messages.pay_limit_error_budget_from_and_to', [ 'limitMinFrom' => tbFormat($limitMinFrom), 'transferBudgetTo' => tbFormat($transferBudgetTo), ]) : __('messages.pay_limit_error_budget_from_and_to_without_budget_to', [ 'limitMinFrom' => tbFormat($limitMinFrom), ]); } if ($amount > $transferBudgetFrom) { return __('messages.pay_limit_error_budget_from', [ 'limitMinFrom' => tbFormat($limitMinFrom), 'transferBudgetFrom' => tbFormat($transferBudgetFrom), ]); } if ($amount > $transferBudgetTo) { return $balanceToPublic ? __('messages.pay_limit_error_budget_to', [ 'transferBudgetTo' => tbFormat($transferBudgetTo), ]) : __('messages.pay_limit_error_budget_to_without_budget_to', [ 'transferBudgetTo' => tbFormat($transferBudgetTo), 'toHolderName' => $toHolderName, ]); } return null; // No error } /** * Helper to log a warning message and report it via email to the system administrator. * Returns a redirect response with an error message. */ private function logAndReport($warningMessage, $fromAccountId, $toAccountId, $amount, $description, $transactionType = '', $error = '') { $ip = request()->ip(); $ipLocationInfo = IpLocation::get($ip); if (!$ipLocationInfo || App::environment(['local', 'development', 'staging'])) { $ipLocationInfo = (object) ['cityName' => 'local City', 'regionName' => 'local Region', 'countryName' => 'local Country']; } $eventTime = now()->toDateTimeString(); $fromAccountHolder = Account::find($fromAccountId)->accountable()->value('name') ?? 'N/A'; $toAccountHolder = Account::find($toAccountId)->accountable()->value('name') ?? 'N/A'; Log::warning($warningMessage, [ 'fromAccountId' => $fromAccountId, 'fromAccountHolder' => $fromAccountHolder, 'toAccountId' => $toAccountId, 'toAccountHolder' => $toAccountHolder, '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, ]); $rawMessage = "{$warningMessage}.\n\n" . "From Account ID: {$fromAccountId}\nFrom Account Holder: {$fromAccountHolder}\n" . "To Account ID: {$toAccountId}\nTo Account Holder: {$toAccountHolder}\n" . "Amount: {$amount}\n" . "Description: {$description}\n" . "User ID: " . Auth::id() . "\nUser Name: " . Auth::user()->name . "\n" . "Active Profile ID: " . session('activeProfileId') . "\nActive Profile Type: " . session('activeProfileType') . "\nActive Profile Name: " . session('activeProfileName') . "\n" . "Transaction Type: " . ucfirst($transactionType) . "\n" . "IP address: {$ip}\nIP location: {$ipLocationInfo->cityName}, {$ipLocationInfo->regionName}, {$ipLocationInfo->countryName}\n" . "Event Time: {$eventTime}\n\n{$error}"; Mail::raw($rawMessage, 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') . '.'); } }