Feat add payment with gateway of montify
This commit is contained in:
@@ -30,25 +30,25 @@ class SettingsController extends Controller
|
|||||||
->orderBy('key');
|
->orderBy('key');
|
||||||
|
|
||||||
if ($request->boolean('system_only')) {
|
if ($request->boolean('system_only')) {
|
||||||
return response()->json(
|
$rows = $query
|
||||||
$query
|
->where(function ($q) {
|
||||||
->where(function ($q) {
|
$q->where('can_edit', 0)->orWhere('can_edit', false);
|
||||||
$q->where('can_edit', 0)->orWhere('can_edit', false);
|
})
|
||||||
})
|
->where(function ($q) {
|
||||||
->where(function ($q) {
|
$q->where('is_active', 1)
|
||||||
$q->where('is_active', 1)
|
->orWhere('is_active', true)
|
||||||
->orWhere('is_active', true)
|
->orWhere('is_active', '1');
|
||||||
->orWhere('is_active', '1');
|
})
|
||||||
})
|
->get();
|
||||||
->get()
|
|
||||||
);
|
return response()->json($this->authorizeSettingsPayload($rows));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->boolean('editable_only')) {
|
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)
|
public function edit(Request $request)
|
||||||
@@ -134,4 +134,20 @@ class SettingsController extends Controller
|
|||||||
{
|
{
|
||||||
return in_array($key, self::PANEL_MANAGED_KEYS, true);
|
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,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\pay;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Withdraw;
|
||||||
|
use App\Services\ConfigService;
|
||||||
|
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,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* فقط فلگ نمایش دکمه — بدون نام درگاه و بدون تنظیمات محرمانه.
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'مشکل در ارتباط با درگاه',
|
||||||
|
], 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
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 = [
|
protected $fillable = [
|
||||||
'key',
|
'key',
|
||||||
'title_fa',
|
'title_fa',
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* پردازش webhook.
|
||||||
|
*
|
||||||
|
* @return array{ok: bool, ignored?: bool, http_status?: int, message?: string}
|
||||||
|
*/
|
||||||
|
public function handleWebhook(Request $request): array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Payment\Gateways;
|
||||||
|
|
||||||
|
use App\Services\ConfigService;
|
||||||
|
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
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url('/customer/wallet/payment/return/' . $this->key());
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
$headers = $signer->signRequest('POST', $path, $body);
|
||||||
|
$response = Http::withHeaders($headers)
|
||||||
|
->withBody($signer->stableStringify($body), 'application/json')
|
||||||
|
->acceptJson()
|
||||||
|
->post($this->baseUrl() . $path);
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
if (!$response->successful() || empty($data['payment_url'])) {
|
||||||
|
$message = is_array($data)
|
||||||
|
? (string) ($data['message'] ?? $data['error'] ?? 'initiate failed')
|
||||||
|
: 'initiate failed';
|
||||||
|
throw new RuntimeException($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handleReturn(Request $request): array
|
||||||
|
{
|
||||||
|
$query = $request->query();
|
||||||
|
$signer = $this->signer();
|
||||||
|
|
||||||
|
if (!$signer->verifyReturnSignature($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']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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,117 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
$signature = (string) ($query['signature'] ?? '');
|
||||||
|
if ($signature === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($query['signature']);
|
||||||
|
$expected = hash_hmac('sha256', $this->stableStringify($query), $this->clientSecret);
|
||||||
|
|
||||||
|
return $this->hashEqualsHex($signature, $expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $body
|
||||||
|
*/
|
||||||
|
public function verifyWebhookSignature(array $body, ?string $signatureHeader): bool
|
||||||
|
{
|
||||||
|
if ($signatureHeader === null || $signatureHeader === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expected = hash_hmac('sha256', $this->stableStringify($body), $this->clientSecret);
|
||||||
|
|
||||||
|
return $this->hashEqualsHex($signatureHeader, $expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
$rows = [
|
||||||
|
[
|
||||||
|
'key' => 'external_payment_enabled',
|
||||||
|
'value' => 'false',
|
||||||
|
'title_fa' => 'شارژ آنلاین کیف پول (درگاه خارجی)',
|
||||||
|
'type' => 'switch',
|
||||||
|
'can_edit' => 1,
|
||||||
|
'is_active' => 1,
|
||||||
|
'description' => 'روشن: دکمه پرداخت آنلاین در راهنمای شارژ کیف پول نمایش داده میشود و اتصال به درگاه خارجی فعال است. خاموش: فقط واریز دستی.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'external_payment_base_url',
|
||||||
|
'value' => '',
|
||||||
|
'title_fa' => 'آدرس API درگاه خارجی',
|
||||||
|
'type' => 'text',
|
||||||
|
'can_edit' => 0,
|
||||||
|
'is_active' => 1,
|
||||||
|
'description' => 'BASE_URL سرویس Montify (بدون اسلش انتهایی)، مثلاً https://node.example.com',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'external_payment_client_id',
|
||||||
|
'value' => '',
|
||||||
|
'title_fa' => 'شناسه کلاینت درگاه خارجی',
|
||||||
|
'type' => 'text',
|
||||||
|
'can_edit' => 0,
|
||||||
|
'is_active' => 1,
|
||||||
|
'description' => 'client_id دریافتی از ادمین Montify',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'external_payment_client_secret',
|
||||||
|
'value' => '',
|
||||||
|
'title_fa' => 'رمز کلاینت درگاه خارجی',
|
||||||
|
'type' => 'text',
|
||||||
|
'can_edit' => 0,
|
||||||
|
'is_active' => 1,
|
||||||
|
'description' => 'client_secret دریافتی از ادمین Montify. فقط روی سرور نگه دارید؛ در فرانت استفاده نشود.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'external_payment_gateway',
|
||||||
|
'value' => 'zarinpall',
|
||||||
|
'title_fa' => 'نام درگاه خارجی',
|
||||||
|
'type' => 'select',
|
||||||
|
'can_edit' => 0,
|
||||||
|
'is_active' => 1,
|
||||||
|
'description' => 'یکی از درگاههای فعال در Montify|zarinpall,zibal,payping,sepal',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'external_payment_return_url',
|
||||||
|
'value' => '',
|
||||||
|
'title_fa' => 'آدرس برگشت از درگاه',
|
||||||
|
'type' => 'text',
|
||||||
|
'can_edit' => 0,
|
||||||
|
'is_active' => 1,
|
||||||
|
'description' => 'return_url ثبتشده در whitelist پارتنر. خالی = آدرس پیشفرض سرور (/customer/wallet/external-payment/return)',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'external_payment_webhook_url',
|
||||||
|
'value' => '',
|
||||||
|
'title_fa' => 'آدرس Webhook درگاه خارجی',
|
||||||
|
'type' => 'text',
|
||||||
|
'can_edit' => 0,
|
||||||
|
'is_active' => 1,
|
||||||
|
'description' => 'webhook_url برای اطلاع سرور از پرداخت موفق. خالی = آدرس پیشفرض سرور (/customer/wallet/external-payment/webhook)',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (DB::table('settings')->where('key', $row['key'])->exists()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('settings')->insert(array_merge($row, [
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::table('settings')->whereIn('key', [
|
||||||
|
'external_payment_enabled',
|
||||||
|
'external_payment_base_url',
|
||||||
|
'external_payment_client_id',
|
||||||
|
'external_payment_client_secret',
|
||||||
|
'external_payment_gateway',
|
||||||
|
'external_payment_return_url',
|
||||||
|
'external_payment_webhook_url',
|
||||||
|
])->delete();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! DB::table('settings')->where('key', 'wallet_payment_provider')->exists()) {
|
||||||
|
$legacyEnabled = DB::table('settings')
|
||||||
|
->where('key', 'external_payment_enabled')
|
||||||
|
->value('value');
|
||||||
|
$default = filter_var((string) $legacyEnabled, FILTER_VALIDATE_BOOLEAN) ? 'montify' : 'none';
|
||||||
|
|
||||||
|
DB::table('settings')->insert([
|
||||||
|
'key' => 'wallet_payment_provider',
|
||||||
|
'value' => $default,
|
||||||
|
'title_fa' => 'درگاه شارژ آنلاین کیف پول',
|
||||||
|
'type' => 'select',
|
||||||
|
'can_edit' => 0,
|
||||||
|
'is_active' => 1,
|
||||||
|
'description' => 'انتخاب درگاه فعال برای شارژ آنلاین. none=غیرفعال، montify=پارتنر Montify. درگاههای مستقیم بعدی اینجا اضافه میشوند.|none,montify',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::table('settings')->where('key', 'wallet_payment_provider')->delete();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
# راهنمای اتصال پرداخت خارجی (Partner Integration)
|
||||||
|
|
||||||
|
این سند برای تیم فنی سایتهایی است که میخواهند کاربر را به Montify بفرستند، پرداخت را از درگاه انجام دهند، و نتیجه را امن دریافت کنند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱. خلاصه جریان
|
||||||
|
|
||||||
|
```
|
||||||
|
سایت شما (سرور)
|
||||||
|
│ POST /external-payment/initiate (امضای HMAC)
|
||||||
|
▼
|
||||||
|
Montify → payment_url
|
||||||
|
│
|
||||||
|
▼ redirect کاربر
|
||||||
|
/pay/external/{token} → درگاه پرداخت
|
||||||
|
│
|
||||||
|
▼ برگشت از درگاه
|
||||||
|
Montify verify
|
||||||
|
│
|
||||||
|
├─► redirect مرورگر به return_url شما (با signature)
|
||||||
|
└─► POST webhook به سرور شما (با signature)
|
||||||
|
```
|
||||||
|
|
||||||
|
**نکات مهم:**
|
||||||
|
|
||||||
|
- کاربر نهایی نیاز به لاگین در Montify ندارد.
|
||||||
|
- مبلغ و آدرس برگشت فقط از سرور شما (با امضا) پذیرفته میشود؛ لینک دستی/جعلی کار نمیکند.
|
||||||
|
- `client_secret` فقط روی سرور شما بماند؛ هرگز در فرانتاند قرار ندهید.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۲. پیشنیازها
|
||||||
|
|
||||||
|
از ادمین Montify این اطلاعات را دریافت کنید:
|
||||||
|
|
||||||
|
| مقدار | توضیح |
|
||||||
|
|--------|--------|
|
||||||
|
| `BASE_URL` | آدرس API نود، مثلاً `https://node.example.com` |
|
||||||
|
| `APP_URL` | آدرس فرانت Montify (برای `payment_url`) |
|
||||||
|
| `client_id` | شناسه partner شما |
|
||||||
|
| `client_secret` | رمز مخفی (فقط یکبار نمایش داده میشود) |
|
||||||
|
|
||||||
|
همچنین این موارد باید در پنل partner ثبت شده باشد:
|
||||||
|
|
||||||
|
- `allowed_return_urls` — پیشوند آدرسهای مجاز برگشت (مثلاً `https://yoursite.com/payment/`)
|
||||||
|
- `webhook_url` — اختیاری؛ آدرس پیشفرض webhook شما
|
||||||
|
- `allowed_ips` — اختیاری؛ اگر پر باشد فقط همان IPها مجازند
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۳. احراز هویت درخواستهای سرور به سرور
|
||||||
|
|
||||||
|
برای `initiate` و `status` این هدرها الزامی است:
|
||||||
|
|
||||||
|
| Header | توضیح |
|
||||||
|
|--------|--------|
|
||||||
|
| `X-Client-Id` | همان `client_id` |
|
||||||
|
| `X-Timestamp` | Unix time به ثانیه |
|
||||||
|
| `X-Nonce` | یکتا برای هر درخواست (مثلاً UUID) |
|
||||||
|
| `X-Signature` | HMAC-SHA256 روی canonical string |
|
||||||
|
| `Content-Type` | `application/json` |
|
||||||
|
|
||||||
|
### ۳.۱. ساخت Canonical String
|
||||||
|
|
||||||
|
```
|
||||||
|
METHOD
|
||||||
|
PATH
|
||||||
|
BODY_HASH
|
||||||
|
TIMESTAMP
|
||||||
|
NONCE
|
||||||
|
```
|
||||||
|
|
||||||
|
با `\n` بین خطوط. بدون `\n` انتهایی.
|
||||||
|
|
||||||
|
- `METHOD`: حروف بزرگ (`POST` یا `GET`)
|
||||||
|
- `PATH`: فقط path بدون query، مثلاً `/external-payment/initiate`
|
||||||
|
- `BODY_HASH`: `SHA256` از `stableStringify(body)` به صورت hex
|
||||||
|
برای `GET` بدون body: hash روی رشته خالی `""`
|
||||||
|
- `TIMESTAMP` و `nonce`: همان مقادیر هدر
|
||||||
|
|
||||||
|
### ۳.۲. stableStringify
|
||||||
|
|
||||||
|
قبل از hash کردن body، JSON را با کلیدهای **مرتبشده** بسازید:
|
||||||
|
|
||||||
|
- object: کلیدها به ترتیب الفبایی
|
||||||
|
- array: ترتیب حفظ شود
|
||||||
|
- null / number / string / boolean: مثل `JSON.stringify`
|
||||||
|
|
||||||
|
مثال:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"amount":1000,"gateway":"zarinpall","reference_id":"ORD-1","return_url":"https://yoursite.com/ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
نه:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"return_url":"...","amount":1000,...}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۳.۳. Signature
|
||||||
|
|
||||||
|
```text
|
||||||
|
X-Signature = HMAC_SHA256(canonical, client_secret) → hex
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۳.۴. محدودیتها
|
||||||
|
|
||||||
|
- اختلاف ساعت مجاز: ±۵ دقیقه
|
||||||
|
- هر `nonce` فقط یکبار قابل استفاده است (ضد replay)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۴. APIها
|
||||||
|
|
||||||
|
### ۴.۱. شروع پرداخت — `POST /external-payment/initiate`
|
||||||
|
|
||||||
|
**Auth:** HMAC (الزامی)
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
|
||||||
|
| فیلد | نوع | الزامی | توضیح |
|
||||||
|
|------|-----|--------|--------|
|
||||||
|
| `amount` | number | بله | مبلغ به **ریال** (حداقل ۱۰۰۰) |
|
||||||
|
| `reference_id` | string | بله | شناسه سفارش شما (یکتا در وضعیت فعال) |
|
||||||
|
| `gateway` | string | بله | یکی از: `zarinpall` ، `zibal` ، `payping` ، `sepal` |
|
||||||
|
| `return_url` | string | بله | باید با یکی از `allowed_return_urls` شروع شود |
|
||||||
|
| `webhook_url` | string | خیر | اگر نباشد از webhook پیشفرض partner استفاده میشود |
|
||||||
|
| `description` | string | خیر | توضیح |
|
||||||
|
| `metadata` | object | خیر | داده دلخواه شما |
|
||||||
|
|
||||||
|
**نمونه درخواست:**
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /external-payment/initiate
|
||||||
|
Content-Type: application/json
|
||||||
|
X-Client-Id: ptr_xxxxxxxx
|
||||||
|
X-Timestamp: 1720000000
|
||||||
|
X-Nonce: 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
X-Signature: <hex>
|
||||||
|
|
||||||
|
{
|
||||||
|
"amount": 500000,
|
||||||
|
"reference_id": "ORDER-1001",
|
||||||
|
"gateway": "zarinpall",
|
||||||
|
"return_url": "https://yoursite.com/payment/result",
|
||||||
|
"webhook_url": "https://yoursite.com/api/payment/webhook",
|
||||||
|
"description": "پرداخت سفارش ۱۰۰۱"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**پاسخ موفق `201`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "a1b2c3...",
|
||||||
|
"payment_url": "https://shop.example.com/pay/external/a1b2c3...",
|
||||||
|
"expires_at": "2026-07-21T10:15:00.000Z",
|
||||||
|
"reference_id": "ORDER-1001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
کاربر را به `payment_url` هدایت کنید. جلسه حدود **۱۵ دقیقه** اعتبار دارد.
|
||||||
|
|
||||||
|
**خطاهای رایج:**
|
||||||
|
|
||||||
|
| error | معنی |
|
||||||
|
|-------|------|
|
||||||
|
| `invalid_signature` | امضا اشتباه است |
|
||||||
|
| `expired_timestamp` | ساعت سرور نامعتبر |
|
||||||
|
| `replay_detected` | nonce تکراری |
|
||||||
|
| `invalid_return_url` | `return_url` در whitelist نیست |
|
||||||
|
| `invalid_gateway` | درگاه نامعتبر |
|
||||||
|
| `amount_too_low` | مبلغ کمتر از ۱۰۰۰ ریال |
|
||||||
|
| `duplicate_reference` | `reference_id` قبلاً در وضعیت فعال استفاده شده |
|
||||||
|
| `gateway_not_configured` | درگاه در Montify پیکربندی نشده |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ۴.۲. استعلام وضعیت — `GET /external-payment/status`
|
||||||
|
|
||||||
|
**Auth:** HMAC (الزامی)
|
||||||
|
|
||||||
|
یکی از queryها لازم است:
|
||||||
|
|
||||||
|
- `?reference_id=ORDER-1001`
|
||||||
|
- `?session_id=<session_id>`
|
||||||
|
|
||||||
|
**پاسخ نمونه:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "a1b2c3...",
|
||||||
|
"reference_id": "ORDER-1001",
|
||||||
|
"amount": 500000,
|
||||||
|
"gateway": "zarinpall",
|
||||||
|
"status": "paid",
|
||||||
|
"track_number": "123456789",
|
||||||
|
"expires_at": "...",
|
||||||
|
"paid_at": "...",
|
||||||
|
"created_at": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**وضعیتها (`status`):**
|
||||||
|
|
||||||
|
| status | معنی |
|
||||||
|
|--------|------|
|
||||||
|
| `pending` | ساخته شده، هنوز به درگاه نرفته |
|
||||||
|
| `processing` | به درگاه هدایت شده |
|
||||||
|
| `paid` | پرداخت موفق |
|
||||||
|
| `failed` | ناموفق |
|
||||||
|
| `expired` | منقضی |
|
||||||
|
| `cancelled` | لغو |
|
||||||
|
|
||||||
|
> منبع حقیقت نهایی برای علامتگذاری سفارش: **Webhook** یا **استعلام status**. فقط به redirect مرورگر اکتفا نکنید.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۵. برگشت کاربر (Browser Redirect)
|
||||||
|
|
||||||
|
بعد از پرداخت، Montify کاربر را به `return_url` شما میفرستد با queryهای امضاشده:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://yoursite.com/payment/result?
|
||||||
|
status=success
|
||||||
|
&reference_id=ORDER-1001
|
||||||
|
&session_id=a1b2c3...
|
||||||
|
&amount=500000
|
||||||
|
&track_number=123456789
|
||||||
|
×tamp=1720000123
|
||||||
|
&signature=<hex>
|
||||||
|
```
|
||||||
|
|
||||||
|
| پارامتر | توضیح |
|
||||||
|
|---------|--------|
|
||||||
|
| `status` | `success` یا `failed` |
|
||||||
|
| `reference_id` | همان شناسه شما |
|
||||||
|
| `session_id` | توکن جلسه |
|
||||||
|
| `amount` | مبلغ (ریال) |
|
||||||
|
| `track_number` | کد پیگیری درگاه (ممکن است خالی باشد) |
|
||||||
|
| `timestamp` | زمان امضا |
|
||||||
|
| `signature` | HMAC روی payload |
|
||||||
|
|
||||||
|
### تأیید امضای redirect
|
||||||
|
|
||||||
|
1. همه پارامترها بهجز `signature` را بگیرید.
|
||||||
|
2. یک object بسازید (مقادیر string هستند).
|
||||||
|
3. `stableStringify` کنید.
|
||||||
|
4. `HMAC-SHA256(..., client_secret)` بگیرید و با `signature` مقایسه کنید.
|
||||||
|
5. `timestamp` را چک کنید (±۵ دقیقه توصیه میشود).
|
||||||
|
6. مبلغ و `reference_id` را با سفارش خودتان تطبیق دهید.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۶. Webhook سرور به سرور
|
||||||
|
|
||||||
|
Montify بعد از پرداخت موفق یک `POST` به `webhook_url` میزند.
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
X-Client-Id: ptr_xxxxxxxx
|
||||||
|
X-Timestamp: 1720000123
|
||||||
|
X-Signature: <hex>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"reference_id": "ORDER-1001",
|
||||||
|
"session_id": "a1b2c3...",
|
||||||
|
"amount": 500000,
|
||||||
|
"track_number": "123456789",
|
||||||
|
"gateway": "zarinpall",
|
||||||
|
"timestamp": "1720000123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### تأیید امضای webhook
|
||||||
|
|
||||||
|
`X-Signature = HMAC-SHA256(stableStringify(body), client_secret)`
|
||||||
|
|
||||||
|
پاسخ `2xx` برگردانید. در غیر این صورت ممکن است ارسال ناموفق ثبت شود.
|
||||||
|
|
||||||
|
> Webhook فقط وقتی session به `paid` برسد ارسال میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۷. نمونه کد Node.js
|
||||||
|
|
||||||
|
```js
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const BASE_URL = 'https://node.example.com';
|
||||||
|
const CLIENT_ID = process.env.MONTIFY_CLIENT_ID;
|
||||||
|
const CLIENT_SECRET = process.env.MONTIFY_CLIENT_SECRET;
|
||||||
|
|
||||||
|
function stableStringify(obj) {
|
||||||
|
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
|
||||||
|
if (Array.isArray(obj)) return `[${obj.map(stableStringify).join(',')}]`;
|
||||||
|
const keys = Object.keys(obj).sort();
|
||||||
|
return `{${keys.map((k) => `"${k}":${stableStringify(obj[k])}`).join(',')}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function signRequest(method, path, body) {
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||||
|
const nonce = crypto.randomUUID();
|
||||||
|
const bodyString = body ? stableStringify(body) : '';
|
||||||
|
const bodyHash = crypto.createHash('sha256').update(bodyString).digest('hex');
|
||||||
|
const canonical = [method.toUpperCase(), path, bodyHash, timestamp, nonce].join('\n');
|
||||||
|
const signature = crypto.createHmac('sha256', CLIENT_SECRET).update(canonical).digest('hex');
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Client-Id': CLIENT_ID,
|
||||||
|
'X-Timestamp': timestamp,
|
||||||
|
'X-Nonce': nonce,
|
||||||
|
'X-Signature': signature,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initiatePayment({ amount, referenceId, gateway, returnUrl, webhookUrl, description }) {
|
||||||
|
const path = '/external-payment/initiate';
|
||||||
|
const body = {
|
||||||
|
amount,
|
||||||
|
reference_id: referenceId,
|
||||||
|
gateway,
|
||||||
|
return_url: returnUrl,
|
||||||
|
webhook_url: webhookUrl,
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
const headers = signRequest('POST', path, body);
|
||||||
|
const { data } = await axios.post(`${BASE_URL}${path}`, body, { headers });
|
||||||
|
return data; // { session_id, payment_url, expires_at, reference_id }
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyReturnSignature(query) {
|
||||||
|
const { signature, ...rest } = query;
|
||||||
|
const expected = crypto
|
||||||
|
.createHmac('sha256', CLIENT_SECRET)
|
||||||
|
.update(stableStringify(rest))
|
||||||
|
.digest('hex');
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyWebhook(body, signatureHeader) {
|
||||||
|
const expected = crypto
|
||||||
|
.createHmac('sha256', CLIENT_SECRET)
|
||||||
|
.update(stableStringify(body))
|
||||||
|
.digest('hex');
|
||||||
|
const a = Buffer.from(signatureHeader, 'hex');
|
||||||
|
const b = Buffer.from(expected, 'hex');
|
||||||
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// مثال استفاده:
|
||||||
|
// const result = await initiatePayment({
|
||||||
|
// amount: 500000,
|
||||||
|
// referenceId: 'ORDER-1001',
|
||||||
|
// gateway: 'zarinpall',
|
||||||
|
// returnUrl: 'https://yoursite.com/payment/result',
|
||||||
|
// webhookUrl: 'https://yoursite.com/api/payment/webhook',
|
||||||
|
// description: 'خرید سفارش',
|
||||||
|
// });
|
||||||
|
// res.redirect(result.payment_url);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۸. نمونه کد PHP
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
function stable_stringify($obj) {
|
||||||
|
if (is_null($obj) || !is_array($obj)) {
|
||||||
|
return json_encode($obj, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
// تشخیص array لیستی vs associative
|
||||||
|
if (array_is_list($obj)) {
|
||||||
|
$parts = array_map('stable_stringify', $obj);
|
||||||
|
return '[' . implode(',', $parts) . ']';
|
||||||
|
}
|
||||||
|
ksort($obj);
|
||||||
|
$parts = [];
|
||||||
|
foreach ($obj as $k => $v) {
|
||||||
|
$parts[] = '"' . $k . '":' . stable_stringify($v);
|
||||||
|
}
|
||||||
|
return '{' . implode(',', $parts) . '}';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sign_request(string $method, string $path, ?array $body, string $clientId, string $secret): array {
|
||||||
|
$timestamp = (string) time();
|
||||||
|
$nonce = bin2hex(random_bytes(16));
|
||||||
|
$bodyString = $body ? stable_stringify($body) : '';
|
||||||
|
$bodyHash = hash('sha256', $bodyString);
|
||||||
|
$canonical = strtoupper($method) . "\n" . $path . "\n" . $bodyHash . "\n" . $timestamp . "\n" . $nonce;
|
||||||
|
$signature = hash_hmac('sha256', $canonical, $secret);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'X-Client-Id: ' . $clientId,
|
||||||
|
'X-Timestamp: ' . $timestamp,
|
||||||
|
'X-Nonce: ' . $nonce,
|
||||||
|
'X-Signature: ' . $signature,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function initiate_payment(array $payload, string $baseUrl, string $clientId, string $secret): array {
|
||||||
|
$path = '/external-payment/initiate';
|
||||||
|
$headers = sign_request('POST', $path, $payload, $clientId, $secret);
|
||||||
|
$ch = curl_init(rtrim($baseUrl, '/') . $path);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
]);
|
||||||
|
$raw = curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
if ($code >= 400) {
|
||||||
|
throw new RuntimeException($data['message'] ?? 'initiate failed');
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verify_return_signature(array $query, string $secret): bool {
|
||||||
|
$signature = $query['signature'] ?? '';
|
||||||
|
unset($query['signature']);
|
||||||
|
$expected = hash_hmac('sha256', stable_stringify($query), $secret);
|
||||||
|
return hash_equals($expected, $signature);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۹. چکلیست پیادهسازی سایت شما
|
||||||
|
|
||||||
|
1. `client_id` / `client_secret` را فقط در env سرور نگه دارید.
|
||||||
|
2. قبل از پرداخت، سفارش خود را با وضعیت «در انتظار پرداخت» ذخیره کنید.
|
||||||
|
3. از سرور `initiate` بزنید و کاربر را به `payment_url` بفرستید.
|
||||||
|
4. صفحه `return_url` را بسازید و signature را verify کنید.
|
||||||
|
5. endpoint webhook بسازید و signature را verify کنید؛ سفارش را آنجا `paid` کنید.
|
||||||
|
6. برای اطمینان، در صورت نیاز `GET /status` هم بزنید (idempotent).
|
||||||
|
7. `reference_id` را یکتا و قابل ردیابی نگه دارید.
|
||||||
|
8. `return_url` را دقیقاً زیر پیشوند whitelist ثبتشده بفرستید.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱۰. امنیت — خلاصه قوانین
|
||||||
|
|
||||||
|
| کار درست | کار غلط |
|
||||||
|
|----------|---------|
|
||||||
|
| initiate فقط از سرور | ساخت لینک پرداخت در مرورگر کاربر |
|
||||||
|
| امضا با `client_secret` | گذاشتن secret در JS فرانت |
|
||||||
|
| verify امضای redirect و webhook | اعتماد کور به query بدون signature |
|
||||||
|
| تطبیق `amount` با سفارش | قبول هر مبلغ برگشتی |
|
||||||
|
| whitelist برای `return_url` | قبول هر URL دلخواه |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱۱. پشتیبانی
|
||||||
|
|
||||||
|
در صورت خطای مداوم `invalid_signature`:
|
||||||
|
|
||||||
|
1. ساعت سرور (NTP) را چک کنید.
|
||||||
|
2. `stableStringify` و ترتیب کلیدها را بررسی کنید.
|
||||||
|
3. `PATH` دقیقاً `/external-payment/initiate` باشد (بدون domain و بدون slash اضافه).
|
||||||
|
4. body ارسالی دقیقاً همان چیزی باشد که hash شده (نه serialize متفاوت axios/curl).
|
||||||
|
|
||||||
|
برای فعالسازی partner جدید یا افزودن دامنه به whitelist با ادمین Montify هماهنگ کنید.
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
# شارژ آنلاین کیف پول (Wallet Online Payment)
|
||||||
|
|
||||||
|
مستند پیادهسازی داخلی پروژه برای شارژ کیف پول از طریق درگاه.
|
||||||
|
برای پروتکل Montify (HMAC، initiate، webhook) به سند جداگانه مراجعه کنید:
|
||||||
|
|
||||||
|
→ [`EXTERNAL_PAYMENT_PARTNER.md`](./EXTERNAL_PAYMENT_PARTNER.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱. هدف
|
||||||
|
|
||||||
|
- کاربر بتواند علاوه بر واریز دستی (کارت/شبا)، از **پرداخت آنلاین** کیف را شارژ کند.
|
||||||
|
- تمام credentialها و انتخاب درگاه **فقط سمت سرور** بماند.
|
||||||
|
- معماری طوری باشد که بعداً چند نوع درگاه (Montify پارتنر، درگاه مستقیم، …) اضافه شود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۲. جریان کلی (از دید کاربر و سیستم)
|
||||||
|
|
||||||
|
```
|
||||||
|
اپ کاربر (khaneghi_user)
|
||||||
|
│ مبلغ را وارد میکند → مدال راهنمای شارژ
|
||||||
|
│ GET /customer/wallet/payment/options → { online_enabled }
|
||||||
|
│ POST /customer/wallet/payment/initiate → { payment_url }
|
||||||
|
▼
|
||||||
|
سرور ما
|
||||||
|
│ Withdraw (sts=1) میسازد
|
||||||
|
│ درگاه فعال را از Settings انتخاب میکند (مثلاً montify)
|
||||||
|
│ با HMAC به Montify: POST /external-payment/initiate
|
||||||
|
▼
|
||||||
|
Montify → payment_url → درگاه بانکی
|
||||||
|
│
|
||||||
|
├─► Browser return → GET /customer/wallet/payment/return/{provider}
|
||||||
|
└─► Webhook → POST /customer/wallet/payment/webhook/{provider}
|
||||||
|
│
|
||||||
|
└─► شارژ idempotent کیف (sts=2 + Transaction)
|
||||||
|
```
|
||||||
|
|
||||||
|
**منبع حقیقت شارژ:** webhook (یا verify امن سمت سرور). فقط به query برگشت مرورگر اعتماد نکنید؛ امضا را verify کنید و مبلغ/`reference_id` را با `Withdraw` تطبیق دهید.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۳. امنیت — قوانین اجباری
|
||||||
|
|
||||||
|
| درست | غلط |
|
||||||
|
|------|-----|
|
||||||
|
| `client_id` / `client_secret` / `base_url` فقط در Settings سرور | گذاشتن secret در JS فرانت |
|
||||||
|
| فیلتر `api/get_settings` برای کلیدهای محرمانه | برگرداندن `external_payment_*` به اپ کاربر |
|
||||||
|
| اپ فقط `online_enabled` و بعد `payment_url` میبیند | خواندن provider یا credential از settings در کلاینت |
|
||||||
|
| initiate فقط با JWT مشتری | ساخت لینک پرداخت در مرورگر |
|
||||||
|
| verify امضای return و webhook | اعتماد کور به query بدون signature |
|
||||||
|
| تطبیق `amount` با `Withdraw.cash` | قبول هر مبلغ برگشتی |
|
||||||
|
|
||||||
|
### ۳.۱. چه چیزی به اپ کاربر میرسد؟
|
||||||
|
|
||||||
|
- `GET /customer/wallet/payment/options` → فقط `{ "online_enabled": true|false }`
|
||||||
|
- `POST /customer/wallet/payment/initiate` → فقط `{ "payment_url": "https://..." }`
|
||||||
|
- **هیچ** `client_id`، `client_secret`، `base_url`، نام درگاه داخلی، یا `wallet_payment_provider` در Network تب اپ نباید دیده شود.
|
||||||
|
|
||||||
|
### ۳.۲. فیلتر `get_settings`
|
||||||
|
|
||||||
|
در `App\Models\Setting::isSecretOrServerOnlyKey()` و `SettingsController::authorizeSettingsPayload()`:
|
||||||
|
|
||||||
|
- درخواست عمومی (`api/get_settings` بدون auth ادمین) → کلیدهای محرمانه حذف میشوند.
|
||||||
|
- پنل ادمین (`admin/get_settings` با `auth:user`) → همه کلیدها قابل مشاهده/ویرایش طبق `can_edit`.
|
||||||
|
|
||||||
|
کلیدهای مرتبط با پرداخت که به اپ عمومی نمیروند شامل:
|
||||||
|
|
||||||
|
- همه `external_payment_*`
|
||||||
|
- `wallet_payment_provider`
|
||||||
|
- همه `payping_*`
|
||||||
|
- الگوهای `*_secret` / `*_token` / `*_password` و لیست ثابت توکنها
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۴. Settings (جدول `settings`)
|
||||||
|
|
||||||
|
پیکربندی فقط از پنل ادمین / دیپلوی. گروه UI: **درگاه خارجی (Montify)**.
|
||||||
|
|
||||||
|
| کلید | نوع | توضیح |
|
||||||
|
|------|-----|--------|
|
||||||
|
| `wallet_payment_provider` | select | `none` = خاموش، `montify` = پارتنر Montify. برای درگاههای بعدی همین فیلد گسترش مییابد. |
|
||||||
|
| `external_payment_enabled` | switch | سازگاری قدیمی؛ اگر `true` باشد و provider خالی باشد، مثل `montify` رفتار میکند. ترجیحاً از `wallet_payment_provider` استفاده کنید. |
|
||||||
|
| `external_payment_base_url` | text | BASE_URL API Montify (بدون اسلش انتهایی) |
|
||||||
|
| `external_payment_client_id` | text | `client_id` پارتنر |
|
||||||
|
| `external_payment_client_secret` | text | `client_secret` — محرمانه |
|
||||||
|
| `external_payment_gateway` | select | درگاه داخل Montify: `zarinpall` / `zibal` / `payping` / `sepal` |
|
||||||
|
| `external_payment_return_url` | text | اختیاری؛ خالی = پیشفرض سرور |
|
||||||
|
| `external_payment_webhook_url` | text | اختیاری؛ خالی = پیشفرض سرور |
|
||||||
|
| `min_deposit` | price | حداقل مبلغ واریز (مشترک با واریز دستی) |
|
||||||
|
|
||||||
|
### مقادیر پیشفرض URL وقتی فیلد خالی است
|
||||||
|
|
||||||
|
- Return: `{APP}/customer/wallet/payment/return/montify`
|
||||||
|
- Webhook: `{APP}/customer/wallet/payment/webhook/montify`
|
||||||
|
|
||||||
|
این آدرسها باید در whitelist پارتنر Montify ثبت شوند.
|
||||||
|
|
||||||
|
### Migrationها
|
||||||
|
|
||||||
|
- `2026_07_24_000001_add_external_payment_settings.php`
|
||||||
|
- `2026_07_24_000002_add_wallet_payment_provider_setting.php`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۵. APIهای سرور ما
|
||||||
|
|
||||||
|
پیشوند: `/customer`
|
||||||
|
Auth مشتری: `auth:customer` + `customer.active` (بهجز return/webhook)
|
||||||
|
|
||||||
|
| متد | مسیر | Auth | نقش |
|
||||||
|
|-----|------|------|-----|
|
||||||
|
| GET | `/wallet/payment/options` | مشتری | `{ online_enabled }` |
|
||||||
|
| POST | `/wallet/payment/initiate` | مشتری | Body: `{ value }` (ریال) → `{ payment_url }` |
|
||||||
|
| GET | `/wallet/payment/return/{provider}` | عمومی | برگشت مرورگر؛ verify + گزارش |
|
||||||
|
| POST | `/wallet/payment/webhook/{provider}` | عمومی (HMAC) | شارژ کیف |
|
||||||
|
|
||||||
|
### سازگاری قدیمی
|
||||||
|
|
||||||
|
| مسیر قدیمی | معادل |
|
||||||
|
|------------|--------|
|
||||||
|
| `POST /wallet/external-payment/initiate` | همان `initiate` |
|
||||||
|
| `GET /wallet/external-payment/return` | `return/montify` |
|
||||||
|
| `POST /wallet/external-payment/webhook` | `webhook/montify` |
|
||||||
|
|
||||||
|
### Initiate — جزئیات
|
||||||
|
|
||||||
|
1. بررسی `WalletPaymentManager::isOnlineDepositAvailable()`
|
||||||
|
2. اعتبارسنجی مبلغ (≥ ۱۰۰۰ و ≥ `min_deposit`)
|
||||||
|
3. ساخت `Withdraw` با `sts=1`، `note=واریز آنلاین`
|
||||||
|
4. `reference_id = WD-{withdraw_id}`
|
||||||
|
5. فراخوانی `gateway->initiate(...)`
|
||||||
|
6. ذخیره `session_id` در `tracked_number` در صورت وجود
|
||||||
|
7. پاسخ فقط `payment_url`
|
||||||
|
|
||||||
|
### Webhook / Return — شارژ
|
||||||
|
|
||||||
|
منطق مشترک در `App\Services\Payment\WalletDepositCreditor`:
|
||||||
|
|
||||||
|
- قفل ردیف `Withdraw` (idempotent)
|
||||||
|
- اگر قبلاً `sts=2` باشد، دوباره شارژ نمیکند
|
||||||
|
- `TransactionInterface` نوع واریز نقدی
|
||||||
|
- همگامسازی حسابداری (Pargar) در صورت امکان
|
||||||
|
- SMS ادمین با سوییچ `sms_notif_gateway_charge_admin`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۶. معماری کد (چنددرگاهی)
|
||||||
|
|
||||||
|
```
|
||||||
|
app/Services/Payment/
|
||||||
|
Contracts/
|
||||||
|
WalletPaymentGatewayInterface.php # قرارداد درگاه
|
||||||
|
Gateways/
|
||||||
|
MontifyPartnerGateway.php # پیادهسازی فعلی Montify
|
||||||
|
# PaypingDirectGateway.php # آینده
|
||||||
|
Support/
|
||||||
|
MontifyHmacSigner.php # HMAC + stableStringify
|
||||||
|
WalletPaymentManager.php # انتخاب درگاه از Settings
|
||||||
|
WalletDepositCreditor.php # شارژ idempotent کیف
|
||||||
|
|
||||||
|
app/Http/Controllers/pay/
|
||||||
|
WalletPaymentController.php # options / initiate / return / webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
### قرارداد درگاه
|
||||||
|
|
||||||
|
هر درگاه باید اینها را پیاده کند:
|
||||||
|
|
||||||
|
- `key(): string` — مثلاً `montify`
|
||||||
|
- `isAvailable(): bool` — فعال + پیکربندی کامل
|
||||||
|
- `initiate(array $payload): array` — حداقل `payment_url`
|
||||||
|
- `returnUrl()` / `webhookUrl()`
|
||||||
|
- `handleReturn(Request): array`
|
||||||
|
- `handleWebhook(Request): array`
|
||||||
|
|
||||||
|
### افزودن درگاه جدید (چکلیست)
|
||||||
|
|
||||||
|
1. کلاس جدید در `Gateways/` با `WalletPaymentGatewayInterface`
|
||||||
|
2. ثبت در `WalletPaymentManager::drivers()`
|
||||||
|
3. افزودن مقدار به optionsی `wallet_payment_provider` در migration/admin (`settingTypes.ts`)
|
||||||
|
4. Settings مخصوص آن درگاه (و اضافه کردن به فیلتر secret در `Setting::isSecretOrServerOnlyKey`)
|
||||||
|
5. مسیرهای `return/{provider}` و `webhook/{provider}` از قبل generic هستند
|
||||||
|
6. تست: options → initiate → webhook → موجودی کیف
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۷. فرانتاند (khaneghi_user)
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `src/api/wallet.ts` | `getWalletPaymentOptions` / `initiateWalletPayment` |
|
||||||
|
| `src/views/base/wallet/composables/useWalletPage.ts` | بارگذاری `online_enabled`، `payOnline()` |
|
||||||
|
| `src/views/base/wallet/components/WalletDepositGuideModal.vue` | دکمه «پرداخت آنلاین از درگاه» |
|
||||||
|
| `src/views/base/wallet/index.vue` | اتصال props/events مدال |
|
||||||
|
|
||||||
|
رفتار UI:
|
||||||
|
|
||||||
|
1. کاربر مبلغ را وارد میکند و واریز میزند → مدال راهنما (کارت/شبا + پشتیبانی)
|
||||||
|
2. اگر `online_enabled` باشد دکمه درگاه نمایش داده میشود
|
||||||
|
3. کلیک → `initiate` → `window.location = payment_url`
|
||||||
|
|
||||||
|
**نکته:** وضعیت دکمه از endpoint سرور میآید، نه از `settingsStore` و کلیدهای `external_payment_*`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۸. پنل ادمین
|
||||||
|
|
||||||
|
- گروه تنظیمات: `admin/.../configuration/settingGroups.ts` → id `external_payment`
|
||||||
|
- انواع/برچسب/قفل: `settingTypes.ts`
|
||||||
|
- ویرایش credentialها فقط در ادمین (و کلیدهای `can_edit=0` فقط دیپلوی)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۹. مدل داده مرتبط
|
||||||
|
|
||||||
|
| مدل | نقش |
|
||||||
|
|-----|-----|
|
||||||
|
| `Withdraw` | درخواست واریز؛ `sts`: 1=در انتظار، 2=موفق، 3=ناموفق |
|
||||||
|
| `reference_id` | `WD-{id}` برای Montify |
|
||||||
|
| Wallet `TransactionInterface` | واریز نقدی پس از موفقیت |
|
||||||
|
|
||||||
|
واریز دستی تغییری نکرده؛ فقط راهنما + کپی کارت/شبا از Settings عمومی (`deposit_card_number` و …) است.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱۰. راهاندازی عملیاتی
|
||||||
|
|
||||||
|
1. `php artisan migrate`
|
||||||
|
2. در پنل ادمین / دیپلوی:
|
||||||
|
- `wallet_payment_provider` = `montify`
|
||||||
|
- `external_payment_base_url` / `client_id` / `client_secret` / `gateway`
|
||||||
|
3. در پنل Montify:
|
||||||
|
- ثبت `allowed_return_urls` و `webhook_url` مطابق URLهای سرور
|
||||||
|
4. تست با مبلغ ≥ `min_deposit`
|
||||||
|
5. تأیید: Network اپ کاربر فاقد secret باشد؛ فقط `options` و `initiate` دیده شود
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱۱. عیبیابی سریع
|
||||||
|
|
||||||
|
| مشکل | بررسی |
|
||||||
|
|------|--------|
|
||||||
|
| دکمه درگاه نیست | `options.online_enabled`؛ provider؛ کامل بودن credential؛ `isAvailable()` |
|
||||||
|
| `مشکل در ارتباط با درگاه` | ساعت سرور (NTP)، `stableStringify`، PATH دقیق، body خام همان hash |
|
||||||
|
| شارژ دوبل | باید idempotent باشد (`sts=2` + lock) |
|
||||||
|
| secret در Network | مسیر باید `api/get_settings` باشد نه admin؛ فیلتر `Setting::forPublicApi` |
|
||||||
|
|
||||||
|
جزئیات امضای HMAC و خطاهای Montify: [`EXTERNAL_PAYMENT_PARTNER.md`](./EXTERNAL_PAYMENT_PARTNER.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱۲. ارجاع فایلهای کلیدی
|
||||||
|
|
||||||
|
```
|
||||||
|
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/routes/customer.php
|
||||||
|
server/app/Models/Setting.php ← فیلتر secret
|
||||||
|
server/database/migrations/2026_07_24_000001_add_external_payment_settings.php
|
||||||
|
server/database/migrations/2026_07_24_000002_add_wallet_payment_provider_setting.php
|
||||||
|
khaneghi_user/src/api/wallet.ts
|
||||||
|
khaneghi_user/src/views/base/wallet/...
|
||||||
|
admin/src/pages/menuPages/configuration/settingTypes.ts
|
||||||
|
admin/src/pages/menuPages/configuration/settingGroups.ts
|
||||||
|
```
|
||||||
@@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Route;
|
|||||||
use App\Http\Controllers\Single\BankAccountController;
|
use App\Http\Controllers\Single\BankAccountController;
|
||||||
use \App\Http\Controllers\Customer\FundController;
|
use \App\Http\Controllers\Customer\FundController;
|
||||||
use \App\Http\Controllers\pay\paypingController;
|
use \App\Http\Controllers\pay\paypingController;
|
||||||
|
use App\Http\Controllers\pay\WalletPaymentController;
|
||||||
use \App\Http\Controllers\Single\SingleOrderController;
|
use \App\Http\Controllers\Single\SingleOrderController;
|
||||||
use App\Http\Controllers\Admin\ProductController as AdminProductController;
|
use App\Http\Controllers\Admin\ProductController as AdminProductController;
|
||||||
use App\Http\Controllers\Customer\ProductController;
|
use App\Http\Controllers\Customer\ProductController;
|
||||||
@@ -27,6 +28,13 @@ Route::get('/TCharchee ', [paypingController::class, 'TCharchee']);
|
|||||||
Route::post('/charge_account_verify', [paypingController::class, 'verifyTransation']);
|
Route::post('/charge_account_verify', [paypingController::class, 'verifyTransation']);
|
||||||
Route::post('/charge_account_verify/{id}', [paypingController::class, 'verifyTransationByID']);
|
Route::post('/charge_account_verify/{id}', [paypingController::class, 'verifyTransationByID']);
|
||||||
|
|
||||||
|
//----------wallet online payment — return/webhook per provider (public)
|
||||||
|
Route::get('/wallet/payment/return/{provider}', [WalletPaymentController::class, 'returnFromGateway']);
|
||||||
|
Route::post('/wallet/payment/webhook/{provider}', [WalletPaymentController::class, 'webhook']);
|
||||||
|
// سازگاری با URLهای قبلی Montify
|
||||||
|
Route::get('/wallet/external-payment/return', [WalletPaymentController::class, 'returnLegacy']);
|
||||||
|
Route::post('/wallet/external-payment/webhook', [WalletPaymentController::class, 'webhookLegacy']);
|
||||||
|
|
||||||
$isStore = app(ConfigService::class)->isStore();
|
$isStore = app(ConfigService::class)->isStore();
|
||||||
|
|
||||||
Route::middleware('validation')->group(function () {
|
Route::middleware('validation')->group(function () {
|
||||||
@@ -74,6 +82,10 @@ Route::middleware(['auth:customer', 'customer.active'])->group(function () use (
|
|||||||
|
|
||||||
//-----gateway
|
//-----gateway
|
||||||
Route::post('/charge_account ', [paypingController::class, 'startTransation']);
|
Route::post('/charge_account ', [paypingController::class, 'startTransation']);
|
||||||
|
Route::get('/wallet/payment/options', [WalletPaymentController::class, 'options']);
|
||||||
|
Route::post('/wallet/payment/initiate', [WalletPaymentController::class, 'initiate']);
|
||||||
|
// سازگاری قدیمی
|
||||||
|
Route::post('/wallet/external-payment/initiate', [WalletPaymentController::class, 'initiate']);
|
||||||
|
|
||||||
|
|
||||||
//--------------Customer
|
//--------------Customer
|
||||||
|
|||||||
Reference in New Issue
Block a user