بناء التطبيق

تطبيقات الدفع (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).
  • لا يُسمح بـ payment block إلا حين 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)، وليس بدء عمليات دفع لصالح تطبيقات أخرى.

كيف تعمل الآلية من طرف إلى طرف

  1. الجهة تُثبّت مزوّد دفع واحد أو أكثر (integrationType=payment) من /partner-apps/payment/providers — خطوة إدارية تقوم بها الجهة نفسها، لا علاقة لتطبيقك بها.
  2. تطبيقك المدمج (Embedded) يطلب تنفيذ دفعة بمبلغ عبر عقد CreateCharge الموحَّد دون أن يعرف أي بوابة فعلية مثبَّتة لدى الجهة.
  3. المنصة (ResolveTenantPaymentProvider) تحلّ المزوّد النشط الذي يدعم الوسيلة المطلوبة، وتُنفّذ الدفعة عبره (نفس مسارات api/embedded/hybrid الموصوفة أعلاه من جهة المزوّد).
  4. تطبيقك يتابع النتيجة بطريقتين متكاملتين:
    • فورياً من قيمة الرد نفسه (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.


عرض الدليل كاملاً في صفحة واحدة · ↧ Markdown خام