Files
boursecu_server/docs/EXTERNAL_PAYMENT_PARTNER.md
T

479 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# راهنمای اتصال پرداخت خارجی (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
&timestamp=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 هماهنگ کنید.