Fix some for sign body to montify and not get 401 error|
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;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ 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;
|
||||
@@ -20,6 +22,7 @@ class WalletPaymentController extends Controller
|
||||
public function __construct(
|
||||
private WalletPaymentManager $payments,
|
||||
private ConfigService $config,
|
||||
private WalletDepositCreditor $creditor,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -105,25 +108,17 @@ class WalletPaymentController extends Controller
|
||||
throw new UnprocessableEntityHttpException('پرداخت آنلاین فعال نیست');
|
||||
}
|
||||
|
||||
// همه پارامترهای برگشت عیناً وارد امضا میشوند؛ whitelist کردن باعث
|
||||
// مغایرت امضا میشود اگر پارتنر پارامتر اضافهای بفرستد.
|
||||
$payload = [];
|
||||
foreach ([
|
||||
'status',
|
||||
'reference_id',
|
||||
'session_id',
|
||||
'amount',
|
||||
'track_number',
|
||||
'timestamp',
|
||||
'signature',
|
||||
] as $key) {
|
||||
if (!$request->has($key)) {
|
||||
foreach ($request->all() as $key => $value) {
|
||||
if (!is_string($key) || $key === '' || is_array($value) || $value === null) {
|
||||
continue;
|
||||
}
|
||||
$value = $request->input($key);
|
||||
if ($value === null || is_array($value)) {
|
||||
continue;
|
||||
if (count($payload) >= 30) {
|
||||
break;
|
||||
}
|
||||
// track_number خالی هم ممکن است در امضا باشد
|
||||
$payload[$key] = (string) $value;
|
||||
$payload[$key] = is_bool($value) ? ($value ? 'true' : 'false') : (string) $value;
|
||||
}
|
||||
|
||||
if (($payload['signature'] ?? '') === '' || ($payload['reference_id'] ?? '') === '') {
|
||||
@@ -136,6 +131,12 @@ class WalletPaymentController extends Controller
|
||||
$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'] ?? 'خطا در تأیید پرداخت',
|
||||
@@ -148,18 +149,77 @@ class WalletPaymentController extends Controller
|
||||
return response()->json(['ok' => false, 'message' => 'سفارش یافت نشد'], 404);
|
||||
}
|
||||
|
||||
$customerId = (int) auth('customer')->id();
|
||||
if ((int) $withdraw->customer_id !== $customerId) {
|
||||
if (!$this->ownsWithdraw($withdraw)) {
|
||||
return response()->json(['ok' => false, 'message' => 'دسترسی غیرمجاز'], 403);
|
||||
}
|
||||
|
||||
$sts = (int) $withdraw->sts;
|
||||
$paid = $sts === 2;
|
||||
$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' => $paid,
|
||||
'status' => $paid ? 'success' : ((string) ($payload['status'] ?? '') === 'success' ? 'pending' : 'failed'),
|
||||
'paid' => (int) $withdraw->sts === 2,
|
||||
'status' => $status,
|
||||
'amount' => (int) $withdraw->cash,
|
||||
'reference_id' => 'WD-' . $withdraw->id,
|
||||
'withdraw_id' => $withdraw->id,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -37,6 +37,14 @@ interface WalletPaymentGatewayInterface
|
||||
*/
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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;
|
||||
@@ -15,7 +16,7 @@ use RuntimeException;
|
||||
* درگاه پارتنر Montify (HMAC) — فقط credentials روی سرور.
|
||||
* @see docs/EXTERNAL_PAYMENT_PARTNER.md
|
||||
*/
|
||||
class MontifyPartnerGateway implements WalletPaymentGatewayInterface
|
||||
class MontifyPartnerGateway implements WalletPaymentGatewayInterface, SupportsStatusQuery
|
||||
{
|
||||
public function __construct(
|
||||
private ConfigService $config,
|
||||
@@ -169,10 +170,14 @@ class MontifyPartnerGateway implements WalletPaymentGatewayInterface
|
||||
$query = $this->normalizeReturnQuery($rawQuery);
|
||||
$signer = $this->signer();
|
||||
|
||||
if (!$signer->verifyReturnSignature($query)) {
|
||||
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' => 'امضای نامعتبر'];
|
||||
@@ -262,6 +267,73 @@ class MontifyPartnerGateway implements WalletPaymentGatewayInterface
|
||||
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();
|
||||
|
||||
@@ -47,26 +47,35 @@ class MontifyHmacSigner
|
||||
* @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 false;
|
||||
return null;
|
||||
}
|
||||
|
||||
unset($query['signature']);
|
||||
|
||||
// فقط اسکالرها — همه string طبق مستند پارتنر
|
||||
$payload = [];
|
||||
foreach ($query as $key => $value) {
|
||||
if (!is_string($key) || is_array($value) || $value === null) {
|
||||
continue;
|
||||
foreach ($this->payloadVariants($query) as $name => $payload) {
|
||||
$expected = hash_hmac('sha256', $this->stableStringify($payload), $this->clientSecret);
|
||||
if ($this->hashEqualsHex($signature, $expected)) {
|
||||
return $name;
|
||||
}
|
||||
$payload[$key] = (string) $value;
|
||||
}
|
||||
|
||||
$expected = hash_hmac('sha256', $this->stableStringify($payload), $this->clientSecret);
|
||||
|
||||
return $this->hashEqualsHex($signature, $expected);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,13 +83,79 @@ class MontifyHmacSigner
|
||||
*/
|
||||
public function verifyWebhookSignature(array $body, ?string $signatureHeader): bool
|
||||
{
|
||||
if ($signatureHeader === null || $signatureHeader === '') {
|
||||
$signature = (string) ($signatureHeader ?? '');
|
||||
if ($signature === '' || !ctype_xdigit($signature)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expected = hash_hmac('sha256', $this->stableStringify($body), $this->clientSecret);
|
||||
// بدنه همانطور که آمده (نوعها از JSON) اولویت دارد، بعد حالتهای نرمالشده
|
||||
$candidates = array_merge(
|
||||
['raw' => $body],
|
||||
$this->payloadVariants($body)
|
||||
);
|
||||
|
||||
return $this->hashEqualsHex($signatureHeader, $expected);
|
||||
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
|
||||
|
||||
@@ -130,9 +130,18 @@ Auth مشتری: `auth:customer` + `customer.active` (بهجز return/webhook
|
||||
|-----|------|------|-----|
|
||||
| GET | `/wallet/payment/options` | مشتری | `{ online_enabled }` |
|
||||
| POST | `/wallet/payment/initiate` | مشتری | Body: `{ value }` (ریال) → `{ payment_url }` |
|
||||
| POST | `/wallet/payment/verify-return` | مشتری | verify امضای بازگشت + شارژ |
|
||||
| GET | `/wallet/payment/status` | مشتری | `?reference_id=WD-123` → استعلام مستقیم از درگاه |
|
||||
| GET | `/wallet/payment/return/{provider}` | عمومی | برگشت مرورگر؛ verify + گزارش |
|
||||
| POST | `/wallet/payment/webhook/{provider}` | عمومی (HMAC) | شارژ کیف |
|
||||
|
||||
### verify-return و status
|
||||
|
||||
- فرانت **همه** پارامترهای query صفحه بازگشت را بدون فیلتر میفرستد؛ حذف حتی یک پارامتر امضا را میشکند.
|
||||
- سرور امضا را با چند قالب payload میسنجد (مقادیر string، بدون مقادیر خالی، `amount`/`timestamp` عددی) تا اختلافهای جزئی پیادهسازی پارتنر باعث رد شدن پرداخت واقعی نشود. دستکاری مقادیر همچنان رد میشود.
|
||||
- اگر امضا نخواند یا نتیجه هنوز قطعی نباشد، سرور `GET /external-payment/status` را از Montify میپرسد (`SupportsStatusQuery`) و در صورت `paid` کیف را idempotent شارژ میکند. redirect مرورگر هیچوقت منبع حقیقت نیست.
|
||||
- مالکیت `Withdraw` قبل از هر پاسخی با کاربر لاگینشده تطبیق داده میشود.
|
||||
|
||||
### سازگاری قدیمی
|
||||
|
||||
| مسیر قدیمی | معادل |
|
||||
@@ -263,6 +272,8 @@ app/Http/Controllers/pay/
|
||||
| `مشکل در ارتباط با درگاه` | ساعت سرور (NTP)، `stableStringify`، PATH دقیق، body خام همان hash |
|
||||
| شارژ دوبل | باید idempotent باشد (`sts=2` + lock) |
|
||||
| secret در Network | مسیر باید `api/get_settings` باشد نه admin؛ فیلتر `Setting::forPublicApi` |
|
||||
| `امضای نامعتبر` در verify-return | `php artisan montify:check-return "<کل URL بازگشت>"` را روی سرور بزنید؛ خروجی نشان میدهد کدام قالب میخواند یا اینکه `client_secret` غلط است. لاگ `montify.return_invalid_signature` هم کلیدهای دریافتی را دارد |
|
||||
| پرداخت موفق ولی کیف شارژ نشد | `GET /wallet/payment/status?reference_id=WD-…`؛ لاگ `montify.status_failed` و رسیدن webhook را بررسی کنید |
|
||||
|
||||
جزئیات امضای HMAC و خطاهای Montify: [`EXTERNAL_PAYMENT_PARTNER.md`](./EXTERNAL_PAYMENT_PARTNER.md)
|
||||
|
||||
@@ -275,6 +286,7 @@ server/docs/WALLET_ONLINE_PAYMENT.md ← این سند
|
||||
server/docs/EXTERNAL_PAYMENT_PARTNER.md ← پروتکل Montify
|
||||
server/app/Services/Payment/...
|
||||
server/app/Http/Controllers/pay/WalletPaymentController.php
|
||||
server/app/Console/Commands/MontifyCheckReturnCommand.php ← عیبیابی امضای بازگشت
|
||||
server/routes/customer.php
|
||||
server/app/Models/Setting.php ← فیلتر secret
|
||||
server/database/migrations/2026_07_24_000001_add_external_payment_settings.php
|
||||
|
||||
@@ -85,6 +85,7 @@ Route::middleware(['auth:customer', 'customer.active'])->group(function () use (
|
||||
Route::get('/wallet/payment/options', [WalletPaymentController::class, 'options']);
|
||||
Route::post('/wallet/payment/initiate', [WalletPaymentController::class, 'initiate']);
|
||||
Route::post('/wallet/payment/verify-return', [WalletPaymentController::class, 'verifyReturn']);
|
||||
Route::get('/wallet/payment/status', [WalletPaymentController::class, 'status']);
|
||||
// سازگاری قدیمی
|
||||
Route::post('/wallet/external-payment/initiate', [WalletPaymentController::class, 'initiate']);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user