Merge remote-tracking branch 'origin/montify' into Abshode_Khanegi
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ConfigService;
|
||||
use App\Services\Payment\Support\MontifyHmacSigner;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* عیبیابی امضای بازگشت Montify: نشان میدهد امضای دریافتی با کدام قالب payload میخواند.
|
||||
* مثال:
|
||||
* php artisan montify:check-return "https://site.ir/wallet/payment-result?status=success&..."
|
||||
*/
|
||||
class MontifyCheckReturnCommand extends Command
|
||||
{
|
||||
protected $signature = 'montify:check-return {url : آدرس کامل بازگشت یا فقط query string}';
|
||||
|
||||
protected $description = 'بررسی امضای بازگشت درگاه Montify و نمایش رشته canonical هر قالب';
|
||||
|
||||
public function handle(ConfigService $config): int
|
||||
{
|
||||
$input = (string) $this->argument('url');
|
||||
$queryString = str_contains($input, '?') ? substr($input, strpos($input, '?') + 1) : $input;
|
||||
|
||||
parse_str($queryString, $query);
|
||||
if (!is_array($query) || $query === []) {
|
||||
$this->error('پارامتری در ورودی پیدا نشد.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$clientId = $config->getString('external_payment_client_id', '');
|
||||
$clientSecret = $config->getString('external_payment_client_secret', '');
|
||||
if ($clientSecret === '') {
|
||||
$this->error('external_payment_client_secret در تنظیمات خالی است.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$signature = strtolower((string) ($query['signature'] ?? ''));
|
||||
unset($query['signature']);
|
||||
|
||||
$strings = [];
|
||||
foreach ($query as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
$strings[$key] = (string) $value;
|
||||
}
|
||||
$withoutEmpty = array_filter($strings, static fn ($value) => $value !== '');
|
||||
|
||||
$signer = new MontifyHmacSigner($clientId, $clientSecret);
|
||||
$castNumeric = static function (array $payload) {
|
||||
foreach (['amount', 'timestamp'] as $key) {
|
||||
if (isset($payload[$key]) && ctype_digit((string) $payload[$key])) {
|
||||
$payload[$key] = (int) $payload[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
};
|
||||
|
||||
$variants = [
|
||||
'strings' => $strings,
|
||||
'strings_without_empty' => $withoutEmpty,
|
||||
'numeric' => $castNumeric($strings),
|
||||
'numeric_without_empty' => $castNumeric($withoutEmpty),
|
||||
];
|
||||
|
||||
$this->line('');
|
||||
$this->info('امضای دریافتی: ' . ($signature !== '' ? $signature : '— (خالی)'));
|
||||
$this->line('طول client_secret: ' . strlen($clientSecret) . ' کاراکتر');
|
||||
$this->line('');
|
||||
|
||||
$matched = null;
|
||||
foreach ($variants as $name => $payload) {
|
||||
$canonical = $signer->stableStringify($payload);
|
||||
$expected = hash_hmac('sha256', $canonical, $clientSecret);
|
||||
$ok = $signature !== '' && hash_equals($expected, $signature);
|
||||
if ($ok && $matched === null) {
|
||||
$matched = $name;
|
||||
}
|
||||
|
||||
$this->line(($ok ? '[OK] ' : '[خطا] ') . $name);
|
||||
$this->line(' canonical: ' . $canonical);
|
||||
$this->line(' expected : ' . $expected);
|
||||
$this->line('');
|
||||
}
|
||||
|
||||
if ($matched !== null) {
|
||||
$this->info('امضا با قالب «' . $matched . '» میخواند.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->error('هیچ قالبی با امضا نخواند — احتمالاً client_secret اشتباه است یا پارامتری از URL حذف شده.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -30,25 +30,25 @@ class SettingsController extends Controller
|
||||
->orderBy('key');
|
||||
|
||||
if ($request->boolean('system_only')) {
|
||||
return response()->json(
|
||||
$query
|
||||
->where(function ($q) {
|
||||
$q->where('can_edit', 0)->orWhere('can_edit', false);
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->where('is_active', 1)
|
||||
->orWhere('is_active', true)
|
||||
->orWhere('is_active', '1');
|
||||
})
|
||||
->get()
|
||||
);
|
||||
$rows = $query
|
||||
->where(function ($q) {
|
||||
$q->where('can_edit', 0)->orWhere('can_edit', false);
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->where('is_active', 1)
|
||||
->orWhere('is_active', true)
|
||||
->orWhere('is_active', '1');
|
||||
})
|
||||
->get();
|
||||
|
||||
return response()->json($this->authorizeSettingsPayload($rows));
|
||||
}
|
||||
|
||||
if ($request->boolean('editable_only')) {
|
||||
return response()->json($query->where('can_edit', 1)->get());
|
||||
return response()->json($this->authorizeSettingsPayload($query->where('can_edit', 1)->get()));
|
||||
}
|
||||
|
||||
return response()->json($query->get());
|
||||
return response()->json($this->authorizeSettingsPayload($query->get()));
|
||||
}
|
||||
|
||||
public function edit(Request $request)
|
||||
@@ -134,4 +134,20 @@ class SettingsController extends Controller
|
||||
{
|
||||
return in_array($key, self::PANEL_MANAGED_KEYS, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* اپ کاربر (بدون auth ادمین) هرگز secret/credential نمیبیند.
|
||||
* پنل ادمین با auth:user همه کلیدها را میبیند.
|
||||
*
|
||||
* @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Collection|iterable $rows
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
private function authorizeSettingsPayload($rows)
|
||||
{
|
||||
if (auth('user')->check()) {
|
||||
return collect($rows)->values();
|
||||
}
|
||||
|
||||
return Setting::forPublicApi($rows);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\pay;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Withdraw;
|
||||
use App\Services\ConfigService;
|
||||
use App\Services\Payment\Contracts\SupportsStatusQuery;
|
||||
use App\Services\Payment\WalletDepositCreditor;
|
||||
use App\Services\Payment\WalletPaymentManager;
|
||||
use Illuminate\Http\Request;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
/**
|
||||
* API عمومی شارژ آنلاین کیف — بدون افشای credential به کلاینت.
|
||||
* انتخاب درگاه فقط سمت سرور (wallet_payment_provider).
|
||||
*/
|
||||
class WalletPaymentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private WalletPaymentManager $payments,
|
||||
private ConfigService $config,
|
||||
private WalletDepositCreditor $creditor,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* فقط فلگ نمایش دکمه — بدون نام درگاه و بدون تنظیمات محرمانه.
|
||||
*/
|
||||
public function options()
|
||||
{
|
||||
return response()->json($this->payments->publicOptions());
|
||||
}
|
||||
|
||||
public function initiate(Request $request)
|
||||
{
|
||||
$gateway = $this->payments->resolve();
|
||||
if (!$gateway || !$gateway->isAvailable()) {
|
||||
throw new UnprocessableEntityHttpException('پرداخت آنلاین در حال حاضر فعال نیست');
|
||||
}
|
||||
|
||||
$amount = (int) $request->input('value', $request->input('amount', 0));
|
||||
$minDeposit = $this->config->getInt('min_deposit', 0);
|
||||
if ($amount < 1000) {
|
||||
throw new UnprocessableEntityHttpException('حداقل مبلغ پرداخت ۱۰۰۰ ریال است');
|
||||
}
|
||||
if ($minDeposit > 0 && $amount < $minDeposit) {
|
||||
throw new UnprocessableEntityHttpException(
|
||||
'حداقل مقدار واریز ' . number_format($minDeposit) . ' ریال است'
|
||||
);
|
||||
}
|
||||
|
||||
$customer = auth('customer')->user();
|
||||
$withdraw = Withdraw::create([
|
||||
'customer_id' => $customer->id,
|
||||
'cash' => $amount,
|
||||
'note' => 'واریز آنلاین',
|
||||
'sts' => 1,
|
||||
]);
|
||||
|
||||
$referenceId = 'WD-' . $withdraw->id;
|
||||
|
||||
try {
|
||||
$result = $gateway->initiate([
|
||||
'amount' => $amount,
|
||||
'reference_id' => $referenceId,
|
||||
'description' => 'شارژ کیف پول کاربر ' . ($customer->name ?? $customer->id),
|
||||
'metadata' => [
|
||||
'withdraw_id' => $withdraw->id,
|
||||
'customer_id' => $customer->id,
|
||||
],
|
||||
]);
|
||||
} catch (RuntimeException $e) {
|
||||
$withdraw->sts = 3;
|
||||
$withdraw->save();
|
||||
|
||||
\Log::warning('wallet.payment.initiate_failed', [
|
||||
'withdraw_id' => $withdraw->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => $e->getMessage() ?: 'مشکل در ارتباط با درگاه',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (!empty($result['session_id'])) {
|
||||
$withdraw->tracked_number = (string) $result['session_id'];
|
||||
$withdraw->save();
|
||||
}
|
||||
|
||||
// حداقل اطلاعات برای redirect — بدون credential و بدون جزئیات داخلی درگاه
|
||||
return response()->json([
|
||||
'payment_url' => $result['payment_url'],
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* وریفای برگشت مرورگر از فرانت — امضا و شارژ سمت سرور؛ بدون افشای secret.
|
||||
* Body یا query: status, reference_id, session_id, amount, track_number, timestamp, signature
|
||||
*/
|
||||
public function verifyReturn(Request $request)
|
||||
{
|
||||
$gateway = $this->payments->resolve();
|
||||
if (!$gateway) {
|
||||
throw new UnprocessableEntityHttpException('پرداخت آنلاین فعال نیست');
|
||||
}
|
||||
|
||||
// همه پارامترهای برگشت عیناً وارد امضا میشوند؛ whitelist کردن باعث
|
||||
// مغایرت امضا میشود اگر پارتنر پارامتر اضافهای بفرستد.
|
||||
$payload = [];
|
||||
foreach ($request->all() as $key => $value) {
|
||||
if (!is_string($key) || $key === '' || is_array($value) || $value === null) {
|
||||
continue;
|
||||
}
|
||||
if (count($payload) >= 30) {
|
||||
break;
|
||||
}
|
||||
$payload[$key] = is_bool($value) ? ($value ? 'true' : 'false') : (string) $value;
|
||||
}
|
||||
|
||||
if (($payload['signature'] ?? '') === '' || ($payload['reference_id'] ?? '') === '') {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'پارامترهای بازگشت ناقص است',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$result = $gateway->handleReturnPayload($payload);
|
||||
|
||||
if (!($result['ok'] ?? false)) {
|
||||
// redirect مرورگر قابل اعتماد نیست؛ منبع حقیقت، استعلام مستقیم از درگاه است.
|
||||
$fallback = $this->statusFallback((string) $payload['reference_id']);
|
||||
if ($fallback) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => $result['message'] ?? 'خطا در تأیید پرداخت',
|
||||
], (int) ($result['http_status'] ?? 400));
|
||||
}
|
||||
|
||||
/** @var Withdraw|null $withdraw */
|
||||
$withdraw = $result['withdraw'] ?? null;
|
||||
if (!$withdraw) {
|
||||
return response()->json(['ok' => false, 'message' => 'سفارش یافت نشد'], 404);
|
||||
}
|
||||
|
||||
if (!$this->ownsWithdraw($withdraw)) {
|
||||
return response()->json(['ok' => false, 'message' => 'دسترسی غیرمجاز'], 403);
|
||||
}
|
||||
|
||||
$paid = (int) $withdraw->sts === 2;
|
||||
|
||||
return $this->paymentStateResponse(
|
||||
$withdraw,
|
||||
$paid ? 'success' : ((string) ($payload['status'] ?? '') === 'success' ? 'pending' : 'failed')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* استعلام وضعیت یک پرداخت از درگاه — برای polling فرانت وقتی نتیجه هنوز قطعی نیست.
|
||||
*/
|
||||
public function status(Request $request)
|
||||
{
|
||||
$referenceId = trim((string) $request->input('reference_id', ''));
|
||||
if ($referenceId === '') {
|
||||
return response()->json(['ok' => false, 'message' => 'شناسه پرداخت ارسال نشده'], 422);
|
||||
}
|
||||
|
||||
$response = $this->statusFallback($referenceId);
|
||||
if ($response) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return response()->json(['ok' => false, 'message' => 'سفارش یافت نشد'], 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* استعلام مستقیم وضعیت از درگاه با بررسی مالکیت سفارش.
|
||||
*/
|
||||
private function statusFallback(string $referenceId): ?\Illuminate\Http\JsonResponse
|
||||
{
|
||||
$withdraw = $this->creditor->findByReference($referenceId);
|
||||
if (!$withdraw || !$this->ownsWithdraw($withdraw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int) $withdraw->sts !== 2) {
|
||||
$gateway = $this->payments->resolve();
|
||||
if ($gateway instanceof SupportsStatusQuery) {
|
||||
$gateway->syncStatusByReference($referenceId);
|
||||
$withdraw = $withdraw->fresh() ?: $withdraw;
|
||||
}
|
||||
}
|
||||
|
||||
$sts = (int) $withdraw->sts;
|
||||
|
||||
return $this->paymentStateResponse(
|
||||
$withdraw,
|
||||
match ($sts) {
|
||||
2 => 'success',
|
||||
3 => 'failed',
|
||||
default => 'pending',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private function ownsWithdraw(Withdraw $withdraw): bool
|
||||
{
|
||||
return (int) $withdraw->customer_id === (int) auth('customer')->id();
|
||||
}
|
||||
|
||||
private function paymentStateResponse(Withdraw $withdraw, string $status): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'paid' => (int) $withdraw->sts === 2,
|
||||
'status' => $status,
|
||||
'amount' => (int) $withdraw->cash,
|
||||
'reference_id' => 'WD-' . $withdraw->id,
|
||||
'withdraw_id' => $withdraw->id,
|
||||
'track_number' => $withdraw->tracked_number,
|
||||
]);
|
||||
}
|
||||
|
||||
public function returnFromGateway(Request $request, string $provider)
|
||||
{
|
||||
try {
|
||||
$gateway = $this->payments->resolveOrFail($provider);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return response('درگاه نامعتبر', 404);
|
||||
}
|
||||
|
||||
$result = $gateway->handleReturn($request);
|
||||
if (!($result['ok'] ?? false)) {
|
||||
return response(
|
||||
(string) ($result['message'] ?? 'خطا'),
|
||||
(int) ($result['http_status'] ?? 400)
|
||||
);
|
||||
}
|
||||
|
||||
$Withdraw = $result['withdraw'];
|
||||
$site_url = $this->config->resolvePublicSiteUrl($request) ?: '/';
|
||||
|
||||
return view('showTransactionReport', compact('Withdraw', 'site_url'));
|
||||
}
|
||||
|
||||
public function webhook(Request $request, string $provider)
|
||||
{
|
||||
try {
|
||||
$gateway = $this->payments->resolveOrFail($provider);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return response()->json(['message' => 'invalid_provider'], 404);
|
||||
}
|
||||
|
||||
$result = $gateway->handleWebhook($request);
|
||||
$status = (int) ($result['http_status'] ?? 200);
|
||||
|
||||
if (!($result['ok'] ?? false)) {
|
||||
return response()->json([
|
||||
'message' => $result['message'] ?? 'error',
|
||||
], $status >= 400 ? $status : 400);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'ignored' => (bool) ($result['ignored'] ?? false),
|
||||
]);
|
||||
}
|
||||
|
||||
/** سازگاری با URLهای قبلی external-payment/* → montify */
|
||||
public function returnLegacy(Request $request)
|
||||
{
|
||||
return $this->returnFromGateway($request, WalletPaymentManager::PROVIDER_MONTIFY);
|
||||
}
|
||||
|
||||
public function webhookLegacy(Request $request)
|
||||
{
|
||||
return $this->webhook($request, WalletPaymentManager::PROVIDER_MONTIFY);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,65 @@ class Setting extends Model
|
||||
return $cols;
|
||||
}
|
||||
|
||||
/**
|
||||
* کلیدهایی که هرگز نباید در پاسخ عمومی api/get_settings برگردند.
|
||||
* credential درگاه، توکنها، رمزها و پیکربندی داخلی پرداخت.
|
||||
*/
|
||||
public static function isSecretOrServerOnlyKey(string $key): bool
|
||||
{
|
||||
$key = strtolower(trim($key));
|
||||
if ($key === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// کل گروه پیکربندی پرداخت آنلاین — فقط سرور / ادمین
|
||||
if (
|
||||
str_starts_with($key, 'external_payment_')
|
||||
|| $key === 'wallet_payment_provider'
|
||||
|| str_starts_with($key, 'payping_')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$exact = [
|
||||
'node_client_id',
|
||||
'node_shared_secret',
|
||||
'montzar_password',
|
||||
'montzar_token',
|
||||
'montzar_mobile',
|
||||
'bale_bot_token',
|
||||
'telegram_bot_token',
|
||||
'rubika_bot_token',
|
||||
'tahesab_token',
|
||||
'tahesab_host',
|
||||
'tahesab_port',
|
||||
'tahesab_db',
|
||||
'pargar_password',
|
||||
'pargar_api_token',
|
||||
'pargar_username',
|
||||
'zarin_api',
|
||||
'zarin_token',
|
||||
];
|
||||
if (in_array($key, $exact, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (bool) preg_match('/(^|_)(secret|password|token|api_key|apikey|private)(_|$)/', $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* فیلتر تنظیمات برای پاسخ عمومی (اپ کاربر).
|
||||
*
|
||||
* @param \Illuminate\Support\Collection|iterable $settings
|
||||
* @return \Illuminate\Support\Collection
|
||||
*/
|
||||
public static function forPublicApi($settings)
|
||||
{
|
||||
return collect($settings)
|
||||
->filter(fn ($row) => ! static::isSecretOrServerOnlyKey((string) (is_array($row) ? ($row['key'] ?? '') : ($row->key ?? ''))))
|
||||
->values();
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'title_fa',
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payment\Contracts;
|
||||
|
||||
/**
|
||||
* درگاههایی که میتوانند وضعیت پرداخت را مستقیم استعلام کنند.
|
||||
* برای وقتی که امضای برگشت مرورگر در دسترس/معتبر نیست و webhook هنوز نرسیده.
|
||||
*/
|
||||
interface SupportsStatusQuery
|
||||
{
|
||||
/**
|
||||
* استعلام وضعیت از درگاه و در صورت پرداخت موفق، شارژ idempotent کیف.
|
||||
*
|
||||
* @return array{status: string, paid: bool}
|
||||
*/
|
||||
public function syncStatusByReference(string $referenceId): array;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payment\Contracts;
|
||||
|
||||
use App\Models\Withdraw;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* قرارداد درگاه شارژ کیف پول — برای افزودن درگاه جدید این اینترفیس را پیاده کنید.
|
||||
*/
|
||||
interface WalletPaymentGatewayInterface
|
||||
{
|
||||
/** شناسه یکتا درگاه، مثلاً montify / payping */
|
||||
public function key(): string;
|
||||
|
||||
/** آیا این درگاه برای شارژ آنلاین آماده است؟ (فعال + پیکربندی کامل) */
|
||||
public function isAvailable(): bool;
|
||||
|
||||
/**
|
||||
* شروع پرداخت و برگرداندن URL هدایت کاربر.
|
||||
*
|
||||
* @param array{amount:int, reference_id:string, description?:string, metadata?:array} $payload
|
||||
* @return array{payment_url: string, session_id?: string|null, reference_id?: string, expires_at?: string|null}
|
||||
*/
|
||||
public function initiate(array $payload): array;
|
||||
|
||||
/** آدرس برگشت مرورگر که به پارتنر/درگاه اعلام میشود */
|
||||
public function returnUrl(): string;
|
||||
|
||||
/** آدرس webhook سرور به سرور */
|
||||
public function webhookUrl(): string;
|
||||
|
||||
/**
|
||||
* پردازش برگشت مرورگر.
|
||||
*
|
||||
* @return array{ok: bool, withdraw: ?Withdraw, http_status?: int, message?: string}
|
||||
*/
|
||||
public function handleReturn(Request $request): array;
|
||||
|
||||
/**
|
||||
* پردازش برگشت از روی پارامترهای خام — وقتی فرانت query را برای verify میفرستد.
|
||||
*
|
||||
* @param array<string, mixed> $rawQuery
|
||||
* @return array{ok: bool, withdraw: ?Withdraw, http_status?: int, message?: string}
|
||||
*/
|
||||
public function handleReturnPayload(array $rawQuery): array;
|
||||
|
||||
/**
|
||||
* پردازش webhook.
|
||||
*
|
||||
* @return array{ok: bool, ignored?: bool, http_status?: int, message?: string}
|
||||
*/
|
||||
public function handleWebhook(Request $request): array;
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payment\Gateways;
|
||||
|
||||
use App\Services\ConfigService;
|
||||
use App\Services\Payment\Contracts\SupportsStatusQuery;
|
||||
use App\Services\Payment\Contracts\WalletPaymentGatewayInterface;
|
||||
use App\Services\Payment\Support\MontifyHmacSigner;
|
||||
use App\Services\Payment\WalletDepositCreditor;
|
||||
use App\Services\Payment\WalletPaymentManager;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* درگاه پارتنر Montify (HMAC) — فقط credentials روی سرور.
|
||||
* @see docs/EXTERNAL_PAYMENT_PARTNER.md
|
||||
*/
|
||||
class MontifyPartnerGateway implements WalletPaymentGatewayInterface, SupportsStatusQuery
|
||||
{
|
||||
public function __construct(
|
||||
private ConfigService $config,
|
||||
private WalletDepositCreditor $creditor,
|
||||
) {
|
||||
}
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return WalletPaymentManager::PROVIDER_MONTIFY;
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return $this->isConfigured();
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->baseUrl() !== ''
|
||||
&& $this->clientId() !== ''
|
||||
&& $this->clientSecret() !== ''
|
||||
&& $this->gatewayName() !== '';
|
||||
}
|
||||
|
||||
public function returnUrl(): string
|
||||
{
|
||||
$fromSettings = $this->config->getString('external_payment_return_url', '');
|
||||
if ($fromSettings !== '') {
|
||||
return $fromSettings;
|
||||
}
|
||||
|
||||
// پیشفرض: صفحه فرانت برای نمایش نتیجه + verify سمت سرور
|
||||
$site = $this->config->resolvePublicSiteUrl(request());
|
||||
if ($site === '') {
|
||||
$appUrl = rtrim((string) config('app.url'), '/');
|
||||
// اگر APP_URL با /server تمام شود، دامنه فرانت همان host بدون /server است
|
||||
$site = preg_replace('#/server$#', '', $appUrl) ?: $appUrl;
|
||||
}
|
||||
|
||||
return rtrim($site, '/') . '/wallet/payment-result';
|
||||
}
|
||||
|
||||
public function webhookUrl(): string
|
||||
{
|
||||
$fromSettings = $this->config->getString('external_payment_webhook_url', '');
|
||||
if ($fromSettings !== '') {
|
||||
return $fromSettings;
|
||||
}
|
||||
|
||||
return url('/customer/wallet/payment/webhook/' . $this->key());
|
||||
}
|
||||
|
||||
public function initiate(array $payload): array
|
||||
{
|
||||
if (!$this->isConfigured()) {
|
||||
throw new RuntimeException('تنظیمات درگاه Montify ناقص است');
|
||||
}
|
||||
|
||||
$path = '/external-payment/initiate';
|
||||
$body = [
|
||||
'amount' => (int) $payload['amount'],
|
||||
'reference_id' => (string) $payload['reference_id'],
|
||||
'gateway' => (string) ($payload['gateway'] ?? $this->gatewayName()),
|
||||
'return_url' => (string) ($payload['return_url'] ?? $this->returnUrl()),
|
||||
'webhook_url' => (string) ($payload['webhook_url'] ?? $this->webhookUrl()),
|
||||
];
|
||||
|
||||
if (!empty($payload['description'])) {
|
||||
$body['description'] = (string) $payload['description'];
|
||||
}
|
||||
if (!empty($payload['metadata']) && is_array($payload['metadata'])) {
|
||||
$body['metadata'] = $payload['metadata'];
|
||||
}
|
||||
|
||||
$signer = $this->signer();
|
||||
$rawBody = $signer->stableStringify($body);
|
||||
$headers = $signer->signRequest('POST', $path, $body);
|
||||
|
||||
try {
|
||||
// مهم: post($url, []) بدنهٔ withBody را خالی میکند — حتماً send بدون data
|
||||
$response = Http::withHeaders($headers)
|
||||
->withBody($rawBody, 'application/json')
|
||||
->acceptJson()
|
||||
->timeout(30)
|
||||
->send('POST', $this->baseUrl() . $path);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
throw new RuntimeException('اتصال به سرور درگاه برقرار نشد');
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
if (!$response->successful() || !is_array($data) || empty($data['payment_url'])) {
|
||||
$errorCode = is_array($data) ? (string) ($data['error'] ?? '') : '';
|
||||
$remoteMessage = is_array($data)
|
||||
? (string) ($data['message'] ?? $data['error'] ?? 'initiate failed')
|
||||
: 'initiate failed';
|
||||
|
||||
\Log::warning('montify.initiate_failed', [
|
||||
'status' => $response->status(),
|
||||
'error' => $errorCode,
|
||||
'message' => $remoteMessage,
|
||||
'return_url' => $body['return_url'] ?? null,
|
||||
'webhook_url' => $body['webhook_url'] ?? null,
|
||||
'gateway' => $body['gateway'] ?? null,
|
||||
'reference_id' => $body['reference_id'] ?? null,
|
||||
'amount' => $body['amount'] ?? null,
|
||||
'body' => is_array($data) ? $data : $response->body(),
|
||||
]);
|
||||
|
||||
throw new RuntimeException($this->mapInitiateError($errorCode, $remoteMessage));
|
||||
}
|
||||
|
||||
return [
|
||||
'payment_url' => (string) $data['payment_url'],
|
||||
'session_id' => $data['session_id'] ?? null,
|
||||
'reference_id' => $data['reference_id'] ?? $payload['reference_id'],
|
||||
'expires_at' => $data['expires_at'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function mapInitiateError(string $errorCode, string $fallback): string
|
||||
{
|
||||
return match ($errorCode) {
|
||||
'invalid_signature' => 'امضای درخواست درگاه نامعتبر است (secret یا ساعت سرور را بررسی کنید)',
|
||||
'expired_timestamp' => 'ساعت سرور با درگاه همخوان نیست',
|
||||
'replay_detected' => 'درخواست تکراری شناسایی شد؛ دوباره تلاش کنید',
|
||||
'invalid_return_url' => 'آدرس برگشت (return_url) در whitelist پارتنر Montify ثبت نشده است',
|
||||
'invalid_gateway' => 'نام درگاه داخل Montify نامعتبر است',
|
||||
'amount_too_low' => 'مبلغ کمتر از حد مجاز درگاه است',
|
||||
'duplicate_reference' => 'شناسه پرداخت تکراری است؛ دوباره تلاش کنید',
|
||||
'gateway_not_configured' => 'درگاه انتخابشده در Montify پیکربندی نشده است',
|
||||
'missing_auth_headers' => 'هدرهای احراز هویت به درگاه ارسال نشد',
|
||||
default => $fallback !== '' && $fallback !== 'initiate failed'
|
||||
? $fallback
|
||||
: 'مشکل در ارتباط با درگاه',
|
||||
};
|
||||
}
|
||||
|
||||
public function handleReturn(Request $request): array
|
||||
{
|
||||
return $this->handleReturnPayload($this->extractQueryArray($request));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $rawQuery
|
||||
* @return array{ok: bool, withdraw: ?\App\Models\Withdraw, http_status?: int, message?: string}
|
||||
*/
|
||||
public function handleReturnPayload(array $rawQuery): array
|
||||
{
|
||||
$query = $this->normalizeReturnQuery($rawQuery);
|
||||
$signer = $this->signer();
|
||||
|
||||
if ($signer->matchReturnSignatureVariant($query) === null) {
|
||||
\Log::warning('montify.return_invalid_signature', [
|
||||
'keys' => array_keys($query),
|
||||
'reference_id' => $query['reference_id'] ?? null,
|
||||
'status' => $query['status'] ?? null,
|
||||
'amount' => $query['amount'] ?? null,
|
||||
'timestamp' => $query['timestamp'] ?? null,
|
||||
'has_track_number' => array_key_exists('track_number', $query),
|
||||
]);
|
||||
|
||||
return ['ok' => false, 'withdraw' => null, 'http_status' => 401, 'message' => 'امضای نامعتبر'];
|
||||
}
|
||||
|
||||
if (!$signer->isTimestampFresh($query['timestamp'] ?? null)) {
|
||||
return ['ok' => false, 'withdraw' => null, 'http_status' => 401, 'message' => 'زمان درخواست منقضی شده'];
|
||||
}
|
||||
|
||||
$referenceId = (string) ($query['reference_id'] ?? '');
|
||||
$status = (string) ($query['status'] ?? '');
|
||||
$amount = (int) ($query['amount'] ?? 0);
|
||||
$trackNumber = (string) ($query['track_number'] ?? '');
|
||||
$sessionId = (string) ($query['session_id'] ?? '');
|
||||
|
||||
$withdraw = $this->creditor->findByReference($referenceId);
|
||||
if (!$withdraw) {
|
||||
return ['ok' => false, 'withdraw' => null, 'http_status' => 404, 'message' => 'سفارش یافت نشد'];
|
||||
}
|
||||
|
||||
if ($amount > 0 && (int) $withdraw->cash !== $amount) {
|
||||
return ['ok' => false, 'withdraw' => $withdraw, 'http_status' => 422, 'message' => 'مبلغ پرداختی دستکاری شده !'];
|
||||
}
|
||||
|
||||
if ($status === 'success') {
|
||||
$this->creditor->markPaidAndCredit(
|
||||
$withdraw,
|
||||
$trackNumber !== '' ? $trackNumber : $sessionId,
|
||||
'واریز درگاه Montify'
|
||||
);
|
||||
} elseif ((int) $withdraw->sts === 1) {
|
||||
$withdraw->sts = 3;
|
||||
$withdraw->save();
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'withdraw' => $withdraw->fresh(['customer']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* استخراج امن پارامترهای query — بدون فرض ParameterBag یا array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function extractQueryArray(Request $request): array
|
||||
{
|
||||
// دسترسی مستقیم به bag همیشه ParameterBag است
|
||||
if (isset($request->query) && is_object($request->query) && method_exists($request->query, 'all')) {
|
||||
return $request->query->all();
|
||||
}
|
||||
|
||||
$raw = $request->query();
|
||||
if (is_array($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
if (is_object($raw) && method_exists($raw, 'all')) {
|
||||
return $raw->all();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* پارامترهای برگشت Montify باید همگی string باشند (طبق مستند).
|
||||
*
|
||||
* @param array<string, mixed> $query
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function normalizeReturnQuery(array $query): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($query as $key => $value) {
|
||||
if (!is_string($key) || $key === '') {
|
||||
continue;
|
||||
}
|
||||
if (is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
if ($value === null) {
|
||||
continue;
|
||||
}
|
||||
$normalized[$key] = (string) $value;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* منبع حقیقت مستقل از redirect: استعلام مستقیم وضعیت از Montify.
|
||||
*
|
||||
* @return array{status: string, paid: bool}
|
||||
*/
|
||||
public function syncStatusByReference(string $referenceId): array
|
||||
{
|
||||
if ($referenceId === '' || !$this->isConfigured()) {
|
||||
return ['status' => 'unknown', 'paid' => false];
|
||||
}
|
||||
|
||||
$path = '/external-payment/status';
|
||||
$headers = $this->signer()->signRequest('GET', $path, null);
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders($headers)
|
||||
->acceptJson()
|
||||
->timeout(20)
|
||||
->get($this->baseUrl() . $path, ['reference_id' => $referenceId]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return ['status' => 'unknown', 'paid' => false];
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
if (!$response->successful() || !is_array($data)) {
|
||||
\Log::warning('montify.status_failed', [
|
||||
'reference_id' => $referenceId,
|
||||
'http_status' => $response->status(),
|
||||
'body' => is_array($data) ? $data : $response->body(),
|
||||
]);
|
||||
|
||||
return ['status' => 'unknown', 'paid' => false];
|
||||
}
|
||||
|
||||
$status = strtolower((string) ($data['status'] ?? ''));
|
||||
if ($status !== 'paid') {
|
||||
return ['status' => $status !== '' ? $status : 'unknown', 'paid' => false];
|
||||
}
|
||||
|
||||
$withdraw = $this->creditor->findByReference($referenceId);
|
||||
if (!$withdraw) {
|
||||
return ['status' => 'paid', 'paid' => false];
|
||||
}
|
||||
|
||||
$amount = (int) ($data['amount'] ?? 0);
|
||||
if ($amount > 0 && (int) $withdraw->cash !== $amount) {
|
||||
\Log::warning('montify.status_amount_mismatch', [
|
||||
'reference_id' => $referenceId,
|
||||
'remote_amount' => $amount,
|
||||
'local_amount' => (int) $withdraw->cash,
|
||||
]);
|
||||
|
||||
return ['status' => 'amount_mismatch', 'paid' => false];
|
||||
}
|
||||
|
||||
$trackNumber = (string) ($data['track_number'] ?? '');
|
||||
$this->creditor->markPaidAndCredit(
|
||||
$withdraw,
|
||||
$trackNumber !== '' ? $trackNumber : (string) ($data['token'] ?? ''),
|
||||
'واریز درگاه Montify'
|
||||
);
|
||||
|
||||
return ['status' => 'paid', 'paid' => true];
|
||||
}
|
||||
|
||||
public function handleWebhook(Request $request): array
|
||||
{
|
||||
$body = $request->all();
|
||||
$signer = $this->signer();
|
||||
|
||||
if (!$signer->verifyWebhookSignature($body, $request->header('X-Signature'))) {
|
||||
return ['ok' => false, 'http_status' => 401, 'message' => 'invalid_signature'];
|
||||
}
|
||||
|
||||
$clientId = (string) $request->header('X-Client-Id', '');
|
||||
if ($clientId !== '' && $clientId !== $this->clientId()) {
|
||||
return ['ok' => false, 'http_status' => 401, 'message' => 'invalid_client'];
|
||||
}
|
||||
|
||||
if (!$signer->isTimestampFresh($body['timestamp'] ?? $request->header('X-Timestamp'))) {
|
||||
return ['ok' => false, 'http_status' => 401, 'message' => 'expired_timestamp'];
|
||||
}
|
||||
|
||||
$status = (string) ($body['status'] ?? '');
|
||||
if ($status !== 'success') {
|
||||
return ['ok' => true, 'ignored' => true];
|
||||
}
|
||||
|
||||
$referenceId = (string) ($body['reference_id'] ?? '');
|
||||
$amount = (int) ($body['amount'] ?? 0);
|
||||
$trackNumber = (string) ($body['track_number'] ?? '');
|
||||
$sessionId = (string) ($body['session_id'] ?? '');
|
||||
|
||||
$withdraw = $this->creditor->findByReference($referenceId);
|
||||
if (!$withdraw) {
|
||||
return ['ok' => false, 'http_status' => 404, 'message' => 'not_found'];
|
||||
}
|
||||
|
||||
if ($amount > 0 && (int) $withdraw->cash !== $amount) {
|
||||
return ['ok' => false, 'http_status' => 422, 'message' => 'amount_mismatch'];
|
||||
}
|
||||
|
||||
$this->creditor->markPaidAndCredit(
|
||||
$withdraw,
|
||||
$trackNumber !== '' ? $trackNumber : $sessionId,
|
||||
'واریز درگاه Montify'
|
||||
);
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
private function baseUrl(): string
|
||||
{
|
||||
return rtrim($this->config->getString('external_payment_base_url', ''), '/');
|
||||
}
|
||||
|
||||
private function clientId(): string
|
||||
{
|
||||
return $this->config->getString('external_payment_client_id', '');
|
||||
}
|
||||
|
||||
private function clientSecret(): string
|
||||
{
|
||||
return $this->config->getString('external_payment_client_secret', '');
|
||||
}
|
||||
|
||||
private function gatewayName(): string
|
||||
{
|
||||
return strtolower($this->config->getString('external_payment_gateway', 'zarinpall'));
|
||||
}
|
||||
|
||||
private function signer(): MontifyHmacSigner
|
||||
{
|
||||
return new MontifyHmacSigner($this->clientId(), $this->clientSecret());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payment\Support;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* ابزار امضای HMAC مطابق EXTERNAL_PAYMENT_PARTNER.md (Montify).
|
||||
* فقط سمت سرور استفاده شود.
|
||||
*/
|
||||
class MontifyHmacSigner
|
||||
{
|
||||
public function __construct(private string $clientId, private string $clientSecret)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $body
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function signRequest(string $method, string $path, ?array $body): array
|
||||
{
|
||||
$timestamp = (string) time();
|
||||
$nonce = (string) Str::uuid();
|
||||
$bodyString = $body !== null ? $this->stableStringify($body) : '';
|
||||
$bodyHash = hash('sha256', $bodyString);
|
||||
$canonical = implode("\n", [
|
||||
strtoupper($method),
|
||||
$path,
|
||||
$bodyHash,
|
||||
$timestamp,
|
||||
$nonce,
|
||||
]);
|
||||
$signature = hash_hmac('sha256', $canonical, $this->clientSecret);
|
||||
|
||||
return [
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
'X-Client-Id' => $this->clientId,
|
||||
'X-Timestamp' => $timestamp,
|
||||
'X-Nonce' => $nonce,
|
||||
'X-Signature' => $signature,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
public function verifyReturnSignature(array $query): bool
|
||||
{
|
||||
return $this->matchReturnSignatureVariant($query) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* نام قالبی که امضا با آن خواند، یا null اگر هیچکدام نخواند.
|
||||
*
|
||||
* پارتنر ممکن است مقادیر عددی را بهصورت number امضا کند یا پارامتر خالی
|
||||
* (مثل track_number) را اصلاً در payload نگذارد؛ همه حالتها بررسی میشود.
|
||||
*
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
public function matchReturnSignatureVariant(array $query): ?string
|
||||
{
|
||||
$signature = (string) ($query['signature'] ?? '');
|
||||
if ($signature === '' || !ctype_xdigit($signature)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
unset($query['signature']);
|
||||
|
||||
foreach ($this->payloadVariants($query) as $name => $payload) {
|
||||
$expected = hash_hmac('sha256', $this->stableStringify($payload), $this->clientSecret);
|
||||
if ($this->hashEqualsHex($signature, $expected)) {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $body
|
||||
*/
|
||||
public function verifyWebhookSignature(array $body, ?string $signatureHeader): bool
|
||||
{
|
||||
$signature = (string) ($signatureHeader ?? '');
|
||||
if ($signature === '' || !ctype_xdigit($signature)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// بدنه همانطور که آمده (نوعها از JSON) اولویت دارد، بعد حالتهای نرمالشده
|
||||
$candidates = array_merge(
|
||||
['raw' => $body],
|
||||
$this->payloadVariants($body)
|
||||
);
|
||||
|
||||
foreach ($candidates as $payload) {
|
||||
$expected = hash_hmac('sha256', $this->stableStringify($payload), $this->clientSecret);
|
||||
if ($this->hashEqualsHex($signature, $expected)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* حالتهای ممکن payload برای مقایسه امضا (بدون تکرار).
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function payloadVariants(array $payload): array
|
||||
{
|
||||
$strings = [];
|
||||
foreach ($payload as $key => $value) {
|
||||
if (!is_string($key) || $key === '' || is_array($value) || $value === null) {
|
||||
continue;
|
||||
}
|
||||
$strings[$key] = is_bool($value) ? ($value ? 'true' : 'false') : (string) $value;
|
||||
}
|
||||
|
||||
$withoutEmpty = array_filter($strings, static fn ($value) => $value !== '');
|
||||
|
||||
$variants = [
|
||||
'strings' => $strings,
|
||||
'strings_without_empty' => $withoutEmpty,
|
||||
'numeric' => $this->castNumericValues($strings),
|
||||
'numeric_without_empty' => $this->castNumericValues($withoutEmpty),
|
||||
];
|
||||
|
||||
$unique = [];
|
||||
$seen = [];
|
||||
foreach ($variants as $name => $variant) {
|
||||
$fingerprint = $this->stableStringify($variant);
|
||||
if (isset($seen[$fingerprint])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$fingerprint] = true;
|
||||
$unique[$name] = $variant;
|
||||
}
|
||||
|
||||
return $unique;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function castNumericValues(array $payload): array
|
||||
{
|
||||
foreach (['amount', 'timestamp'] as $key) {
|
||||
if (isset($payload[$key]) && is_string($payload[$key]) && ctype_digit($payload[$key])) {
|
||||
$payload[$key] = (int) $payload[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function isTimestampFresh(int|string|null $timestamp, int $skewSeconds = 300): bool
|
||||
{
|
||||
if ($timestamp === null || $timestamp === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return abs(time() - (int) $timestamp) <= $skewSeconds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
public function stableStringify($value): string
|
||||
{
|
||||
if ($value === null || !is_array($value)) {
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
if (array_is_list($value)) {
|
||||
$parts = array_map(fn ($item) => $this->stableStringify($item), $value);
|
||||
|
||||
return '[' . implode(',', $parts) . ']';
|
||||
}
|
||||
|
||||
ksort($value);
|
||||
$parts = [];
|
||||
foreach ($value as $key => $item) {
|
||||
$parts[] = '"' . $key . '":' . $this->stableStringify($item);
|
||||
}
|
||||
|
||||
return '{' . implode(',', $parts) . '}';
|
||||
}
|
||||
|
||||
private function hashEqualsHex(string $a, string $b): bool
|
||||
{
|
||||
if (!ctype_xdigit($a) || !ctype_xdigit($b) || strlen($a) !== strlen($b)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals(strtolower($a), strtolower($b));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payment;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\User;
|
||||
use App\Models\Withdraw;
|
||||
use App\Services\ConfigService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Rooyesh\Wallet\interfaces\TransactionInterface;
|
||||
use Rooyesh\Wallet\interfaces\WalletInterface;
|
||||
|
||||
/**
|
||||
* شارژ idempotent کیف بعد از پرداخت موفق درگاه.
|
||||
*/
|
||||
class WalletDepositCreditor
|
||||
{
|
||||
public function __construct(private ConfigService $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function findByReference(string $referenceId): ?Withdraw
|
||||
{
|
||||
if ($referenceId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/^WD-(\d+)$/', $referenceId, $matches)) {
|
||||
return Withdraw::with('customer')->find((int) $matches[1]);
|
||||
}
|
||||
|
||||
return Withdraw::with('customer')
|
||||
->where('tracked_number', $referenceId)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function markPaidAndCredit(Withdraw $withdraw, string $trackNumber, string $note = 'واریز درگاه'): void
|
||||
{
|
||||
DB::transaction(function () use ($withdraw, $trackNumber, $note) {
|
||||
$locked = Withdraw::query()->whereKey($withdraw->id)->lockForUpdate()->first();
|
||||
if (!$locked || (int) $locked->sts === 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($trackNumber !== '') {
|
||||
$locked->tracked_number = $trackNumber;
|
||||
}
|
||||
$locked->sts = 2;
|
||||
$locked->save();
|
||||
|
||||
$user = Customer::find($locked->customer_id);
|
||||
if (!$user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$wallet = new WalletInterface($user->id, $user->type);
|
||||
new TransactionInterface($wallet, [
|
||||
'cash' => (int) $locked->cash,
|
||||
'expire_at' => now(),
|
||||
'withdraw_id' => $locked->id,
|
||||
'active' => true,
|
||||
], 3, false, true);
|
||||
|
||||
try {
|
||||
app(\App\Services\PargarSyncService::class)->queueWalletDocument(
|
||||
$user,
|
||||
'cash',
|
||||
(float) $locked->cash,
|
||||
[
|
||||
'withdraw_id' => $locked->id,
|
||||
'note' => $note,
|
||||
'source' => 'gateway',
|
||||
]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
$locked->loadMissing('customer');
|
||||
$message = 'کاربر ' . ($locked->customer->name ?? '') . ' با شماره موبایل ' . ($locked->customer->mobile ?? '')
|
||||
. ' حساب خود را به مقدار ' . number_format((int) $locked->cash) . ' ریال شارژ کرد. با تشکر، '
|
||||
. $this->config->getString('name_fa', '');
|
||||
|
||||
User::where('send_sms', 1)->get()->each(function ($admin) use ($message) {
|
||||
if (Controller::isSmsNotifEnabled('sms_notif_gateway_charge_admin')) {
|
||||
Controller::Send($admin->mobile, $message);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payment;
|
||||
|
||||
use App\Services\ConfigService;
|
||||
use App\Services\Payment\Contracts\WalletPaymentGatewayInterface;
|
||||
use App\Services\Payment\Gateways\MontifyPartnerGateway;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* انتخاب و مدیریت درگاه فعال شارژ کیف پول.
|
||||
*
|
||||
* افزودن درگاه جدید:
|
||||
* 1) کلاس Gateway با WalletPaymentGatewayInterface
|
||||
* 2) ثبت در map داخل drivers()
|
||||
* 3) مقدار جدید در setting: wallet_payment_provider
|
||||
*/
|
||||
class WalletPaymentManager
|
||||
{
|
||||
public const PROVIDER_NONE = 'none';
|
||||
|
||||
public const PROVIDER_MONTIFY = 'montify';
|
||||
|
||||
public function __construct(private ConfigService $config)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, class-string<WalletPaymentGatewayInterface>>
|
||||
*/
|
||||
public function drivers(): array
|
||||
{
|
||||
return [
|
||||
self::PROVIDER_MONTIFY => MontifyPartnerGateway::class,
|
||||
// مثال آینده:
|
||||
// self::PROVIDER_PAYPING => PaypingDirectGateway::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function configuredProvider(): string
|
||||
{
|
||||
$provider = strtolower($this->config->getString('wallet_payment_provider', ''));
|
||||
if ($provider !== '' && $provider !== self::PROVIDER_NONE) {
|
||||
return $provider;
|
||||
}
|
||||
|
||||
// سازگاری با سوییچ قدیمی external_payment_enabled
|
||||
if ($this->config->getBool('external_payment_enabled', false)) {
|
||||
return self::PROVIDER_MONTIFY;
|
||||
}
|
||||
|
||||
return self::PROVIDER_NONE;
|
||||
}
|
||||
|
||||
public function resolve(?string $provider = null): ?WalletPaymentGatewayInterface
|
||||
{
|
||||
$key = strtolower($provider ?: $this->configuredProvider());
|
||||
if ($key === '' || $key === self::PROVIDER_NONE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$drivers = $this->drivers();
|
||||
if (!isset($drivers[$key])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app($drivers[$key]);
|
||||
}
|
||||
|
||||
public function resolveOrFail(?string $provider = null): WalletPaymentGatewayInterface
|
||||
{
|
||||
$gateway = $this->resolve($provider);
|
||||
if (!$gateway) {
|
||||
throw new InvalidArgumentException('درگاه پرداخت پیکربندی نشده است');
|
||||
}
|
||||
|
||||
return $gateway;
|
||||
}
|
||||
|
||||
/** فقط برای UI کلاینت — بدون افشای نوع/credential */
|
||||
public function isOnlineDepositAvailable(): bool
|
||||
{
|
||||
$gateway = $this->resolve();
|
||||
|
||||
return $gateway !== null && $gateway->isAvailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{online_enabled: bool}
|
||||
*/
|
||||
public function publicOptions(): array
|
||||
{
|
||||
return [
|
||||
'online_enabled' => $this->isOnlineDepositAvailable(),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user