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

مستندات کارت خودکار

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

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

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

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

نصب در ۵ قدم

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

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

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

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

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

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

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

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

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

  1. سایت شما برای هر صورتحساب یک مبلغ یکتا می‌سازد و همراه شماره کارت به مشتری نشان می‌دهد.
  2. مشتری کارت به کارت می‌کند؛ پیامک واریز به گوشی‌ای می‌رسد که اپ کارت خودکار روی آن است.
  3. اپ مبلغ را از پیامک جدا می‌کند و به سرور کارت خودکار می‌فرستد.
  4. سرور واریز را با یک درخواست POST امضاشده به آدرس درگاه شما می‌فرستد.
  5. شما امضا را بررسی، صورتحساب هم‌مبلغ را پیدا و تایید می‌کنید و {"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: CardAuto/1.0.

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

🔐 بررسی امضا

امضا را حتماً بررسی کنید؛ وگرنه هر کسی می‌تواند واریز جعلی بفرستد. درخواست‌هایی که ts آن‌ها بیش از ۱۰ دقیقه با الان فاصله دارد را هم رد کنید. خروجی HMAC به‌صورت hex (۶۴ کاراکتر) است.

// واریز عادی
signature = HMAC_SHA256( "project_id|uid|amount|account|sms_time|ts" , SECRET )

// پیوست دستی (وقتی فیلد invoice آمده)
signature = HMAC_SHA256( "project_id|uid|amount|account|sms_time|ts|invoice" , SECRET )

📤 پاسخ شما

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

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

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

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

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

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

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

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

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

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

💻 کد آماده

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

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

$invoice = trim($f('invoice'));
$parts = [$f('project_id'), $f('uid'), $f('amount'), $f('account'), $f('sms_time'), $f('ts')];
if ($invoice !== '') $parts[] = $invoice;
$sig = hash_hmac('sha256', implode('|', $parts), SECRET);
if (!hash_equals($sig, $f('signature')) || abs(time() - (int)$f('ts')) > 600) {
    http_response_code(403); exit(json_encode(['ok' => false, 'error' => 'bad signature']));
}

$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('/cardauto', [CardAutoController::class, 'callback']);   // مسیر api است؛ CSRF ندارد

// app/Http/Controllers/CardAutoController.php
class CardAutoController extends Controller
{
    public function callback(Request $r)
    {
        $invoice = trim((string) $r->input('invoice', ''));
        $parts = collect(['project_id', 'uid', 'amount', 'account', 'sms_time', 'ts'])->map(fn ($k) => (string) $r->input($k, ''));
        if ($invoice !== '') $parts->push($invoice);
        $sig = hash_hmac('sha256', $parts->implode('|'), config('services.cardauto.secret'));
        if (!hash_equals($sig, (string) $r->input('signature')) || abs(time() - (int) $r->input('ts')) > 600) {
            return response()->json(['ok' => false, 'error' => 'bad signature'], 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.CARDAUTO_SECRET;

app.post('/cardauto', express.urlencoded({ extended: false }), async (req, res) => {
  const b = req.body, invoice = (b.invoice || '').trim();
  const parts = ['project_id', 'uid', 'amount', 'account', 'sms_time', 'ts'].map(k => b[k] ?? '');
  if (invoice) parts.push(invoice);
  const sig = crypto.createHmac('sha256', SECRET).update(parts.join('|')).digest('hex');
  const given = String(b.signature || '');
  const ok = given.length === sig.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(given));
  if (!ok || Math.abs(Date.now() / 1000 - Number(b.ts)) > 600) return res.status(403).json({ ok: false, error: 'bad signature' });

  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, hashlib, time
from flask import Flask, request
app = Flask(__name__)
SECRET = b'project secret'

@app.post('/cardauto')
def cardauto():
    f = request.form
    invoice = f.get('invoice', '').strip()
    parts = [f.get(k, '') for k in ('project_id', 'uid', 'amount', 'account', 'sms_time', 'ts')]
    if invoice:
        parts.append(invoice)
    sig = hmac.new(SECRET, '|'.join(parts).encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, f.get('signature', '')) or abs(time.time() - int(f.get('ts') or 0)) > 600:
        return {'ok': False, 'error': 'bad signature'}, 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('cardauto/', views.cardauto) در urls.py
import hmac, hashlib, time
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 cardauto(request):
    f = request.POST
    invoice = f.get('invoice', '').strip()
    parts = [f.get(k, '') for k in ('project_id', 'uid', 'amount', 'account', 'sms_time', 'ts')] + ([invoice] if invoice else [])
    sig = hmac.new(settings.CARDAUTO_SECRET.encode(), '|'.join(parts).encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, f.get('signature', '')) or abs(time.time() - int(f.get('ts') or 0)) > 600:
        return JsonResponse({'ok': False, 'error': 'bad signature'}, 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/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"math"
	"net/http"
	"strconv"
	"strings"
	"time"
)

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 cardauto(w http.ResponseWriter, r *http.Request) {
	r.ParseForm()
	f := r.PostForm.Get
	invoice := strings.TrimSpace(f("invoice"))
	parts := []string{f("project_id"), f("uid"), f("amount"), f("account"), f("sms_time"), f("ts")}
	if invoice != "" {
		parts = append(parts, invoice)
	}
	m := hmac.New(sha256.New, secret)
	m.Write([]byte(strings.Join(parts, "|")))
	ts, _ := strconv.ParseInt(f("ts"), 10, 64)
	if !hmac.Equal([]byte(hex.EncodeToString(m.Sum(nil))), []byte(f("signature"))) || math.Abs(float64(time.Now().Unix()-ts)) > 600 {
		reply(w, 403, map[string]any{"ok": false, "error": "bad signature"})
		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("/cardauto", cardauto); http.ListenAndServe(":8080", nil) }
// ASP.NET Core Minimal API (Program.cs)
using System.Security.Cryptography;
using System.Text;

app.MapPost("/cardauto", async (HttpRequest req, AppDb db) =>
{
    var f = await req.ReadFormAsync();
    string F(string k) => f[k].ToString();
    var invoice = F("invoice").Trim();
    var parts = new List<string> { F("project_id"), F("uid"), F("amount"), F("account"), F("sms_time"), F("ts") };
    if (invoice != "") parts.Add(invoice);
    var sig = Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(app.Configuration["CardAuto:Secret"]!),
                                                     Encoding.UTF8.GetBytes(string.Join('|', parts)))).ToLowerInvariant();
    long.TryParse(F("ts"), out var ts);
    if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(sig), Encoding.UTF8.GetBytes(F("signature")))
        || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 600)
        return Results.Json(new { ok = false, error = "bad signature" }, 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 CardAutoController {
    @Value("${cardauto.secret}") private String secret;
    private final InvoiceRepository invoices;
    public CardAutoController(InvoiceRepository invoices) { this.invoices = invoices; }

    @PostMapping(value = "/cardauto", 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();
        List<String> parts = new ArrayList<>(List.of(g.apply("project_id"), g.apply("uid"), g.apply("amount"), g.apply("account"), g.apply("sms_time"), g.apply("ts")));
        if (!invoice.isEmpty()) parts.add(invoice);
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        String sig = HexFormat.of().formatHex(mac.doFinal(String.join("|", parts).getBytes(StandardCharsets.UTF_8)));
        long ts = g.apply("ts").isEmpty() ? 0 : Long.parseLong(g.apply("ts"));
        if (!MessageDigest.isEqual(sig.getBytes(), g.apply("signature").getBytes()) || Math.abs(Instant.now().getEpochSecond() - ts) > 600)
            return ResponseEntity.status(403).body(Map.of("ok", false, "error", "bad signature"));

        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()));
    }
}

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

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

SECRET='کلید امنیتی'; URL='https://your-site.com/wp-json/cardauto/v1/callback'
P=1; UID=test$(date +%s); AMT=1250317; ACC=''; ST=$(date +%s); TS=$(date +%s)
SIG=$(printf '%s' "$P|$UID|$AMT|$ACC|$ST|$TS" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -s -X POST "$URL" -d "project_id=$P&uid=$UID&amount=$AMT&amount_rial=$AMT&account=$ACC&sms_time=$ST&ts=$TS&signature=$SIG"

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

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

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

🧯 رفع مشکل

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