Fix some for sign body to montify and not get 401 error|

This commit is contained in:
mahmood
2026-07-27 01:36:02 +03:30
parent 815da9896d
commit 933424ddfc
8 changed files with 381 additions and 36 deletions
@@ -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