💳 ریختمشروع رایگان

مستندات ریختم

ریختم هیچ نقش واسطه‌ای ندارد: پول مستقیم به حساب شما واریز می‌شود. اپلیکیشن پیامک واریز بانک را می‌خواند و سرور ما با یک درخواست ساده به سایت یا ربات شما خبر می‌دهد تا صورتحساب همان لحظه تایید شود.

🧩 افزونه‌ی ووکامرس

یک درگاه پرداخت «کارت به کارت با تایید خودکار» به ووکامرس اضافه می‌کند. با تسویه حساب کلاسیک و بلوکی ووکامرس و ذخیره‌سازی جدید سفارش‌ها (HPOS) سازگار است.

⬇️ دانلود افزونه

نصب در ۵ قدم

  1. پیشخوان وردپرس ← افزونه‌ها ← افزودن ← بارگذاری افزونه ← فایل zip را انتخاب و فعال کنید.
  2. ووکامرس ← پیکربندی ← پرداخت‌ها ← کارت به کارت خودکار را باز کنید.
  3. کارت‌ها را وارد کنید؛ هر خط: شماره کارت | نام صاحب کارت | بانک | شبا.
  4. آدرسی که همان صفحه نشان می‌دهد (/wp-json/rikhtam/v1/callback) را خودتان در پنل ریختم (بخش «درگاه‌ها») به‌عنوان «آدرس دریافت‌کننده» ثبت کنید، بعد «کلید امنیتی» همان درگاه را از پنل بردارید و در فیلد Secret افزونه بگذارید.
  5. در اپلیکیشن ریختم، حساب بانکی را اضافه و به همین درگاه وصل کنید. تمام! یک سفارش تستی ارزان بزنید و واریز کنید.
✅ مشتری بعد از ثبت سفارش، مبلغ یکتا و شماره کارت را با دکمه‌ی کپی می‌بیند. صفحه هر چند ثانیه وضعیت را چک می‌کند و بلافاصله بعد از واریز، پیام تایید نشان داده می‌شود؛ بدون ارسال فیش.

⚙️ تنظیمات افزونه

تنظیمتوضیح
عنوان و توضیحاتمتنی که مشتری در صفحه‌ی پرداخت، صفحه‌ی تشکر و ایمیل می‌بیند؛ کاملاً قابل تغییر.
کارت‌هایک یا چند کارت. حالت «یک کارت تصادفی برای هر سفارش» واریزها را بین حساب‌ها پخش می‌کند.
واحد پول فروشگاهخودکار از ووکامرس (تومان، ریال، هزار تومان، هزار ریال) یا دستی.
عدد یکتاواحد (تومان/ریال)، اضافه یا کم شدن از مبلغ (تخفیف کوچک برای مشتری) و بازه‌ی عدد (پیش‌فرض ۱ تا ۹۹۹).
وضعیت سفارشقبل از واریز «در انتظار بررسی» یا «در انتظار پرداخت»؛ بعد از تایید «پیش‌فرض»، «در حال انجام» یا «تکمیل شده».
مهلت واریزسفارش پرداخت‌نشده بعد از N دقیقه لغو می‌شود (۰ = هرگز). اگر واریز دیر برسد، سفارش لغوشده دوباره فعال و پرداخت می‌شود (قابل خاموش کردن).
حداقل / حداکثر مبلغدرگاه فقط برای سبدهای در این بازه نمایش داده می‌شود.
ایمیل، بروزرسانی خودکار صفحه، نمایش شبا، گزارش اشکال‌یابیهر کدام جدا روشن/خاموش می‌شود. گزارش‌ها: ووکامرس ← وضعیت ← گزارش‌ها ← rikhtam.

برای توسعه‌دهنده‌ها: اکشن rikhtam_order_paid (‏$order, $deposit) بعد از هر تایید اجرا می‌شود.

🔔 بروزرسانی افزونه

افزونه هر ۶ ساعت نسخه‌ی جدید را از سرور ریختم چک می‌کند. وقتی نسخه‌ی جدید منتشر شود:

  • در پیشخوان وردپرس اعلانی با فهرست تغییرات و دکمه‌ی «بروزرسانی همین حالا» نمایش داده می‌شود.
  • در صفحه‌ی افزونه‌ها و پیشخوان ← بروزرسانی‌ها هم مثل بقیه‌ی افزونه‌ها قابل نصب با یک کلیک است (بروزرسانی خودکار وردپرس هم پشتیبانی می‌شود).
  • برای چک فوری: افزونه‌ها ← زیر «ریختم» ← بررسی بروزرسانی.

تنظیمات و سفارش‌ها با بروزرسانی پاک نمی‌شوند.

🛠 طرز کار (اتصال اختصاصی)

  1. سایت شما برای هر صورتحساب یک مبلغ یکتا می‌سازد و همراه شماره کارت به مشتری نشان می‌دهد.
  2. مشتری کارت به کارت می‌کند؛ پیامک واریز به گوشی‌ای می‌رسد که اپلیکیشن ریختم روی آن است.
  3. اپلیکیشن مبلغ را از پیامک جدا می‌کند و به سرور ریختم می‌فرستد.
  4. اپلیکیشن خودش، مستقیم از روی گوشی، واریز را با یک درخواست POST به آدرس درگاه شما می‌فرستد؛ تایید پرداخت به سایت یا سرور ریختم وابسته نیست و حتی اگر سایت اصلی ما در دسترس نباشد کار می‌کند. ثبت سابقه و کارمزد هم بعداً و خودکار در سرور انجام می‌شود.
  5. شما فقط key را با کلید امنیتی خودتان مقایسه می‌کنید، صورتحساب هم‌مبلغ را پیدا و تایید می‌کنید و {"ok":true} برمی‌گردانید.

خودتان در پنل کاربری سایت (بخش «درگاه‌ها») برای هر درگاه «آدرس دریافت» (کال‌بک، فقط https و با دامنه‌ی عمومی) را ثبت می‌کنید و «کلید امنیتی» همان‌جا برایتان ساخته می‌شود. چند درگاه می‌توانند یک حساب مشترک داشته باشند؛ واریز به همه‌ی درگاه‌های آن حساب عرضه می‌شود تا درگاه‌ای که صورتحساب هم‌مبلغ دارد آن را بپذیرد. به محض اینکه یکی از درگاه‌ها واریز را تایید کند، همان واریز برای بقیه‌ی درگاه‌ها باطل می‌شود و در اپ و پنل «فاکتور پیدا نشد» یا «در صف» نشان داده نمی‌شود.

🔢 مبلغ یکتا

تنها اطلاعات قابل اعتماد پیامک بانک «مبلغ» است؛ پس مبلغ صورتحساب‌های باز نباید تکراری باشد. روش پیشنهادی:

// مبلغ اصلی 125,000 تومان → 125,317 تومان (عدد ۳ رقمی تصادفی که بین صورتحساب‌های باز تکراری نباشد)
$unique = $price + random_int(1, 999);
// اگر با ریال کار می‌کنید: 1,250,000 → 1,250,317 ریال
صورتحساب‌های قدیمی (مثلاً بعد از ۱ تا ۱۲ ساعت) را منقضی کنید تا عددها آزاد شوند.
⚠ حداقل مبلغ صورتحساب ۱۰ هزار تومان است. فاکتورهای زیر ۱۰ هزار تومان پردازش نمی‌شوند (بعضی بانک‌ها برای مبلغ‌های پایین پیامک واریز نمی‌فرستند). مبلغ یکتا را هم طوری بسازید که پایین‌تر از این حد نیاید.

📥 درخواستی که به آدرس شما می‌رسد

روش POST با بدنه‌ی فرم (application/x-www-form-urlencoded)، User-Agent: Rikhtam/1.0. درخواست از روی گوشی‌ای که اپلیکیشن روی آن نصب است فرستاده می‌شود، پس IP ثابتی ندارد؛ آدرس دریافت‌کننده را بر اساس IP فیلتر نکنید و فقط با key احراز کنید. اگر گوشی اینترنت نداشته باشد یا درگاه شما در دسترس نباشد، واریز در صف می‌ماند و دوباره تلاش می‌شود.

فیلدتوضیح
project_idشناسه‌ی درگاه در ریختم
uidشناسه‌ی یکتای واریز؛ برای جلوگیری از تایید تکراری ذخیره‌اش کنید
amountمبلغ در واحدی که برای درگاه تنظیم شده (ریال، یا تومان = ریال ÷ ۱۰)
amount_rialمبلغ خام پیامک به ریال
accountشماره حسابی که کاربر در اپلیکیشن ثبت کرده (ممکن است خالی باشد)
sms_time / timeزمان پیامک: ثانیه‌ی یونیکس / متن به وقت تهران
tsزمان ارسال درخواست (ثانیه‌ی یونیکس)؛ اختیاری
event_idشماره‌ی واریز در سرور ریختم؛ وقتی اپلیکیشن مستقیم خبر می‌دهد 0 است، پس به آن تکیه نکنید و از uid استفاده کنید
invoice, manualفقط در پیوست دستی: شماره‌ی صورتحسابی که صاحب درگاه در اپلیکیشن وارد کرده
keyکلید امنیتی درگاه شما؛ فقط با کلید خودتان مقایسه کنید
testفقط در «تست سلامت درگاه» (1)؛ توضیح

🔐 احراز درخواست

فقط یک شرط: فیلد key باید دقیقاً با «کلید امنیتی» درگاه شما در پنل برابر باشد؛ وگرنه 403 برگردانید. آدرس دریافت فقط https است و کلید فقط دست شما و ماست، پس بدون آن کسی نمی‌تواند واریز جعلی بفرستد. تکراری بودن را هم همان uid می‌گیرد.

if (!hash_equals(SECRET, $_POST['key'] ?? '')) { http_response_code(403); exit; }

📤 پاسخ شما

وضعیتپاسخکار سرور
تایید شد (یا همین واریز قبلاً تایید شده)200 و {"ok":true,"invoice":"4821"}تمام؛ شماره‌ی invoice کنار واریز در اپلیکیشن و پنل نمایش داده می‌شود
صورتحسابی با این مبلغ/شماره نیست404درگاه بعدیِ همان حساب امتحان می‌شود و بعداً دوباره تلاش می‌شود
صورتحساب قبلاً پرداخت شده409دوباره تایید نمی‌شود؛ پیام مناسب به صاحب درگاه نشان داده می‌شود
صورتحساب لغو یا منقضی شده410مثل بالا
خطای موقت5xx یا تایم‌اوتتلاش مجدد با فاصله‌ی افزایشی

invoice در پاسخ اختیاری ولی بسیار پیشنهادی است: شماره‌ی صورتحساب/سفارشی که تایید کردید. با آن در اپلیکیشن می‌بینید هر واریز برای کدام صورتحساب بوده.

مهلت پاسخ تا ۳۰ ثانیه است (در تست سلامت و پیوست دستی که از سرور ما فرستاده می‌شود تا ۲۰ ثانیه)؛ کارهای سنگین (ارسال پیام، ایمیل) را بعد از پاسخ یا در صف انجام دهید.

🔗 پیوست دستی به صورتحساب

اگر مشتری به‌جای مبلغ یکتا یک عدد رند (مثلاً ۱۲۵,۰۰۰ به‌جای ۱۲۵,۳۱۷) واریز کند، هیچ صورتحسابی خودکار تایید نمی‌شود. صاحب درگاه در اپلیکیشن روی همان واریز «پیوست به صورتحساب» را می‌زند و شماره‌ی صورتحساب را وارد می‌کند. سرور همان واریز را دوباره به آدرس شما می‌فرستد، این بار با:

فیلدتوضیح
invoiceشماره‌ی صورتحسابی که صاحب درگاه وارد کرده
manual1

در این حالت صورتحساب را با شماره پیدا کنید، نه مبلغ:

  • صورتحساب نیست → 404
  • قبلاً پرداخت شده → 409 (یک صورتحساب هرگز دو بار تایید نمی‌شود)
  • لغو/منقضی → 410 (یا اگر می‌خواهید، دوباره فعالش کنید و تایید کنید)
  • در غیر این صورت پرداخت‌شده کنید و {"ok":true,"invoice":"…"} برگردانید.
سرور ریختم هم جلوی تایید دوباره‌ی یک صورتحساب را می‌گیرد: اگر شماره‌ای قبلاً با واریز دیگری در همان درگاه تایید شده باشد، اصلاً ارسال نمی‌شود. حتماً در کدتان بررسی invoice را بگذارید؛ نمونه‌کدهای پایین آن را دارند.

🔁 تلاش مجدد و درخواست تکراری

اگر پاسخ موفق نگیرد، سرور با فاصله‌ی ۱، ۲، ۵، ۱۰، ۳۰، ۶۰، ۱۲۰ و سپس هر ۳۶۰ دقیقه دوباره می‌فرستد (تا ۳۰ بار). پس یک واریز ممکن است بیش از یک بار برسد: uid را ذخیره کنید و برای درخواست تکراری هم {"ok":true} (همراه شماره‌ی صورتحساب) برگردانید، بدون تایید دوباره.

💻 کد آماده

همه‌ی نمونه‌ها کامل‌اند: بررسی کلید، تکراری، پیدا کردن با مبلغ یا شماره، جلوگیری از تایید دوباره و برگرداندن شماره‌ی صورتحساب. فقط سه تابع دیتابیس را به سیستم خودتان وصل کنید.

<?php
// rikhtam.php — آدرس همین فایل را در پنل ریختم ثبت کنید
const SECRET = 'کلید امنیتی درگاه';
header('Content-Type: application/json; charset=utf-8');
$f = fn($k) => (string)($_POST[$k] ?? '');

if (!hash_equals(SECRET, $f('key'))) {
    http_response_code(403); exit(json_encode(['ok' => false, 'error' => 'bad key']));
}
$invoice = trim($f('invoice'));

$pdo = new PDO('mysql:host=localhost;dbname=shop;charset=utf8mb4', 'user', 'pass', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);

// همین واریز قبلاً ثبت شده؟
$st = $pdo->prepare('SELECT id FROM invoices WHERE paid_uid = ?'); $st->execute([$f('uid')]);
if ($id = $st->fetchColumn()) exit(json_encode(['ok' => true, 'duplicate' => true, 'invoice' => (string)$id]));

if ($invoice !== '') {   // پیوست دستی: با شماره
    $st = $pdo->prepare('SELECT id, status FROM invoices WHERE id = ?'); $st->execute([$invoice]);
} else {                 // عادی: با مبلغ یکتا (صورتحساب‌های باز ۲۴ ساعت اخیر)
    $st = $pdo->prepare('SELECT id, status FROM invoices WHERE amount = ? AND status = "unpaid" AND created_at > NOW() - INTERVAL 1 DAY ORDER BY id DESC LIMIT 1');
    $st->execute([(int)$f('amount')]);
}
$inv = $st->fetch(PDO::FETCH_ASSOC);
if (!$inv) { http_response_code(404); exit(json_encode(['ok' => false, 'error' => 'invoice not found'])); }
if ($inv['status'] === 'paid') { http_response_code(409); exit(json_encode(['ok' => false, 'error' => 'invoice already paid'])); }
if ($inv['status'] === 'cancelled') { http_response_code(410); exit(json_encode(['ok' => false, 'error' => 'invoice cancelled'])); }

// تایید اتمی: فقط اگر هنوز پرداخت نشده
$up = $pdo->prepare('UPDATE invoices SET status = "paid", paid_uid = ?, paid_at = NOW() WHERE id = ? AND status <> "paid"');
$up->execute([$f('uid'), $inv['id']]);
if (!$up->rowCount()) { http_response_code(409); exit(json_encode(['ok' => false, 'error' => 'invoice already paid'])); }

echo json_encode(['ok' => true, 'invoice' => (string)$inv['id']]);
// بعد از پاسخ: فعال‌سازی سرویس، پیام به مشتری و ...
// routes/api.php
Route::post('/rikhtam', [RikhtamController::class, 'callback']);   // مسیر api است؛ CSRF ندارد

// app/Http/Controllers/RikhtamController.php
class RikhtamController extends Controller
{
    public function callback(Request $r)
    {
        $invoice = trim((string) $r->input('invoice', ''));
        if (!hash_equals(config('services.rikhtam.secret'), (string) $r->input('key'))) {
            return response()->json(['ok' => false, 'error' => 'bad key'], 403);
        }
        if ($dup = Invoice::where('paid_uid', $r->input('uid'))->first()) {
            return ['ok' => true, 'duplicate' => true, 'invoice' => (string) $dup->id];
        }
        $inv = $invoice !== ''
            ? Invoice::find($invoice)
            : Invoice::where('amount', (int) $r->input('amount'))->where('status', 'unpaid')->where('created_at', '>', now()->subDay())->latest('id')->first();
        if (!$inv) return response()->json(['ok' => false, 'error' => 'invoice not found'], 404);
        if ($inv->status === 'cancelled') return response()->json(['ok' => false, 'error' => 'invoice cancelled'], 410);

        $updated = Invoice::whereKey($inv->id)->where('status', '!=', 'paid')
            ->update(['status' => 'paid', 'paid_uid' => $r->input('uid'), 'paid_at' => now()]);
        if (!$updated) return response()->json(['ok' => false, 'error' => 'invoice already paid'], 409);

        dispatch(new ActivateOrder($inv->id));   // کار سنگین در صف
        return ['ok' => true, 'invoice' => (string) $inv->id];
    }
}
// Express
const express = require('express'), crypto = require('crypto');
const app = express();
const SECRET = process.env.RIKHTAM_SECRET;

app.post('/rikhtam', express.urlencoded({ extended: false }), async (req, res) => {
  const b = req.body, invoice = (b.invoice || '').trim();
  const given = String(b.key || '');
  const ok = given.length === SECRET.length && crypto.timingSafeEqual(Buffer.from(SECRET), Buffer.from(given));
  if (!ok) return res.status(403).json({ ok: false, error: 'bad key' });

  const dup = await db.invoice.findFirst({ where: { paidUid: b.uid } });
  if (dup) return res.json({ ok: true, duplicate: true, invoice: String(dup.id) });

  const inv = invoice
    ? await db.invoice.findUnique({ where: { id: Number(invoice) } })
    : await db.invoice.findFirst({ where: { amount: Number(b.amount), status: 'unpaid', createdAt: { gt: new Date(Date.now() - 864e5) } }, orderBy: { id: 'desc' } });
  if (!inv) return res.status(404).json({ ok: false, error: 'invoice not found' });
  if (inv.status === 'cancelled') return res.status(410).json({ ok: false, error: 'invoice cancelled' });

  const r = await db.invoice.updateMany({ where: { id: inv.id, status: { not: 'paid' } }, data: { status: 'paid', paidUid: b.uid, paidAt: new Date() } });
  if (!r.count) return res.status(409).json({ ok: false, error: 'invoice already paid' });
  res.json({ ok: true, invoice: String(inv.id) });
});
# Flask
import hmac
from flask import Flask, request
app = Flask(__name__)
SECRET = b'project secret'

@app.post('/rikhtam')
def rikhtam():
    f = request.form
    invoice = f.get('invoice', '').strip()
    if not hmac.compare_digest(SECRET, f.get('key', '').encode()):
        return {'ok': False, 'error': 'bad key'}, 403

    dup = db.one('SELECT id FROM invoices WHERE paid_uid = %s', f['uid'])
    if dup:
        return {'ok': True, 'duplicate': True, 'invoice': str(dup.id)}
    inv = (db.one('SELECT id, status FROM invoices WHERE id = %s', invoice) if invoice else
           db.one("SELECT id, status FROM invoices WHERE amount = %s AND status = 'unpaid' AND created_at > NOW() - INTERVAL '1 day' ORDER BY id DESC LIMIT 1", int(f['amount'])))
    if not inv:
        return {'ok': False, 'error': 'invoice not found'}, 404
    if inv.status == 'cancelled':
        return {'ok': False, 'error': 'invoice cancelled'}, 410
    if not db.execute("UPDATE invoices SET status = 'paid', paid_uid = %s, paid_at = NOW() WHERE id = %s AND status <> 'paid'", f['uid'], inv.id):
        return {'ok': False, 'error': 'invoice already paid'}, 409
    return {'ok': True, 'invoice': str(inv.id)}
# views.py  —  path('rikhtam/', views.rikhtam) در urls.py
import hmac
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.utils import timezone
from .models import Invoice

@csrf_exempt
@require_POST
def rikhtam(request):
    f = request.POST
    invoice = f.get('invoice', '').strip()
    if not hmac.compare_digest(settings.RIKHTAM_SECRET.encode(), f.get('key', '').encode()):
        return JsonResponse({'ok': False, 'error': 'bad key'}, status=403)
    if (dup := Invoice.objects.filter(paid_uid=f['uid']).first()):
        return JsonResponse({'ok': True, 'duplicate': True, 'invoice': str(dup.pk)})
    qs = Invoice.objects
    inv = (qs.filter(pk=invoice).first() if invoice else
           qs.filter(amount=int(f['amount']), status='unpaid', created_at__gt=timezone.now() - timezone.timedelta(days=1)).order_by('-pk').first())
    if not inv:
        return JsonResponse({'ok': False, 'error': 'invoice not found'}, status=404)
    if inv.status == 'cancelled':
        return JsonResponse({'ok': False, 'error': 'invoice cancelled'}, status=410)
    if not Invoice.objects.filter(pk=inv.pk).exclude(status='paid').update(status='paid', paid_uid=f['uid'], paid_at=timezone.now()):
        return JsonResponse({'ok': False, 'error': 'invoice already paid'}, status=409)
    return JsonResponse({'ok': True, 'invoice': str(inv.pk)})
package main

import (
	"crypto/subtle"
	"encoding/json"
	"net/http"
	"strconv"
	"strings"
)

var secret = []byte("project secret")

func reply(w http.ResponseWriter, code int, body map[string]any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(code)
	json.NewEncoder(w).Encode(body)
}

func rikhtam(w http.ResponseWriter, r *http.Request) {
	r.ParseForm()
	f := r.PostForm.Get
	invoice := strings.TrimSpace(f("invoice"))
	if subtle.ConstantTimeCompare(secret, []byte(f("key"))) != 1 {
		reply(w, 403, map[string]any{"ok": false, "error": "bad key"})
		return
	}
	if id, ok := findByPaidUID(f("uid")); ok {
		reply(w, 200, map[string]any{"ok": true, "duplicate": true, "invoice": id})
		return
	}
	var inv *Invoice
	if invoice != "" {
		inv = findInvoiceByID(invoice)
	} else {
		amount, _ := strconv.ParseInt(f("amount"), 10, 64)
		inv = findOpenInvoiceByAmount(amount)
	}
	switch {
	case inv == nil:
		reply(w, 404, map[string]any{"ok": false, "error": "invoice not found"})
	case inv.Status == "cancelled":
		reply(w, 410, map[string]any{"ok": false, "error": "invoice cancelled"})
	case !markPaidIfUnpaid(inv.ID, f("uid")): // UPDATE ... WHERE id = ? AND status <> 'paid'
		reply(w, 409, map[string]any{"ok": false, "error": "invoice already paid"})
	default:
		reply(w, 200, map[string]any{"ok": true, "invoice": inv.ID})
	}
}

func main() { http.HandleFunc("/rikhtam", rikhtam); http.ListenAndServe(":8080", nil) }
// ASP.NET Core Minimal API (Program.cs)
using System.Security.Cryptography;
using System.Text;

app.MapPost("/rikhtam", async (HttpRequest req, AppDb db) =>
{
    var f = await req.ReadFormAsync();
    string F(string k) => f[k].ToString();
    var invoice = F("invoice").Trim();
    var a = Encoding.UTF8.GetBytes(app.Configuration["Rikhtam:Secret"]!);
    var b = Encoding.UTF8.GetBytes(F("key"));
    if (a.Length != b.Length || !CryptographicOperations.FixedTimeEquals(a, b))
        return Results.Json(new { ok = false, error = "bad key" }, statusCode: 403);

    var dup = await db.Invoices.FirstOrDefaultAsync(i => i.PaidUid == F("uid"));
    if (dup != null) return Results.Json(new { ok = true, duplicate = true, invoice = dup.Id.ToString() });

    var amount = long.Parse(F("amount"));
    var inv = invoice != ""
        ? await db.Invoices.FindAsync(long.Parse(invoice))
        : await db.Invoices.Where(i => i.Amount == amount && i.Status == "unpaid" && i.CreatedAt > DateTime.UtcNow.AddDays(-1))
                           .OrderByDescending(i => i.Id).FirstOrDefaultAsync();
    if (inv == null) return Results.Json(new { ok = false, error = "invoice not found" }, statusCode: 404);
    if (inv.Status == "cancelled") return Results.Json(new { ok = false, error = "invoice cancelled" }, statusCode: 410);

    var n = await db.Invoices.Where(i => i.Id == inv.Id && i.Status != "paid")
        .ExecuteUpdateAsync(s => s.SetProperty(i => i.Status, "paid").SetProperty(i => i.PaidUid, F("uid")).SetProperty(i => i.PaidAt, DateTime.UtcNow));
    if (n == 0) return Results.Json(new { ok = false, error = "invoice already paid" }, statusCode: 409);
    return Results.Json(new { ok = true, invoice = inv.Id.ToString() });
}).DisableAntiforgery();
// Spring Boot
@RestController
public class RikhtamController {
    @Value("${rikhtam.secret}") private String secret;
    private final InvoiceRepository invoices;
    public RikhtamController(InvoiceRepository invoices) { this.invoices = invoices; }

    @PostMapping(value = "/rikhtam", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
    public ResponseEntity<Map<String, Object>> callback(@RequestParam MultiValueMap<String, String> f) throws Exception {
        Function<String, String> g = k -> Optional.ofNullable(f.getFirst(k)).orElse("");
        String invoice = g.apply("invoice").trim();
        if (!MessageDigest.isEqual(secret.getBytes(StandardCharsets.UTF_8), g.apply("key").getBytes(StandardCharsets.UTF_8)))
            return ResponseEntity.status(403).body(Map.of("ok", false, "error", "bad key"));

        var dup = invoices.findByPaidUid(g.apply("uid"));
        if (dup.isPresent()) return ResponseEntity.ok(Map.of("ok", true, "duplicate", true, "invoice", dup.get().getId().toString()));

        Optional<Invoice> inv = invoice.isEmpty()
            ? invoices.findFirstByAmountAndStatusAndCreatedAtAfterOrderByIdDesc(Long.parseLong(g.apply("amount")), "unpaid", LocalDateTime.now().minusDays(1))
            : invoices.findById(Long.parseLong(invoice));
        if (inv.isEmpty()) return ResponseEntity.status(404).body(Map.of("ok", false, "error", "invoice not found"));
        if ("cancelled".equals(inv.get().getStatus())) return ResponseEntity.status(410).body(Map.of("ok", false, "error", "invoice cancelled"));
        // @Modifying @Query("update Invoice i set i.status='paid', i.paidUid=:uid where i.id=:id and i.status <> 'paid'")
        if (invoices.markPaidIfUnpaid(inv.get().getId(), g.apply("uid")) == 0)
            return ResponseEntity.status(409).body(Map.of("ok", false, "error", "invoice already paid"));
        return ResponseEntity.ok(Map.of("ok", true, "invoice", inv.get().getId().toString()));
    }
}

🩺 تست سلامت درگاه

در پنل سایت (بخش «درگاه‌ها») و در اپلیکیشن (بخش درگاه‌ها)، کنار هر درگاه دکمه‌ی «تست سلامت درگاه» هست. سرور ریختم دو درخواست به آدرس شما می‌فرستد و نتیجه را مورد‌به‌مورد نشان می‌دهد:

  1. یک درخواست با کلید درست و فیلد test=1 (مبلغ ۱ و uid تستی که با test شروع می‌شود). هیچ واریزی ثبت نمی‌شود.
  2. یک درخواست با کلید اشتباه؛ کد درست باید آن را با 403 رد کند.
بررسییعنی
آدرس و اتصالhttps بودن، دامنه‌ی عمومی، DNS، گواهی SSL، پورت و تایم‌اوت (سرعت پاسخ)
پاسخ درگاه2xx سالم؛ ریدایرکت (3xx) و 5xx خطا حساب می‌شود
کلیداگر با کلید درست 401/403 گرفتید، کلید کد شما با کلید درگاه یکی نیست
تشخیص حالت تستپاسخ {"ok":true,"test":true} یعنی کد شما حالت تست را می‌شناسد
رد کلید اشتباهاگر کلید غلط هم پذیرفته شود، نتیجه‌ی قرمز و هشدار امنیتی می‌گیرید (هر کسی می‌تواند پرداخت جعلی بفرستد)

برای اینکه کد شما به تست درست جواب دهد، بعد از بررسی کلید و قبل از هر کار دیگر این را اضافه کنید (هیچ صورتحسابی را عوض نکنید):

if (!hash_equals(SECRET, $_POST['key'] ?? '')) { http_response_code(403); exit; }
if (($_POST['test'] ?? '') === '1') { header('Content-Type: application/json'); echo json_encode(['ok' => true, 'test' => true]); exit; }
افزونه‌ی ووکامرس این را خودکار انجام می‌دهد. اگر کد شما فیلد test را نمی‌شناسد، تست «هشدار» می‌دهد (نه خطا)، چون ممکن است برای مبلغ ۱ تومان دنبال صورتحساب بگردد. بهتر است همین چند خط را اضافه کنید. هر ۱۰ دقیقه حداکثر ۱۲ تست مجاز است.

🧪 تست اتصال بدون واریز واقعی

این دستور یک واریز ساختگی به آدرس شما می‌فرستد (کلید، آدرس و مبلغ یک صورتحساب باز را عوض کنید):

curl -s -X POST 'https://your-site.com/wp-json/rikhtam/v1/callback' \
  -d 'key=کلید_امنیتی' -d 'project_id=1' -d "uid=test$(date +%s)" -d 'amount=1250317' -d 'amount_rial=1250317'

پاسخ {"ok":true} یعنی صورتحساب تایید شد؛ 404 یعنی صورتحسابی با این مبلغ نبود؛ 403 یعنی کلید امنیتی اشتباه است.

🏦 حساب، کارت و پیامک

  • به مشتری شماره کارت نشان می‌دهید، ولی پیامک واریز بانک معمولاً شماره حساب را دارد. مشکلی نیست: صورتحساب فقط با مبلغ یکتا شناسایی می‌شود و شماره کارت در تطبیق نقشی ندارد.
  • هنگام ساخت درگاه شماره حساب نمی‌پرسیم. بعد از ساخت درگاه، در اپلیکیشن از بخش «حساب‌ها ← افزودن حساب» بانک و شماره‌ی حساب را بنویسید و همان درگاه را تیک بزنید؛ تا حسابی پیوست نشود هیچ واریزی به درگاه نمی‌رسد. فقط شماره حساب (همان که در پیامک بانک می‌آید) را بنویسید، نه شماره کارت و نه شبا. بعضی بانک‌ها (مثل ملی) فقط چند رقم آخر حساب را در پیامک می‌نویسند؛ باز هم شماره‌ی کامل را بنویسید.
  • حساب لازم نیست به نام خودتان باشد؛ فقط اپلیکیشن ریختم باید روی گوشی‌ای باشد که سیم‌کارتش پیامک واریز همان حساب را می‌گیرد.
  • چند حساب از چند بانک را می‌توانید هم‌زمان در اپلیکیشن اضافه کنید.

🧯 رفع مشکل

مشکلراه‌حل
چرا فاکتور تسویه‌ی کارمزد نیامده؟تا وقتی بدهی کارمزد کمتر از ۱۰٬۰۰۰ تومان باشد فاکتور تسویه ساخته نمی‌شود؛ با رسیدن بدهی به این مقدار، فاکتور خودکار ساخته می‌شود.
واریز انجام شد ولی هیچ واریزی در اپ ثبت نشدبعضی بانک‌ها برای مبالغ پایین پیامک نمی‌فرستند. صورتحساب‌هایتان نباید زیر حداقل مبلغی باشد که بانک برایش پیامک می‌دهد، وگرنه سیستم عمل نمی‌کند.
واریز در پنل «فاکتور پیدا نشد» استمبلغ واریزی با مبلغ یکتای صورتحساب یکی نیست، یا صورتحساب منقضی شده، یا واحد درگاه (ریال/تومان) با کد شما نمی‌خواند.
پاسخ 403کلید امنیتی در سایت با پنل یکی نیست.
در وردپرس آدرس /wp-json/ باز نمی‌شودتنظیمات ← پیوندهای یکتا را یک بار ذخیره کنید؛ افزونه‌ی امنیتی یا فایروال REST API را مسدود نکند.
واریز به سرور نمی‌رسددر اپ، بخش گزارش و «وضعیت سلامت برنامه» را ببینید: دسترسی پیامک، معافیت باتری و قالب بانک.