1035 lines
44 KiB
PHP
1035 lines
44 KiB
PHP
<?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\Enums\MessageType;
|
|
use Namu\WireChat\Events\NotifyParticipant;
|
|
use Namu\WireChat\Models\Attachment;
|
|
use Namu\WireChat\Models\Message;
|
|
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
|
use Illuminate\Support\Facades\Storage;
|
|
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 $isConfirmPaymentModalOpen = false;
|
|
public $isPaymentLimitErrorModalOpen = false;
|
|
public $isNewRecipientModalOpen = false;
|
|
public $pendingToAccountDetails = null;
|
|
public $rememberPaymentData = false;
|
|
public $transactionTypeRemembered;
|
|
|
|
// Payment success modal
|
|
public bool $isPaymentSuccessModalOpen = false;
|
|
public string $successHolderName = '';
|
|
public string $successHolderPhoto = '';
|
|
public string $successAmount = '';
|
|
public string $successAccountName = '';
|
|
public string $successDescription = '';
|
|
public string $successTransactionUrl = '';
|
|
public string $successQrSvg = '';
|
|
public array $pendingNotification = [];
|
|
|
|
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->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 <img> 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');
|
|
}
|
|
}
|