مستندات کارت خودکار
کارت خودکار هیچ نقش واسطهای ندارد: پول مستقیم به حساب شما واریز میشود. اپ پیامک واریز بانک را میخواند و سرور ما با یک درخواست امضاشده به سایت یا ربات شما خبر میدهد تا صورتحساب همان لحظه تایید شود.
🧩 افزونهی ووکامرس
یک درگاه پرداخت «کارت به کارت با تایید خودکار» به ووکامرس اضافه میکند. با تسویه حساب کلاسیک و بلوکی ووکامرس و ذخیرهسازی جدید سفارشها (HPOS) سازگار است.
⬇️ دانلود افزونهنصب در ۵ قدم
- پیشخوان وردپرس ← افزونهها ← افزودن ← بارگذاری افزونه ← فایل zip را انتخاب و فعال کنید.
- ووکامرس ← پیکربندی ← پرداختها ← کارت به کارت خودکار را باز کنید.
- کارتها را وارد کنید؛ هر خط:
شماره کارت | نام صاحب کارت | بانک | شبا. - آدرسی که همان صفحه نشان میدهد (
/wp-json/cardauto/v1/callback) را به مدیر کارت خودکار بدهید (یا خودتان در پنل برای درگاه ثبت کنید) و «کلید امنیتی» درگاه را در فیلد Secret افزونه بگذارید. - در اپ کارت خودکار، حساب بانکی را اضافه و به همین درگاه وصل کنید. تمام! یک سفارش تستی ارزان بزنید و واریز کنید.
⚙️ تنظیمات افزونه
| تنظیم | توضیح |
|---|---|
| عنوان و توضیحات | متنی که مشتری در صفحهی پرداخت، صفحهی تشکر و ایمیل میبیند؛ کاملاً قابل تغییر. |
| کارتها | یک یا چند کارت. حالت «یک کارت تصادفی برای هر سفارش» واریزها را بین حسابها پخش میکند. |
| واحد پول فروشگاه | خودکار از ووکامرس (تومان، ریال، هزار تومان، هزار ریال) یا دستی. |
| عدد یکتا | واحد (تومان/ریال)، اضافه یا کم شدن از مبلغ (تخفیف کوچک برای مشتری) و بازهی عدد (پیشفرض ۱ تا ۹۹۹). |
| وضعیت سفارش | قبل از واریز «در انتظار بررسی» یا «در انتظار پرداخت»؛ بعد از تایید «پیشفرض»، «در حال انجام» یا «تکمیل شده». |
| مهلت واریز | سفارش پرداختنشده بعد از N دقیقه لغو میشود (۰ = هرگز). اگر واریز دیر برسد، سفارش لغوشده دوباره فعال و پرداخت میشود (قابل خاموش کردن). |
| حداقل / حداکثر مبلغ | درگاه فقط برای سبدهای در این بازه نمایش داده میشود. |
| ایمیل، بروزرسانی خودکار صفحه، نمایش شبا، گزارش اشکالیابی | هر کدام جدا روشن/خاموش میشود. گزارشها: ووکامرس ← وضعیت ← گزارشها ← cardauto. |
برای توسعهدهندهها: اکشن cardauto_order_paid ($order, $deposit) بعد از هر تایید اجرا میشود.
🔔 بروزرسانی افزونه
افزونه هر ۶ ساعت نسخهی جدید را از سرور کارت خودکار چک میکند. وقتی نسخهی جدید منتشر شود:
- در پیشخوان وردپرس اعلانی با فهرست تغییرات و دکمهی «بروزرسانی همین حالا» نمایش داده میشود.
- در صفحهی افزونهها و پیشخوان ← بروزرسانیها هم مثل بقیهی افزونهها قابل نصب با یک کلیک است (بروزرسانی خودکار وردپرس هم پشتیبانی میشود).
- برای چک فوری: افزونهها ← زیر «کارت خودکار» ← بررسی بروزرسانی.
تنظیمات و سفارشها با بروزرسانی پاک نمیشوند.
🛠 طرز کار (اتصال اختصاصی)
- سایت شما برای هر صورتحساب یک مبلغ یکتا میسازد و همراه شماره کارت به مشتری نشان میدهد.
- مشتری کارت به کارت میکند؛ پیامک واریز به گوشیای میرسد که اپ کارت خودکار روی آن است.
- اپ مبلغ را از پیامک جدا میکند و به سرور کارت خودکار میفرستد.
- سرور واریز را با یک درخواست
POSTامضاشده به آدرس درگاه شما میفرستد. - شما امضا را بررسی، صورتحساب هممبلغ را پیدا و تایید میکنید و
{"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 | شمارهی صورتحسابی که صاحب درگاه وارد کرده (جزو امضاست) |
manual | 1 |
در این حالت صورتحساب را با شماره پیدا کنید، نه مبلغ:
- صورتحساب نیست →
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 را مسدود نکند. |
| واریز به سرور نمیرسد | در اپ، بخش گزارش و «وضعیت سلامت برنامه» را ببینید: دسترسی پیامک، معافیت باتری و قالب بانک. |