بناء التطبيق
تطبيقات الدفع (Payment)
تطبيق الدفع يُسجَّل كبوابة/وسيلة دفع قابلة للتوصيل تثبّتها الجهة مرة واحدة،
ثم تدفع بقية التطبيقات المبالغ من خلاله عبر عقد دفع موحَّد في okta-web.
أنت تبني المزوِّد مرة، والمنصة تستدعيه بـ charge(...) بصرف النظر عن
البوابة الفعلية (Tabby، Tamara، Noon Payments، ...).
الـ manifest block
{
"integrationType": "payment",
"payment": {
"payment_methods": ["card", "mada", "applepay", "stcpay", "tabby", "tamara", "bank_transfer", "wallet", "cash"],
"delivery": "api",
"api": {
"charge_endpoint": "https://your-gateway.example/okta/charge",
"auth": "hmac"
},
"embedded": {
"provider_class": "Modules\\AcmePay\\Payments\\AcmePayProvider"
},
"capabilities": {
"supports_refunds": true,
"supports_partial_refunds": false,
"supports_installments": true,
"min_amount": null,
"max_amount": null,
"currencies": ["SAR"]
},
"settings_ui": {
"has_settings_page": true,
"livewire_component": "vendor-x-payment-settings"
}
}
}
قواعد التحقق التي تفرضها المنصة على manifest الدفع:
payment.payment_methodsمصفوفة غير فارغة من قيم معروفة:card,mada,applepay,stcpay,tabby,tamara,bank_transfer,wallet,cash.payment.deliveryلازم تكونapiأوembeddedأوhybrid.- إذا
delivery=apiأوhybrid⇐payment.api.charge_endpointلازم يكون HTTPS. - إذا
delivery=embeddedأوhybrid⇐payment.embedded.provider_classلازم يكون FQCN صالح (مثلModules\AcmePay\Payments\AcmePayProvider). - لا يُسمح بـ
paymentblock إلا حينintegrationType=payment.
وسائل دفع مخصصة
بالإضافة إلى الوسائل التسع القياسية، يمكن لمزوّد الدفع تعريف وسائل
مخصصة عبر بلوك custom_methods بجانب payment_methods:
"payment": {
"payment_methods": ["card", "mada", "my_regional_wallet"],
"custom_methods": [
{ "key": "my_regional_wallet", "label": "محفظتي الإقليمية", "label_en": "My Regional Wallet", "kind": "wallet" }
],
"delivery": "api"
}
القواعد:
key: يطابق^[a-z][a-z0-9_]{1,31}$، ولا يساوي أي وسيلة قياسية، وفريد ضمن القائمة.label(عربي) وlabel_en(إنجليزي): مطلوبان، طول كل منهما 2–60 حرفاً.kind: مطلوب، أحد:card|wallet|bnpl|transfer|cash|other.- كل مفتاح في
custom_methodsلازم يظهر فيpayment_methods(والعكس: أي إدخال غير قياسي فيpayment_methodsلازم يقابله إدخال فيcustom_methods). في نموذج الإنشاء تُضاف مفاتيح الوسائل المخصصة تلقائياً إلىpayment_methods، فلا حاجة لتأشيرها يدوياً. - يُحذف
custom_methodsمن الـ manifest تماماً حين لا توجد وسائل مخصصة.
التطبيقات المستهلِكة ترى الوسائل المخصصة (مع مسمياتها) عبر
GET /api/apps/payments/methods تماماً كالوسائل القياسية، وتستخدم
key عند تنفيذ الدفعة.
الـ scopes (تُمنح تلقائياً)
عند اختيار integrationType=payment من نموذج الإنشاء، المنصة تُلحق
تلقائياً بمنحات تطبيقك:
payments.charges.update(إجباري — يسمح بتحديث حالة الدفعة)payments.charges.read(اختياري — قراءة حالة الدفعة)
لا تحتاج اختيار شيء من picker الصلاحيات لو تطبيقك بحت مزوّد دفع.
مسار 1: delivery=api
العقد بين المنصة وتطبيقك
تستقبل من okta-web طلب دفعة موقَّعاً:
POST https://your-gateway.example/okta/charge
Content-Type: application/json
X-Okta-Timestamp: 1736435261
X-Okta-Signature: <hmac-sha256-hex>
X-Okta-Charge-Id: <uuid>
X-Okta-Idempotency-Key: <key>
{
"method": "tabby",
"amount": 150.00,
"currency": "SAR",
"description": "اشتراك سنوي",
"customer": { "name": "أحمد", "phone": "+966500000000", "email": "a@example.com" },
"metadata": {},
"charge_ref": "chg_01H..."
}
- التوقيع:
X-Okta-Signature=hmac_sha256("<timestamp>.<body>", signingSecret)— نفس آلية الإشعار. - لا إعادة محاولة تلقائية من okta-web؛ يُعتمد على
X-Okta-Idempotency-Keyلضمان عدم الخصم المزدوج إن أعدت الطلب.
الرد المتوقَّع
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "pending",
"provider_ref": "tabby_pay_123",
"redirect_url": "https://checkout.tabby.ai/..."
}
statusواحدة من:pending|paid|failed.provider_refمعرّف العملية لدى المزوّد.redirect_urlاختياري — لو الدفع يحتاج توجيه العميل (Tabby/Tamara).- أي رد 2xx يُعتبر مقبولاً؛ حدِّث الحالة لاحقاً عبر callback الحالة.
تحديث الحالة لاحقاً
عند تغيّر حالة العملية (تم الدفع / فشل)، يرسل تطبيقك تحديثاً لـ okta-web
بـ installation token الخاص به (scope payments.charges.update):
POST https://<okta-web>/api/apps/payments/charges/{charge_ref}/status
Authorization: Bearer <installation-token>
Content-Type: application/json
{ "status": "paid", "provider_ref": "tabby_pay_123" }
مسار 2: delivery=embedded
تشحن class داخل okta-web يُطبِّق App\Contracts\PartnerPaymentProvider
(charge / refund / supports / isConfigured). لا تَرمِ exceptions من
charge() — التف بـ try/catch وأعِد نتيجة فشل.
مسار 3: delivery=hybrid
يبني المنصة مزوّداً هجيناً يجرّب المسار embedded أولاً، ويتراجع لـ api
عند عدم تهيئة الـ class (isConfigured()=false) أو فشل غير دائم.
استهلاك المدفوعات من تطبيقك (Embedded فقط)
قاعدة صارمة: استهلاك الدفع (إنشاء دفعة / قراءتها / سردها / استرجاعها) متاح حصراً لتطبيقات Embedded. أي محاولة استهلاك من تطبيق External تُرفض بخطأ
payment_consumption_embedded_only. السبب: ردّة الفعل على نتيجة الدفعة تصل عبر حدث داخل العملية (in-process) لا webhook — تطبيق مستضاف خارجياً لا يملك مساراً للاستماع لحدث Laravel داخلي، فسيبقى "أعمى" عن النتيجة النهائية لو سُمح له بالدفع. مزوّدو الدفع أنفسهم (integrationType=payment) لا يستهلكون أيضاً — دورهم استقبال نداء الدفع وتحديث حالته فقط (payments.charges.update)، وليس بدء عمليات دفع لصالح تطبيقات أخرى.
كيف تعمل الآلية من طرف إلى طرف
- الجهة تُثبّت مزوّد دفع واحد أو أكثر (
integrationType=payment) من/partner-apps/payment/providers— خطوة إدارية تقوم بها الجهة نفسها، لا علاقة لتطبيقك بها. - تطبيقك المدمج (Embedded) يطلب تنفيذ دفعة بمبلغ عبر عقد
CreateChargeالموحَّد دون أن يعرف أي بوابة فعلية مثبَّتة لدى الجهة. - المنصة (
ResolveTenantPaymentProvider) تحلّ المزوّد النشط الذي يدعم الوسيلة المطلوبة، وتُنفّذ الدفعة عبره (نفس مسارات api/embedded/hybrid الموصوفة أعلاه من جهة المزوّد). - تطبيقك يتابع النتيجة بطريقتين متكاملتين:
- فورياً من قيمة الرد نفسه (
status: paid|pending|failed). - لاحقاً عبر الحدث
ChargeUpdatedالذي تبثّه المنصة عند كل تحوّل حالة — ضروري لحالاتpendingالتي تتحوّل لاحقاً إلىpaidعبر بوابات BNPL بعد أن يُكمل العميل الدفع على صفحة البوابة.
- فورياً من قيمة الرد نفسه (
النطاقات المطلوبة
اطلبها من picker النطاقات عند بناء تطبيقك (resource payments):
| النطاق | يمنح |
|---|---|
payments.methods.read |
قراءة وسائل الدفع المتاحة للجهة |
payments.charges.create |
إنشاء دفعة — يتطلب idempotency_key |
payments.charges.read |
قراءة دفعة واحدة أو سرد كل دفعاتك |
payments.refunds.create |
استرجاع دفعة سبق أن دفعتها |
payments.charges.updateليس ضمن picker المستهلكين — هذا النطاق حصري لمزوّدي الدفع أنفسهم (يُمنح تلقائياً عندintegrationType=paymentفقط، لتحديث حالة الدفعات التي ينفّذونها). استدعاؤه من سياق استهلاك يُرفض بخطأpayment_status_update_provider_only.
الخدمات (in-process — الطريق الأساسي)
نفس نمط بقية App\Services\PartnerApi\* — بدون HTTP، بدون exceptions
للتحكم بالتدفق العادي، مسموحة للسكانر. كل خدمة تُعيد array (لا
Eloquent، لا تسريب schema):
| الخدمة | التوقيع | المكافئ HTTP | Scope |
|---|---|---|---|
ListAvailableMethods |
__invoke(): array |
GET /api/apps/payments/methods |
payments.methods.read |
CreateCharge |
__invoke(array $input): array |
POST /api/apps/payments/charges (+ Idempotency-Key) |
payments.charges.create |
GetCharge |
__invoke(string $chargeRef): array |
GET /api/apps/payments/charges/{ref} |
payments.charges.read |
ListCharges |
__invoke(array $filters = []): array |
GET /api/apps/payments/charges |
payments.charges.read |
RefundCharge |
__invoke(string $chargeRef, array $input): array |
POST /api/apps/payments/charges/{ref}/refund |
payments.refunds.create |
ListCharges تُعيد فقط الدفعات التي أنشأها تطبيقك أنت (تُصفّى
بمعرّف تثبيتك الخاص) — فلاتر مدعومة: status, method, from, to،
مع page/per_page (حتى 100)، وترتيب الأحدث أولاً.
مثال 1 — بيع ميزة/خدمة داخل تطبيقك
// Modules/SchoolPortal/app/Services/Reports/SellPremiumReport.php
namespace Modules\SchoolPortal\Services\Reports;
use App\Services\PartnerApi\Payments\CreateCharge;
final class SellPremiumReport
{
public function __construct(
private readonly CreateCharge $createCharge,
) {}
/**
* @return array<string, mixed>
*/
public function __invoke(string $orderId, float $amount): array
{
return ($this->createCharge)([
'method' => 'tabby',
'amount' => $amount,
'currency' => 'SAR',
'description' => 'تقرير تحليلي مميّز',
'metadata' => [
'feature' => 'premium_reports',
'order_id' => $orderId,
],
'idempotency_key' => "premium-report-{$orderId}",
]);
}
}
من الـ Livewire component المستدعي:
$result = app(SellPremiumReport::class)($orderId, 49.00);
if ($result['status'] === 'paid') {
// فعّل الميزة فوراً — الرد جاء paid مباشرة (بطاقة/مدى).
$this->dispatch('feature-unlocked');
return;
}
if ($result['status'] === 'pending' && $result['redirect_url']) {
// بوابات BNPL (Tabby/Tamara) — وجّه العميل لإكمال الدفع هناك.
$this->redirect($result['redirect_url']);
return;
}
if ($result['status'] === 'failed') {
$this->addError('payment', __('فشلت عملية الدفع. حاول بوسيلة أخرى.'));
return;
}
// pending بدون redirect_url — انتظر حدث ChargeUpdated (مثال 2).
لا تعيد استدعاء CreateCharge عند إعادة تحميل الصفحة أو فشل الشبكة —
مرّر نفس idempotency_key دائماً؛ المنصة تُعيد الدفعة المخزَّنة نفسها
بدل تكرار الدفع (لا إعادة محاولة تلقائية على الدفع).
مثال 2 — فتح الميزة عند اكتمال الدفع (ChargeUpdated)
دفعة بدأت pending (بوابة BNPL) تتحوّل لاحقاً إلى paid/failed من
خارج طلب CreateCharge الأصلي — لا webhook يصلك بهذا (الاستهلاك
in-process حصراً)؛ استمع للحدث داخل نفس العملية من boot() الخاص
بـ ServiceProvider تطبيقك:
// Modules/SchoolPortal/app/Providers/SchoolPortalServiceProvider.php
namespace Modules\SchoolPortal\Providers;
use App\Events\PartnerPayments\ChargeUpdated;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Modules\SchoolPortal\Services\Reports\GrantOrRevokePremiumReport;
class SchoolPortalServiceProvider extends ServiceProvider
{
public function boot(): void
{
Event::listen(function (ChargeUpdated $event): void {
// فلترة إلزامية — المنصة تبثّ نفس الحدث لكل التطبيقات
// المستمعة، وليس فقط تطبيقك.
if ($event->consumerModuleSlug !== 'school-portal') {
return;
}
if (($event->metadata['feature'] ?? null) !== 'premium_reports') {
return;
}
$orderId = $event->metadata['order_id'] ?? null;
match ($event->status) {
'paid' => app(GrantOrRevokePremiumReport::class)->grant($orderId),
'refunded', 'partially_refunded' => app(GrantOrRevokePremiumReport::class)->revoke($orderId),
default => null, // failed — لا فعل إضافي، pending لا يصل هنا أبداً
};
});
}
}
نقاط مهمة حول الحدث:
- يصلك فقط عند تغيّر الحالة فعلياً — لن يصلك على الإنشاء الأولي
بحالة
pending(هذه ليست "تحوّلاً")، بل علىpending→paid|failedأوpaid→refunded|partially_refundedلاحقاً. استرجاع فاشل لا يُطلق الحدث. - اجعل المعالج idempotent — تحقّق من الحالة الحالية لديك قبل التفعيل/الإلغاء، تحسّباً لإعادة تشغيل أو تكرار نادر.
- عالج
refunded/partially_refundedدائماً — الحدث قد يصلك لاحقاً لعملية سبق أن فتحت ميزة؛ أوقفها. - الحقول المتاحة:
chargeRef,tenantId,consumerModuleSlug,previousStatus,status,method,amount,currency,refundedAmount,metadata,occurredAt.
مثال 3 — التسوية والمطابقة (Reconciliation)
use App\Services\PartnerApi\Payments\ListCharges;
$page = app(ListCharges::class)([
'status' => 'paid',
'from' => '2026-07-01',
'to' => '2026-07-31',
'per_page' => 100,
]);
foreach ($page['data'] as $charge) {
// $charge['charge_ref'] هو المعرّف العام الوحيد — لا id رقمي يُكشف أبداً
// طابق $charge['metadata']['order_id'] بسجلك المحلي
}
$page['pagination']; // total / per_page / current_page / last_page
الاسترجاع (Refund)
use App\Services\PartnerApi\Payments\RefundCharge;
$result = app(RefundCharge::class)($chargeRef, ['amount' => null]); // null = استرجاع كامل
ضوابط RefundCharge:
- الدفعة يجب أن تكون ملك تطبيقك (أُنشئت بواسطته — غير ذلك تُعامَل كأنها غير موجودة).
- حالتها الحالية
paid(لا استرجاع علىpending/failed). amount≤ المتبقي القابل للاسترجاع (amount − refunded_amount) — يحترم استرجاعات جزئية سابقة تلقائياً.- لا إعادة محاولة تلقائية هنا أيضاً؛ استرجاع فاشل لا يُطلق
ChargeUpdated.
واجهة الإعدادات (settings_ui) — اختيارية
مثل الإشعار: صرّح has_settings_page + livewire_component لعرض صفحة
إعدادات المزوّد داخل لوحة tenant.
الـ overrides لكل إصدار
كل حقول الدفع (payment_methods, delivery, api.charge_endpoint,
embedded.provider_class, capabilities, settings_ui) قابلة للتخصيص
لكل إصدار من تبويب التكامل في صفحة الإصدار؛ الحقل الفارغ يرث من الـ
module.