422 lines
19 KiB
PHP
422 lines
19 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Mail\TransferReceived;
|
|
use App\Models\Account;
|
|
use App\Models\Bank;
|
|
use App\Models\Organization;
|
|
use App\Models\Transaction;
|
|
use App\Models\TransactionType;
|
|
use App\Models\User;
|
|
use App\Traits\AccountInfoTrait;
|
|
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 Illuminate\Http\Request;
|
|
use Mcamara\LaravelLocalization\Facades\LaravelLocalization;
|
|
use Namu\WireChat\Events\NotifyParticipant;
|
|
use Stevebauman\Location\Facades\Location as IpLocation;
|
|
|
|
class TransactionController extends Controller
|
|
{
|
|
use AccountInfoTrait;
|
|
|
|
|
|
/**
|
|
* Create a new controller instance.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function __construct()
|
|
{
|
|
$this->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 . '.' . '<br /><br />' . '<a href="' . route('transaction.show', ['transactionId' => $transfer->id]) . '">' . __('Show Transaction # ') . $transfer->id . '</a>';
|
|
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') . '.');
|
|
}
|
|
}
|