اتصال درگاه پرداخت زرینپال در Next.js: وبهوک، تأیید تراکنش و جلوگیری از پرداخت تکراری
نویسنده: آرش لطیفی
آموزش کامل اتصال درگاه پرداخت به Next.js App Router با Route Handler: ساخت سفارش، ارسال به درگاه، وبهوک، تأیید تراکنش و محافظت از پرداخت تکراری.
TL;DR — پرداخت رو با یک Route Handler بگیر، وضعیت سفارش رو از وبهوک آپدیت کن و با قفل تراکنش نذاری یک سفارش دوبار پرداخت بشه.
اتصال درگاه پرداخت ظاهرش سادهست: دکمهی «پرداخت» رو بزن، کاربر میره درگاه، برمیگرده، سفارش ثبت میشه. ولی همینجایی که بیشتر فروشگاههای Next.js به مشکل میخورن، چون بازگشت کاربر از درگاه یه درخواست GET ساده نیست و اگه درست مدیریت نشه، پرداخت تکراری و سفارش گمشده میسازی.
توی این مقاله کل مسیر رو میسازیم: ساخت سفارش، ریدایرکت به درگاه، وبهوک، تأیید تراکنش و کنترل تکراری بودن.
معماری درست: دو مسیر جدا
اول یه چیز مهم: درگاه پرداخت دو بار به سایت شما خبر میده و این دو مسیر کار متفاوتی انجام میدن:
- Redirect / Callback (GET): کاربر از درگاه برمیگشته و صفحهی «در حال بررسی» رو میبینه. این مسیر اعتمادناپذیره و فقط برای نمایش وضعیته.
- Webhook (POST): سرور درگاه به سرور شما خبر میده که پرداخت واقعاً تأیید شده. این مسیر منبع حقیقته و وضعیت سفارش رو اینجا نهایی میکنی.
اگه وضعیت پرداخت رو فقط از روی برگشت کاربر ست کنی، کاربری که صفحه رو میبنده یا اینترنتش قطع میشه، سفارشش برای همیشه «در انتظار» میمونه.
مسیر کلی اینه: کاربر POST /api/checkout میزنه، سفارش با وضعیت pending توی دیتابیس ثبت و به درگاه ریدایرکت میشه. درگاه کاربر رو به /pay/callback برمیگردونه (فقط نمایش) و همزمان POST /api/webhook/zarinpal رو میزنه که وضعیت سفارش رو نهایی میکنه.
قدم اول: ساخت سفارش با شناسهی یکتا
هر تلاش پرداخت باید یک شناسهی یکتا داشته باشه. دیتابیس اینجاست که جلوی فاجعه رو میگیره.
نکته: مبلغ رو از دیتابیس خودت بگیر، نه از بدنهی درخواست کاربر. هرگز به قیمت ارسالی از کلاینت اعتماد نکن.
قدم دوم: Route Handler برای رفتن به درگاه
نکتهی metadata.attempt_id: همین یه فیلد ساده، بعداً وبهوک رو از حدسزدن نجات میده. مبلغ و شناسه را در جایی مطمئن از خودت ذخیره کردهای.
قدم سوم: وبهوک، جایی که پرداخت واقعاً تأیید میشه
این if (order.status === "paid") سادهترین و مهمترین خط دفاعی توئه. درگاهها معمولاً وبهوک رو چند بار ارسال میکنن؛ بدون این گارد، کالا رو دوبار از انبار کم میکنی.
تأیید نهایی هم جدا و سمت سرور انجام میشه: تابع verifyGateway یه POST به https://api.zarinpal.com/pg/v4/payment/verify.json میزنه و وقتی data.code === 100 باشه، ref_id رو برمیگردونه.
توجه کن که amount رو از دیتابیس خودت میفرستی، نه از دادهی وبهوک. اگه مبلغ رو از ورودی بگیری، هرکسی میتونه با یک وبهوک جعلی درخواست تأیید بزنه.
در ضمن وضعیتهای سفارش رو از همین مقادیر کنترل کن:
| وضعیت | معنی | اقدام بعدی |
|---|---|---|
| pending | ساخته شده، پرداختی تأیید نشده | منتظر وبهوک، انقضا بعد از ۳۰ دقیقه |
| paid | پرداخت تأیید و مرجع تراکنش ثبت شد | نهایی، دیگه پردازش نشه |
| failed | کاربر لغو کرد یا تأیید ناموفق بود | آزادسازی رزرو انبار |
| gateway_error | درگاه درخواست رو قبول نکرد | اجازهی تلاش مجدد با attemptId جدید |
قدم چهارم: صفحهی برگشت کاربر
اینجا فقط نمایش بده، نه تأیید: صفحهی /pay/callback یه کامپوننت کلاینتی سادهست که با useSearchParams مقدار Status رو میخونه و پیام درست رو نشون میده.
اگه با useSearchParams کار میکنی، حتماً صفحه رو در Suspense بذاری؛ وگرنه در build گیر میکنه. این الگو رو در راهنمای حالتهای Loading و Error هم دیدی.
چکلیست امنیت و درستی
- [ ] مبلغ سفارش از دیتابیس خوانده میشه، نه از بدنهی درخواست
- [ ] تأیید نهایی (
verify) همیشه سمت سرور انجام میشه - [ ] سفارش با وضعیت
paidدوباره پردازش نمیشه - [ ] موجودی انبار داخل تراکنش دیتابیس کم میشه
- [ ] کلید API فقط در متغیرهای سمت سرور (
process.env) هست - [ ] صفحهی callback هیچ اطلاعات حساسی نشون نمیده
- [ ] یک job دورهای سفارشهای
pendingقدیمی رو پاک/لغو میکنه - [ ] لاگ
attemptId،refIdو وضعیت نهایی برای پیگیری ذخیره میشه
چند نکته که کار را قابل اتکا میکند
- کاربر برمیگردد ولی وبهوک نرسیده؟ یک endpoint برای polling بساز که هر ۳ ثانیه وضعیت سفارش را از API خودت بپرسد و بعد از ۳۰ ثانیه تسلیم شود. این کار به کاربر اطمینان میدهد پرداختش ثبت شده.
- درگاه قطع شد؟ Route Handler باید خطا برگرداند و سفارش را
gateway_errorکند؛ نه اینکه بیصدا رد کند. - کاربر دکمه را دوبار میزند؟ روی دکمه
disabledبگذار و در سمت سرور هم باattemptIdیکتا جلوی ساخت سفارش تکراری را بگیر.
بخش سبد خرید و بهینهسازی تجربهی خرید را میتوانی در الگوی سبد خرید خوشبینانه با React ببینی. اگر فرم ثبت سفارش هم داری، اعتبارسنجی با Zod را از راهنمای اعتبارسنجی فرم در Next.js بردار. و برای اینکه کاربر بعد از پرداخت ولی نرسیدن به سایت، فروش از دستت نره، راهکار ریکاوری پرداخت ناموفق را بخوان.
Q: چرا نباید به کاربر برگشتی تکیه کنم؟
چون این درخواست رو کاربر میفرسته و هرکسی میتونه با دست تغییرش بده. ضمناً اگر تب رو ببنده، اصلاً خبری نمیرسه. وبهوک تنها مسیر قابل اعتماد برای نهایی کردن وضعیته.
Q: چطور از پرداخت تکراری جلوگیری کنم؟
دو لایه دفاع داشته باش: یکبار در Route Handler با attemptId یکتا و ایندکس یونیک روی آن، و یکبار در وبهوک با گارد status === "paid". لایهی دوم حیاتیه چون درگاه ممکنه webhook رو چند بار بفرسته.
Q: اگر موجودی کم شد وسط پرداخت چی؟
مبلغ پرداختشده رو برگردون و سفارش رو canceled کن. برای این کار معمولاً به API بازگشت مبلغ درگاه نیاز داری؛ این سناریو رو توی تست حتماً شبیهسازی کن.