پرداخت آنلاین یکی از بخشهای مهم بسیاری از پروژههای تحت وب است. فروشگاه اینترنتی، سامانه اشتراکی، مارکتپلیس و نرمافزار SaaS برای دریافت هزینه محصولات و خدمات به یک سیستم پرداخت مطمئن نیاز دارند.
در Laravel میتوان فرایند پرداخت را بهصورت ساختاریافته پیادهسازی کرد؛ بهطوریکه سفارش، تراکنش مالی، پاسخ درگاه و وضعیت نهایی پرداخت از یکدیگر تفکیک شوند.
در این مقاله یاد میگیریم چگونه معماری اولیه یک سیستم پرداخت را طراحی کنیم، اطلاعات سفارش را ذخیره کنیم، تراکنش ایجاد کنیم، وضعیت پرداخت را مدیریت کنیم و از خطاهایی مانند تأیید جعلی یا ثبت چندباره پرداخت جلوگیری کنیم.
۱. سیستم پرداخت در Laravel چگونه کار میکند؟
یکی از اشتباهات رایج در پیادهسازی پرداخت این است که برنامه صرفاً کاربر را به درگاه منتقل میکند و بعد از بازگشت او، سفارش را پرداختشده در نظر میگیرد.
در یک سیستم استاندارد، بازگشت کاربر به سایت بهتنهایی به معنی موفقیت پرداخت نیست. وضعیت پرداخت باید بر اساس تأیید معتبر درگاه و اطلاعات ثبتشده در دیتابیس مشخص شود.
فرایند کلی پرداخت به این شکل است:
- کاربر محصول یا خدمت را انتخاب میکند.
- سرور مبلغ و اطلاعات سفارش را اعتبارسنجی میکند.
- سفارش با وضعیت مناسب در دیتابیس ثبت میشود.
- یک تراکنش پرداخت با وضعیت اولیه ایجاد میشود.
- درخواست پرداخت به درگاه ارسال میشود.
- کاربر به صفحه پرداخت هدایت میشود.
- درگاه کاربر را به آدرس بازگشت سایت هدایت میکند.
- سرور اطلاعات پرداخت را از طریق روش رسمی درگاه تأیید میکند.
- وضعیت تراکنش و سفارش بهصورت کنترلشده بهروزرسانی میشود.
- محصول، اعتبار کیف پول یا اشتراک فقط پس از تأیید پرداخت به کاربر تحویل داده میشود.
این تفکیک باعث میشود پرداخت و تحویل محصول، دو مرحله مستقل اما مرتبط باشند.
۲. تفاوت سفارش و تراکنش پرداخت
سفارش و تراکنش پرداخت نباید یک مفهوم واحد باشند.
| مفهوم | کاربرد |
|---|---|
سفارش (Order) | مشخص میکند کاربر قصد خرید چه محصول یا خدمتی را دارد |
تراکنش پرداخت (Payment) | اطلاعات تلاش برای پرداخت سفارش را نگهداری میکند |
| وضعیت سفارش | نشان میدهد سفارش در چه مرحلهای از فرایند خرید قرار دارد |
| وضعیت پرداخت | مشخص میکند تراکنش موفق، ناموفق یا در انتظار بررسی است |
برای مثال، ممکن است یک سفارش به دلیل انقضای درگاه یا خطای ارتباطی چند بار برای پرداخت تلاش شود. هر تلاش میتواند تراکنش جداگانهای داشته باشد، اما همه آنها به همان سفارش مربوط هستند.
همچنین یک سفارش پرداختشده ممکن است بعداً لغو یا بازپرداخت شود. بنابراین وضعیت سفارش و وضعیت پرداخت باید متناسب با نیاز پروژه، مستقل و هماهنگ مدیریت شوند.
۳. طراحی دیتابیس سیستم پرداخت
برای نمونه، دو جدول orders و payments در نظر میگیریم. این طراحی پایه است و در فروشگاه واقعی میتوان جدول اقلام سفارش، تخفیفها، مالیات و بازپرداختها را نیز اضافه کرد.
ساخت مدلها و مایگریشنها
در ترمینال اجرا کنید:
php artisan make:model Order -m
php artisan make:model Payment -m
مایگریشن جدول سفارشها
در فایل مایگریشن orders ساختار زیر را قرار دهید:
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')
->constrained()
->restrictOnDelete();
$table->string('order_number')->unique();
$table->unsignedBigInteger('total_amount');
$table->string('currency', 10)->default('IRR');
$table->string('status', 30)->default('pending');
$table->timestamp('paid_at')->nullable();
$table->timestamps();
});
در این جدول:
order_numberشناسه قابلنمایش سفارش است.total_amountمبلغ سفارش را در واحد مشخصشده نگهداری میکند.currencyواحد پول را مشخص میکند.statusوضعیت سفارش را نگهداری میکند.paid_atزمان تأیید پرداخت را ثبت میکند.
مبلغ باید در سمت سرور و بر اساس اطلاعات معتبر محصول یا خدمت محاسبه شود. نباید مبلغ ارسالی مرورگر را بدون بررسی بهعنوان مبلغ نهایی سفارش پذیرفت.
مایگریشن جدول پرداختها
در فایل مایگریشن payments کد زیر را قرار دهید:
Schema::create('payments', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')
->constrained()
->restrictOnDelete();
$table->string('gateway', 50);
$table->string('authority')->nullable()->unique();
$table->string('transaction_id')->nullable()->unique();
$table->unsignedBigInteger('amount');
$table->string('currency', 10)->default('IRR');
$table->string('status', 30)->default('pending');
$table->string('idempotency_key')->unique();
$table->json('gateway_response')->nullable();
$table->timestamp('verified_at')->nullable();
$table->timestamps();
$table->index(['order_id', 'status']);
});
در این ساختار:
gatewayنام درگاه پرداخت را مشخص میکند.authorityشناسه درخواست پرداخت است؛ در صورت پشتیبانی درگاه استفاده میشود.transaction_idشناسه تراکنش نهایی درگاه است.amountمبلغ همان تلاش پرداخت است.statusوضعیت تراکنش را نگهداری میکند.idempotency_keyبرای شناسایی عملیات تکراری به کار میرود.gateway_responseمیتواند اطلاعات ضروری پاسخ درگاه را برای پیگیری نگهداری کند.verified_atزمان تأیید موفق تراکنش را ثبت میکند.
نام و ساختار شناسههای درگاهها یکسان نیست. برای نمونه، یک درگاه ممکن است authority و درگاه دیگر token یا شناسه متفاوتی داشته باشد؛ بنابراین مدل داخلی باید با پروتکل درگاه انتخابشده هماهنگ شود.
نکته: پاسخ خام درگاه را بدون فیلتر ذخیره نکنید. اطلاعات حساس، دادههای غیرضروری و مقادیر محرمانه نباید در گزارشها یا دیتابیس بهصورت ناامن نگهداری شوند.
پس از تکمیل مایگریشنها، اجرا کنید:
php artisan migrate
۴. تعریف مدلهای Order و Payment
در مدل Order روابط اصلی را تعریف میکنیم:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Order extends Model
{
protected $fillable = [
'user_id',
'order_number',
'total_amount',
'currency',
'status',
'paid_at',
];
protected function casts(): array
{
return [
'total_amount' => 'integer',
'paid_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function payments(): HasMany
{
return $this->hasMany(Payment::class);
}
}
مدل Payment نیز میتواند به این شکل باشد:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Payment extends Model
{
protected $fillable = [
'order_id',
'gateway',
'authority',
'transaction_id',
'amount',
'currency',
'status',
'idempotency_key',
'gateway_response',
'verified_at',
];
protected function casts(): array
{
return [
'amount' => 'integer',
'gateway_response' => 'array',
'verified_at' => 'datetime',
];
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
}
این روابط امکان دسترسی به پرداختهای یک سفارش و سفارش مرتبط با هر پرداخت را فراهم میکنند.
۵. ثبت سفارش و ایجاد درخواست پرداخت
هنگام شروع پرداخت، بهتر است ابتدا سفارش و تلاش پرداخت در دیتابیس ثبت شوند. سپس درخواست به درگاه ارسال شود.
یک نمونه ساده از ایجاد رکورد پرداخت:
use App\Models\Payment;
use Illuminate\Support\Str;
$payment = Payment::create([
'order_id' => $order->id,
'gateway' => 'example_gateway',
'amount' => $order->total_amount,
'currency' => $order->currency,
'status' => 'pending',
'idempotency_key' => (string) Str::uuid(),
]);
این مثال صرفاً ساخت رکورد داخلی پرداخت را نشان میدهد. در سیستم واقعی، باید مشخص شود آیا سفارش قبلاً پرداخت شده است یا خیر، آیا پرداخت دیگری هنوز در جریان است و آیا کاربر مالک سفارش است.
همچنین برای هر تلاش پرداخت باید شناسهای مشخص داشته باشید. در صورت شکست تلاش قبلی و شروع مجدد، بسته به سیاست سیستم میتوان یک رکورد پرداخت جدید ساخت، بهجای آنکه سابقه تلاش قبلی بازنویسی شود.
۶. اتصال درگاه پرداخت به Laravel
درگاههای پرداخت مختلف API و قرارداد متفاوتی دارند. معمولاً دو مرحله اصلی وجود دارد:
- ایجاد پرداخت: سرور مبلغ، شناسه سفارش و آدرس بازگشت را به درگاه ارسال میکند و اطلاعات لازم برای انتقال کاربر را دریافت میکند.
- تأیید پرداخت: پس از بازگشت کاربر، سرور شناسه دریافتی را با API رسمی درگاه بررسی میکند و نتیجه نهایی را به دست میآورد.
برای نمونه، درگاه ممکن است در مرحله اول یک شناسه درخواست برگرداند. این شناسه را در رکورد پرداخت ذخیره میکنیم و سپس کاربر را به آدرس پرداخت ارائهشده توسط درگاه هدایت میکنیم.
مقادیر محرمانه مانند کلید API باید در فایل .env قرار گیرند:
PAYMENT_GATEWAY_MERCHANT_ID=your-merchant-id
PAYMENT_GATEWAY_BASE_URL=https://gateway.example
آدرس نمونه بالا واقعی نیست و باید با آدرس رسمی درگاه انتخابشده جایگزین شود.
در فایل config/services.php میتوان تنظیمات را اضافه کرد:
'payment_gateway' => [
'merchant_id' => env('PAYMENT_GATEWAY_MERCHANT_ID'),
'base_url' => env('PAYMENT_GATEWAY_BASE_URL'),
],
پس از تغییر تنظیمات در محیط عملیاتی، در صورت استفاده از کش تنظیمات، آن را بهروزرسانی کنید:
php artisan config:cache
برای اتصال واقعی، باید مستندات رسمی همان درگاه را بررسی کنید؛ زیرا نام پارامترها، امضای درخواست، واحد مبلغ، روش تأیید و ساختار پاسخ بین ارائهدهندگان متفاوت است.
۷. تأیید پرداخت در سمت سرور
مهمترین مرحله سیستم پرداخت، تأیید نتیجه تراکنش است.
وقتی کاربر به آدرس بازگشت سایت منتقل میشود، نباید صرفاً بر اساس پارامترهای URL مانند status=success سفارش را پرداختشده اعلام کنیم. این پارامترها ممکن است دستکاری شوند یا اصلاً بهتنهایی اعتبار کافی نداشته باشند.
فرایند تأیید باید شامل این بررسیها باشد:
- پرداخت داخلی با شناسه معتبر پیدا شود.
- پرداخت متعلق به سفارش موردنظر باشد.
- مبلغ و واحد پول با اطلاعات مورد انتظار تطبیق داشته باشند.
- درخواست تأیید با اطلاعات موردنیاز به API رسمی درگاه ارسال شود.
- پاسخ تأیید از نظر ساختار، وضعیت و شناسه تراکنش بررسی شود.
- نتیجه بهصورت اتمیک در دیتابیس ثبت شود.
- سفارش فقط یک بار به وضعیت پرداختشده منتقل شود.
چرا مبلغ باید دوباره بررسی شود؟
مبلغ ارسالی به درگاه باید با مبلغ ثبتشده در رکورد پرداخت و سفارش تطبیق داده شود. اگر درگاه مبلغ را در واحد دیگری گزارش میکند، تبدیل واحد باید بهصورت مشخص و بدون ابهام انجام شود.
بهعنوان مثال، تفاوت ریال و تومان میتواند باعث ثبت مبلغ نادرست شود. بهتر است در کل سیستم، واحد هر فیلد روشن باشد و هنگام ارتباط با درگاه، تبدیل مبلغ در یک لایه مشخص انجام شود.
۸. ثبت اتمیک نتیجه پرداخت
پس از تأیید معتبر پرداخت از درگاه، باید وضعیت تراکنش و سفارش را بهصورت هماهنگ بهروزرسانی کنیم.
یک نمونه ساده از بخش ثبت نتیجه:
use App\Models\Payment;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
DB::transaction(function () use ($paymentId, $verifiedResult) {
$payment = Payment::query()
->whereKey($paymentId)
->lockForUpdate()
->firstOrFail();
$order = $payment->order()
->lockForUpdate()
->firstOrFail();
if ($payment->status === 'paid') {
return;
}
if (! $verifiedResult->successful) {
$payment->status = 'failed';
$payment->save();
return;
}
if (
$verifiedResult->amount !== $payment->amount ||
$verifiedResult->currency !== $payment->currency
) {
Log::warning('Payment amount or currency mismatch', [
'payment_id' => $payment->id,
'order_id' => $order->id,
]);
throw new \RuntimeException(
'اطلاعات پرداخت با رکورد داخلی مطابقت ندارد.'
);
}
if ($order->status === 'paid') {
throw new \RuntimeException(
'سفارش قبلاً پرداخت شده است.'
);
}
$payment->status = 'paid';
$payment->transaction_id = $verifiedResult->transactionId;
$payment->verified_at = now();
$payment->save();
$order->status = 'paid';
$order->paid_at = now();
$order->save();
});
این کد یک الگوی آموزشی است؛ کلاس $verifiedResult در این مثال، شیئی فرضی است که باید توسط لایه اتصال به درگاه و پس از اعتبارسنجی پاسخ واقعی ساخته شود.
همچنین باید مشخص شود وضعیتهای مجاز سفارش و پرداخت چه هستند و در چه شرایطی اجازه تغییر دارند. برای نمونه، پرداخت ناموفق نباید یک پرداخت موفق قبلی را به وضعیت ناموفق برگرداند.
در سامانه عملیاتی، بهتر است شناسه تراکنش درگاه نیز در صورت وجود، با قید یکتای دیتابیس محافظت شود. اگر درگاه نتیجه تأیید را دیر برگرداند یا callback چند بار ارسال شود، سیستم باید بتواند بدون ثبت دوباره پرداخت، نتیجه قبلی را تشخیص دهد.
۹. جلوگیری از پرداخت و تحویل تکراری
گاهی کاربر صفحه بازگشت را چند بار باز میکند یا درگاه callback را مجدداً ارسال میکند. در این شرایط نباید محصول دوباره تحویل داده شود یا اعتبار کیف پول چند بار افزایش پیدا کند.
برای جلوگیری از این مشکل:
- هر تلاش پرداخت شناسه داخلی یکتا داشته باشد.
- شناسه تراکنش نهایی درگاه در صورت پشتیبانی یکتا ذخیره شود.
- تغییر وضعیت پرداخت در یک تراکنش دیتابیس انجام شود.
- فقط پرداختی که از وضعیت مجاز به
paidمنتقل میشود، اجازه ایجاد رویداد تحویل را داشته باشد. - عملیات تحویل محصول یا شارژ کیف پول نیز شناسه یکتا داشته باشد.
برای مثال، پس از تأیید پرداخت میتوان یک رویداد مانند PaymentConfirmed ثبت کرد و پردازش تحویل محصول را در صف قرار داد. اما صرف ارسال یک Job کافی نیست؛ باید در برابر ثبت تکراری رویداد و شکست پردازش نیز مقاوم باشید.
یک روش رایج، استفاده از الگوی Outbox است: تغییر وضعیت پرداخت و ثبت پیام مربوط به تحویل، در یک تراکنش دیتابیس انجام میشود و سپس یک پردازشگر پیام را برای اجرا برمیدارد. این روش احتمال از دست رفتن رویداد در فاصله بین commit دیتابیس و ارسال Job را کاهش میدهد.
۱۰. مدیریت وضعیت سفارش و پرداخت
در طراحی سیستم، بهتر است وضعیتهای سفارش و پرداخت جداگانه تعریف شوند.
نمونه وضعیتهای پرداخت:
| وضعیت | توضیح |
|---|---|
pending | پرداخت ایجاد شده اما نتیجه نهایی مشخص نیست |
paid | پرداخت با روش معتبر تأیید شده است |
failed | پرداخت ناموفق اعلام شده است |
cancelled | پرداخت لغو شده است |
expired | مهلت پرداخت به پایان رسیده است |
همه درگاهها وضعیتهای یکسانی ندارند. برای مثال، برخی پاسخها نشان میدهند پرداخت ناموفق بوده و برخی صرفاً نشان میدهند نتیجه هنوز مشخص نیست. بنابراین وضعیت داخلی باید بر اساس معنی پاسخ رسمی درگاه تعیین شود.
برای سفارش نیز میتوان وضعیتهایی مانند pending، paid، processing، completed و cancelled را در نظر گرفت. انتخاب وضعیتها به نوع کسبوکار بستگی دارد.
نکته مهم این است که سفارش و پرداخت نباید با هر پاسخ غیرمنتظره به وضعیت نهایی منتقل شوند. در شرایطی مانند timeout یا قطع ارتباط، ممکن است نتیجه واقعی پرداخت هنوز نامشخص باشد. در چنین حالتی، سیستم باید امکان استعلام مجدد و تطبیق وضعیت را داشته باشد.
۱۱. امنیت سیستم پرداخت
برای افزایش امنیت، موارد زیر را در نظر بگیرید:
اعتبارسنجی سمت سرور
مبلغ، مالکیت سفارش، تخفیفها و مجازبودن خرید باید در سرور بررسی شوند. اطلاعات ارسالی مرورگر نباید مرجع نهایی قیمت باشند.
نگهداری امن کلیدهای درگاه
کلیدها و اطلاعات محرمانه نباید در کد عمومی، مخزن Git یا پاسخ API قرار گیرند. دسترسی به متغیرهای محیطی و گزارشهای عملیاتی نیز باید محدود باشد.
کنترل مالکیت سفارش
کاربر باید فقط بتواند پرداخت سفارشهایی را شروع کند که اجازه دسترسی به آنها را دارد. دریافت شناسه سفارش از URL به معنی مجازبودن کاربر نیست.
محافظت در برابر درخواست تکراری
از قیدهای یکتا، کنترل وضعیت، تراکنش دیتابیس و شناسههای idempotency استفاده کنید. تنها بررسی وضعیت در حافظه یا قبل از شروع تراکنش برای جلوگیری از رقابت همزمان کافی نیست.
ثبت گزارش مناسب
شناسه سفارش، شناسه داخلی پرداخت، نام درگاه و نتیجه عملیات را برای عیبیابی ثبت کنید؛ اما کلیدهای محرمانه، اطلاعات کارت بانکی یا دادههای حساس را در log ذخیره نکنید.
استعلام مجدد تراکنشهای نامشخص
اگر هنگام تأیید، ارتباط با درگاه قطع شد، نباید بدون بررسی دوباره پرداخت را ناموفق یا موفق اعلام کنید. وضعیت نامشخص باید با استعلام مجدد و تطبیق اطلاعات تعیین تکلیف شود.
۱۲. تست سیستم پرداخت
قبل از انتشار، سناریوهای موفق و ناموفق را آزمایش کنید:
- [ ] ثبت سفارش با مبلغ صحیح
- [ ] رد مبلغ دستکاریشده از سمت مرورگر
- [ ] ایجاد پرداخت برای سفارش مجاز
- [ ] تأیید پرداخت موفق
- [ ] رد پرداخت ناموفق
- [ ] مدیریت timeout در زمان درخواست به درگاه
- [ ] جلوگیری از تأیید چندباره یک تراکنش
- [ ] جلوگیری از تحویل چندباره محصول
- [ ] مدیریت callback تکراری
- [ ] تطبیق مبلغ و واحد پول
- [ ] جلوگیری از دسترسی به سفارش کاربر دیگر
- [ ] بازیابی پرداختهایی که نتیجه آنها نامشخص مانده است
- [ ] کنترل بازپرداخت و لغو سفارش
برای آزمایش این موارد، میتوانید از Feature Testهای Laravel و یک سرویس ساختگی برای درگاه پرداخت استفاده کنید. در تستها، بهتر است پاسخهای موفق، ناموفق، ناقص و timeout را شبیهسازی کنید تا منطق داخلی سیستم مستقل از درگاه قابل ارزیابی باشد.
سوالات متداول
آیا میتوان بعد از بازگشت کاربر از درگاه، سفارش را پرداختشده کرد؟
خیر. بازگشت کاربر به سایت بهتنهایی تأیید پرداخت نیست. باید از روش رسمی درگاه، تراکنش را از سمت سرور تأیید کنید و مبلغ و شناسهها را بررسی کنید.
چرا باید سفارش و تراکنش پرداخت دو جدول جدا باشند؟
یک سفارش ممکن است چند تلاش پرداخت داشته باشد. جداسازی این دو مفهوم، ثبت تاریخچه، پیگیری خطاها و مدیریت تلاشهای مجدد را سادهتر میکند.
چگونه از پرداخت تکراری در Laravel جلوگیری کنیم؟
با استفاده از شناسههای یکتا، کنترل وضعیت، قفل ردیفها در تراکنش دیتابیس و اطمینان از اینکه عملیات تحویل یا شارژ فقط یک بار انجام میشود.
آیا برای هر درگاه پرداخت باید کد جداگانه نوشت؟
بهتر است یک رابط داخلی مشترک برای عملیات ایجاد و تأیید پرداخت طراحی کنید و برای هر درگاه، پیادهسازی متناسب با API آن داشته باشید. به این ترتیب، منطق سفارش و ثبت نتیجه پرداخت به یک ارائهدهنده خاص وابسته نمیشود.
اگر پرداخت موفق شود اما سایت پاسخ درگاه را دریافت نکند، چه باید کرد؟
باید وضعیت تراکنش را نامشخص یا در انتظار بررسی نگه داشت و با استعلام مجدد از درگاه، نتیجه واقعی را مشخص کرد. سیستم نباید فقط به نتیجه درخواست HTTP اولیه تکیه کند.
جمعبندی
ساخت سیستم پرداخت با Laravel فقط به انتقال کاربر به درگاه محدود نمیشود. یک سیستم مطمئن باید سفارش و تراکنش را جداگانه مدیریت کند، مبلغ را در سمت سرور اعتبارسنجی کند، نتیجه پرداخت را از روش معتبر تأیید کند و در برابر callback تکراری، خطاهای ارتباطی و اجرای همزمان مقاوم باشد.
با رعایت این اصول، میتوانید پایهای مناسب برای پرداخت در فروشگاه اینترنتی، سامانه اشتراکی، کیف پول و سایر پروژههای مبتنی بر Laravel ایجاد کنید.





