دمج بوابات الدفع ليس مجرد استدعاء API
أول عملية دمج دفع لكل مطور تبدو متشابهة: استدعاء API، فحص الاستجابة، حفظ المعاملة. ثم الإطلاق.
ثم يأتي الواقع — انقطاع الشبكة، الشحن المزدوج، فشل تسليم Webhooks، أخطاء تقريب العملات، الاستردادات الجزئية، وذلك العميل الذي ضغط "ادفع" سبع عشرة مرة.
يغطي هذا الدليل الأنماط التي تميّز عملية دمج دفع هشة عن أخرى جاهزة للإنتاج.
نمط الاستراتيجية: دعم بوابات متعددة
معظم التطبيقات تحتاج في النهاية إلى دعم مزودي دفع متعددين. نمط الاستراتيجية (Strategy Pattern) يجعل هذا نظيفاً:
interface PaymentGateway
{
public function charge(Money $amount, PaymentMethod $method): PaymentResult;
public function refund(string $transactionId, ?Money $amount = null): RefundResult;
public function supportsMethod(string $method): bool;
}
class StripeGateway implements PaymentGateway
{
public function __construct(private readonly StripeClient $client) {}
public function charge(Money $amount, PaymentMethod $method): PaymentResult
{
try {
$intent = $this->client->paymentIntents->create([
'amount' => $amount->inCents(),
'currency' => strtolower($amount->currency),
'payment_method' => $method->token,
'confirm' => true,
'idempotency_key' => $method->idempotencyKey,
]);
return PaymentResult::success(
transactionId: $intent->id,
amount: $amount,
);
} catch (CardException $e) {
return PaymentResult::failed($e->getMessage(), $e->getDeclineCode());
}
}
// ...
}
class PayPalGateway implements PaymentGateway
{
// Same interface, different implementation
}
// Resolution
class PaymentGatewayFactory
{
public function make(string $provider): PaymentGateway
{
return match($provider) {
'stripe' => app(StripeGateway::class),
'paypal' => app(PayPalGateway::class),
default => throw new UnsupportedGatewayException($provider),
};
}
}
التكرار الآمن: منع الشحن المزدوج
هذا هو أهم نمط على الإطلاق في معالجة المدفوعات. إذا نقر المستخدم مرتين، أو أعاد خادمك المحاولة بعد انتهاء المهلة، أو أُطلق Webhook مرتين — يجب أن يُحاسب العميل مرة واحدة فقط.
class ProcessPayment
{
public function execute(PaymentRequest $request): PaymentResult
{
// Generate a deterministic idempotency key
$idempotencyKey = $this->generateKey($request);
// Check if we've already processed this payment
$existing = Payment::where('idempotency_key', $idempotencyKey)->first();
if ($existing) {
return PaymentResult::fromExisting($existing);
}
// Create a pending record BEFORE calling the gateway
$payment = Payment::create([
'idempotency_key' => $idempotencyKey,
'amount' => $request->amount,
'status' => PaymentStatus::Pending,
'gateway' => $request->gateway,
]);
try {
$result = $this->gateway->charge($request->amount, $request->method);
$payment->update([
'status' => $result->success ? PaymentStatus::Completed : PaymentStatus::Failed,
'transaction_id' => $result->transactionId,
'gateway_response' => $result->rawResponse,
]);
return $result;
} catch (Throwable $e) {
$payment->update(['status' => PaymentStatus::Error, 'error' => $e->getMessage()]);
throw $e;
}
}
private function generateKey(PaymentRequest $request): string
{
return hash('sha256', implode('|', [
$request->orderId,
$request->amount->inCents(),
$request->amount->currency,
$request->userId,
]));
}
}
معالجة Webhooks: العمود الفقري لحالة الدفع
لا تثق أبداً بتأكيد الدفع من جانب العميل وحده. Webhooks هي مصدر الحقيقة.
class StripeWebhookController extends Controller
{
public function handle(Request $request): Response
{
// 1. Verify the webhook signature
try {
$event = Webhook::constructEvent(
$request->getContent(),
$request->header('Stripe-Signature'),
config('services.stripe.webhook_secret')
);
} catch (SignatureVerificationException $e) {
return response('Invalid signature', 400);
}
// 2. Check for duplicate delivery
if (WebhookLog::where('event_id', $event->id)->exists()) {
return response('Already processed', 200);
}
// 3. Log before processing
WebhookLog::create([
'event_id' => $event->id,
'type' => $event->type,
'payload' => $event->toArray(),
]);
// 4. Dispatch to handler
match($event->type) {
'payment_intent.succeeded' => $this->handlePaymentSuccess($event),
'payment_intent.payment_failed' => $this->handlePaymentFailure($event),
'charge.refunded' => $this->handleRefund($event),
default => null, // Ignore unhandled events
};
// 5. Always return 200 to prevent retries
return response('OK', 200);
}
}
أنماط استرداد الأخطاء
يمكن أن تفشل المدفوعات بطرق مثيرة للاهتمام. إليك كيفية التعامل مع السيناريوهات الأكثر شيوعاً:
- انتهاء مهلة البوابة: لا تعد المحاولة بشكل أعمى. تحقق من حالة الدفع أولاً، ثم أعد المحاولة فقط إذا لم تتم عملية الشحن الأصلية
- الفشل الجزئي: إذا نجح الشحن لكن فشلت كتابة قاعدة البيانات، سيقوم Webhook بمطابقة الحالة
- البطاقات المرفوضة: أعد رسائل واضحة للمستخدم مرتبطة بأكواد الرفض
- عدم تطابق العملة: تحقق من العملة في طبقة الإدخال، وليس في طبقة البوابة
اختبار عمليات دمج الدفع
class ProcessPaymentTest extends TestCase
{
public function test_idempotent_payment_does_not_double_charge(): void
{
$gateway = Mockery::mock(PaymentGateway::class);
$gateway->shouldReceive('charge')
->once() // Critical: should only be called ONCE
->andReturn(PaymentResult::success('txn_123', new Money(1000)));
$processor = new ProcessPayment($gateway);
$request = new PaymentRequest(orderId: 1, amount: new Money(1000));
$result1 = $processor->execute($request);
$result2 = $processor->execute($request);
$this->assertTrue($result1->success);
$this->assertTrue($result2->success);
$this->assertEquals(1, Payment::count()); // Only one record
}
}
قائمة التحقق قبل الإطلاق
- التحقق من توقيع Webhook مفعّل
- مفاتيح التكرار الآمن (Idempotency Keys) مولّدة لجميع عمليات الشحن
- جميع المبالغ المالية تستخدم أعداداً صحيحة بالسنتات، وليس أعداداً عشرية
- نقطة نهاية Webhook تُرجع 200 قبل المعالجة (استخدم وظائف مؤجلة)
- عمليات تسليم Webhook الفاشلة مراقبة والتنبيهات مُعدّة
- متطلبات الامتثال لـ PCI مفهومة (استخدم نماذج دفع مستضافة)
- مسارات الاسترداد مختبرة من البداية إلى النهاية