Feat add payment with gateway of montify

This commit is contained in:
mahmood
2026-07-24 16:50:21 +03:30
parent eacf5181a9
commit 572815a6a9
13 changed files with 1710 additions and 14 deletions
@@ -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,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);
}
}
+59
View File
@@ -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,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(),
];
}
}