پرداخت و رمزارز

اتصال Django به درگاه‌های پرداخت ایرانی با django-iranian-payment

راهنمای عملی اتصال پروژه‌ی Django به زرین‌پال، زیبال، ملت و سامان با پکیج متن‌باز django-iranian-payment: دو لایه‌ی پکیج، نصب و تنظیمات، ریال و تومان، جریان پرداخت، دو مرحله‌ی ملت و sandbox.

django-iranian-payment: Django and Python

تقریباً هر پروژه‌ی Django در ایران دیر یا زود به درگاه پرداخت می‌رسد، و هر درگاه ساز خودش را می‌زند: زرین‌پال و زیبال REST/JSON هستند، ملت SOAP است و کاربر را فقط با فرم POST می‌پذیرد، و ملت بعد از تأیید یک مرحله‌ی settle هم دارد. django-iranian-payment پکیج متن‌بازی است که برای همین کار نوشته‌ام. این نوشته طرز استفاده از آن را مرور می‌کند و همه‌ی جزئیاتش از README خود پکیج آمده است.

دو لایه: اپ آماده یا هسته‌ی بدون state

اولین تصمیم، انتخاب لایه است:

  • لایه‌ی ساده (اپ Django): اپ اختیاری django_iranian_payment.contrib.django خودش رکورد پرداخت را می‌سازد، authority را ذخیره می‌کند، تأیید را انجام می‌دهد و وضعیت تراکنش را نگه می‌دارد. کد شما تقریباً به شروع پرداخت محدود می‌شود.
  • لایه‌ی هسته: درگاه‌هایی بدون state که در هر محیطی کار می‌کنند، حتی بیرون از Django یا در کد async. ذخیره‌ی authority و مدیریت تأیید با خود شماست.

پیشنهاد من لایه‌ی ساده است، چون چند دردسر محیط عملیاتی را خودش حل می‌کند. view بازگشت از بانک csrf_exempt است، چون ملت و سامان با POST و بدون توکن CSRF برمی‌گردند. رسید هر پرداخت پیش از verify در فیلد یکتای receipt_key ثبت می‌شود تا رسید یک خرید روی سفارش دیگری پذیرفته نشود؛ سامان و ملت هر دو verify تکراری را موفق اعلام می‌کنند. اگر هم درگاه هنگام verify در دسترس نباشد، رکورد گم نمی‌شود. لایه‌ی هسته برای وقتی است که مدل و migration تازه نمی‌خواهید یا بیرون از Django کار می‌کنید؛ آن‌وقت این مراقبت‌ها با خود شماست و README نمونه‌ی کاملش را در scripts/django_custom_db.py گذاشته است.

کدام درگاه‌ها آماده‌اند

قاعده‌ی پروژه ساده است: هیچ درگاهی پیش از یک تراکنش واقعیِ موفق وارد registry عمومی نمی‌شود. این چهار درگاه با get_gateway("slug") در دسترس‌اند:

درگاهپروتکلهدایت کاربرتست‌شده باsandbox
زرین‌پالREST/JSONredirectsandbox و تراکنش واقعیدارد (URL جدا)
زیبالREST/JSON—sandbox و تراکنش واقعیبا merchant="zibal"
ملتSOAPفرم POSTتراکنش live روی محیط عملیاتیندارد؛ sandbox=True خطا می‌دهد
سامان (SEP)REST/JSONفرم POSTتراکنش واقعی با ترمینال واقعیندارد؛ sandbox=True خطا می‌دهد

ایران‌کیش، نکست‌پی، سداد و دیجی‌پی در core.experimental هستند: از روی مستند رسمی کامل پیاده‌سازی و با تست خودکار (InMemoryTransport) پوشش داده شده‌اند، اما هنوز روی sandbox یا ترمینال واقعی تست نشده‌اند. برای همین با get_gateway در دسترس نیستند و فقط با import صریح کار می‌کنند. ملت و سامان هم پیش‌تر همین‌جا بودند. Pay.ir و IDPay هم از دسترس خارج شده‌اند.

نصب

pip install django-iranian-payment
# ملت (SOAP، با zeep):
pip install "django-iranian-payment[soap]"
# ایران‌کیش (تجربی، AES+RSA):
pip install "django-iranian-payment[irankish]"
# سداد (تجربی، 3DES):
pip install "django-iranian-payment[sadad]"

ملت به extra [soap] نیاز دارد که zeep را نصب می‌کند. برای لایه‌ی ساده، اپ و مسیرهایش را اضافه کنید و migrate بزنید:

INSTALLED_APPS = [
    # ...
    "django_iranian_payment.contrib.django",
]

# urls.py
from django.urls import path, include

urlpatterns = [
    # ...
    path("payment/", include("django_iranian_payment.contrib.django.urls")),
]
python manage.py migrate

تنظیمات

همه‌ی تنظیمات در دیکشنری IRANIAN_PAYMENT در settings.py است:

IRANIAN_PAYMENT = {
    "sandbox": False,    # پیش‌فرض سراسری
    "currency": "rial",  # یا "toman"
    "return_url": "/payment/result/",  # اختیاری
    "gateways": {
        "zarinpal": {"merchant_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
                     "sandbox": True},
        "zibal": {"merchant": "zibal"},
        "mellat": {
            "terminal_id": "1234567",
            "username": "your-username",
            "password": "your-password",
            "settle_mode": "verify_settle",  # یا "verify_only"
        },
    },
}

ریال یا تومان

بانک همیشه ریال می‌گیرد و amount_to_send، مبلغی که به درگاه و verify می‌رود، همیشه ریال است. IRANIAN_PAYMENT["currency"] فقط واحد ورودی شما را تعیین می‌کند: پیش‌فرض "rial" است و تبدیلی انجام نمی‌شود؛ با "toman" پکیج یک‌بار، همان ابتدای کار، مبلغ را به ریال می‌برد (هر تومان 10 ریال). این تنظیم هم در services.start_payment اعمال می‌شود و هم در get_gateway(...).initiate(...)، و اگر currency را روی خود PaymentRequest بدهید، بر تنظیم سراسری مقدم است. مبالغ ذخیره‌شده در مدل Payment و خروجی verify همیشه ریال‌اند؛ برای نمایش تومانی، خودتان بر 10 تقسیم کنید.

جریان پرداخت: ساخت، هدایت، callback، تأیید

ساخت پرداخت و هدایت کاربر

from django.shortcuts import redirect
from django_iranian_payment.contrib.django import services

def start_payment(request, order):
    payment, redirect_url = services.start_payment(
        "zarinpal",
        amount=order.amount,
        # به بانک می‌رود: مسیر callback خود پکیج
        callback_url="https://yoursite.com/payment/callback/zarinpal/",
        # کاربر پس از verify به اینجا می‌رود
        return_url="https://yoursite.com/orders/result/",
        order_id=str(order.id),
        description="پرداخت سفارش",
    )
    return redirect(redirect_url)

callback_url به بانک می‌رود و باید مسیر callback خود پکیج باشد؛ return_url صفحه‌ی نتیجه‌ی سایت شماست و می‌شود آن را یک‌بار در IRANIAN_PAYMENT["return_url"] گذاشت.

ملت و سامان redirect ساده نمی‌پذیرند و فرم POST می‌خواهند. برای این دو، کاربر را به‌جای redirect_url به reverse("iranian_payment:go-to-gateway", kwargs={"payment_id": payment.id}) بفرستید تا view go_to_gateway فرم auto-submit را بسازد.

callback و verify

با مسیرهای پکیج، برای callback هیچ view‌ای لازم نیست: بانک به /payment/callback/zarinpal/ برمی‌گردد، پکیج تأیید می‌کند و کاربر را با پارامتر payment_status (success، failed یا pending) به return_url می‌فرستد. اگر منطق خودتان را می‌خواهید، services.verify_payment(slug, authority) را مستقیم صدا بزنید. رکورد پرداخت این وضعیت‌ها را طی می‌کند:

WAITING → REDIRECT_TO_BANK → RETURN_FROM_BANK → COMPLETE / FAILED / CANCEL_BY_USER

اگر درگاه هنگام verify جواب ندهد، رکورد در RETURN_FROM_BANK می‌ماند و کاربر با payment_status=pending برمی‌گردد؛ ممکن است پول از حساب خریدار کم شده ولی هنوز تأیید نشده باشد. این دو فراخوانی را در cron یا Celery بگذارید:

from django_iranian_payment.contrib.django import services

services.reverify_pending()
services.expire_stale(older_than_minutes=30)

همین جریان با لایه‌ی هسته

در لایه‌ی هسته، authority و مبلغ ارسالی را خودتان ذخیره می‌کنید:

from django_iranian_payment import get_gateway, PaymentRequest

def start_payment(request, order):
    gw = get_gateway("zarinpal")
    result = gw.initiate(PaymentRequest(
        amount=order.amount,
        callback_url="https://yoursite.com/verify/",
        order_id=str(order.id),
    ))
    order.authority = result.authority
    order.amount_sent = result.amount_to_send  # برای verify ذخیره کن
    order.save()
    return redirect(result.redirect_url)

def verify_payment(request):
    authority = request.GET.get("Authority")
    order = Order.objects.get(authority=authority)
    gw = get_gateway("zarinpal")
    result = gw.verify(
        authority=authority,
        amount=order.amount_sent,
        order_id=str(order.id),
    )
    if result.is_success:
        order.mark_paid(result.reference_id)
        return redirect("/success/")
    return redirect("/failure/")

amount_to_send را ذخیره کنید، نه amount: زرین‌پال مبلغ را در callback برنمی‌گرداند و verify همان مبلغ ارسالی را می‌خواهد، که اگر کارمزد (FeeConfig) بر عهده‌ی مشتری باشد با مبلغ پایه فرق دارد. درگاه‌های شاپرکی مثل ملت و سامان داده‌ی بیشتری از callbackِ POST بانک را هم از راه پارامتر extra می‌خواهند؛ لایه‌ی Django این مقادیر را خودش استخراج می‌کند.

ملت: دو مرحله و settle_mode

ملت دومرحله‌ای است: بعد از verify، پول باید settle هم بشود. حالت پیش‌فرض، settle_mode="verify_settle"، هر دو را اتمیک انجام می‌دهد و توصیه‌ی من هم همین است. برای واریز با تأخیر، "settle_mode": "verify_only" را در config ملت بگذارید و بعد از verify موفق خودتان settle() را صدا بزنید؛ اگر settle هیچ‌وقت نرسد، بانک ظرف سه ساعت تراکنش را خودکار برمی‌گرداند (Autoreversal) و پول به خریدار برمی‌گردد. پکیج sale_order_id و sale_reference_id را در فیلد raw مدل Payment نگه می‌دارد تا برای settle یا reverse بعدی آماده باشند. در لایه‌ی هسته، مقادیر callback ملت را با extra بدهید:

gw.verify(authority=ref_id, amount=amount, order_id=oid,
          extra={"sale_reference_id": "...", "sale_order_id": "...",
                 "card_number": "...", "res_code": "0"})

اگر کاربر پرداخت را لغو کند، ملت ResCode=17 برمی‌گرداند؛ آن را با کلید res_code بدهید تا verify بدون تماس SOAP نتیجه‌ی لغوشده برگرداند.

sandbox

sandbox برای هر درگاه جداگانه تعیین می‌شود، پس می‌شود هم‌زمان یک درگاه را روی sandbox و دیگری را live داشت. ترتیب اولویت:

get_gateway(..., sandbox=...)  >  config درگاه  >  "sandbox" سراسری  >  False
  • زرین‌پال URL سندباکس جدا دارد و از این فلگ پیروی می‌کند.
  • زیبال با merchant="zibal" وارد sandbox می‌شود و فلگ sandbox برایش بی‌اثر است.
  • ملت و سامان sandbox واقعی ندارند و sandbox=True را با GatewayConfigurationError رد می‌کنند. اگر sandbox سراسری را True گذاشته‌اید، برای این دو صریحاً "sandbox": False بگذارید.

با اپ django_iranian_payment.contrib.django، یک Django system check با خطای iranian_payment.E001 جلوی اجرای manage.py را می‌گیرد تا بی‌خبر به live وصل نشوید. بدون اپ کامل، django_iranian_payment.contrib.guard را اضافه کنید که بدون مدل و migration فقط همین چک را انجام می‌دهد.

چند نکته‌ی آخر

  • ارتقا به نسخه‌ی 1.3.0 به python manage.py migrate نیاز دارد (migration‌های 0002 و 0003).
  • برای هر درگاه یک راهنمای گام‌به‌گام در پوشه‌ی docs/gateways/ مخزن هست.

پکیج روی PyPI و کدش روی GitHub است. اگر یکی از درگاه‌های تجربی را با ترمینال واقعی امتحان کردید، خبرم کنید؛ همین تراکنش واقعی است که درگاه را به registry عمومی می‌برد.