Search by

samt / skipcash-laravel

DrM7md

Unofficial, production-grade Laravel SDK for the SkipCash payment gateway (Qatar): hosted checkout, verified webhooks, refunds, tokenization and recurring payments.

Package info

github.com/samt-qa/skipcash-laravel

pkg:composer/samt/skipcash-laravel

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-10-29 10:01 UTC

This package is auto-updated.

Last update: 2026-10-29 10:12:40 UTC


README

Tests Latest Version License

English  ·  العربية

A production-grade Laravel SDK for the SkipCash payment gateway (Qatar).

Unofficial. This package is built and maintained by Samt and is not affiliated with SkipCash Solutions LLC. It implements the public SkipCash developer documentation.

  • Hosted checkout — create a payment, redirect to payUrl, done.
  • Verified webhooks — HMAC signature check, idempotent inbox, out-of-order protection, optional API confirmation before anything is marked paid.
  • Refunds, tokenised cards, recurring subscriptions, QPAY debit-only flow.
  • Reconciliation command that catches every payment whose webhook never arrived.
  • Typed everywhere — enums, immutable request/response objects, named exceptions.
  • Test fake — SkipCash::fake() with assertions; no HTTP in your test suite.
  • Laravel 12 and 13 · PHP 8.2+ · MIT.

Contents

  1. Installation
  2. Configuration
  3. Accepting a payment
  4. Reacting to payment events
  5. The return URL
  6. Webhooks in depth
  7. Reconciliation
  8. Refunds
  9. Saved cards (tokenisation)
  10. Recurring payments
  11. Debit cards only (QPAY)
  12. Mobile apps
  13. Testing your application
  14. Multi-tenant credentials
  15. Going live
  16. Reference

Installation

composer require samt/skipcash-laravel
php artisan vendor:publish --tag=skipcash-config
php artisan migrate

Migrations are loaded automatically. Publish them only if you need to customise table names:

php artisan vendor:publish --tag=skipcash-migrations

Configuration

Generate keys in the SkipCash Merchant Portal → Online Payments. Sandbox and Production each have their own set.

SKIPCASH_ENV=sandbox                # sandbox | production
SKIPCASH_CLIENT_ID=
SKIPCASH_KEY_ID=
SKIPCASH_KEY_SECRET=
SKIPCASH_WEBHOOK_KEY=
SKIPCASH_RETURN_URL=https://example.com/checkout/return

Test accounts: if your merchant account itself is a test account, generate keys from the Production tab. Sandbox keys on a test account return Missing or invalid key.

The webhook URL is sent with every payment automatically (route('skipcash.webhook')), so the portal setting is never load-bearing. See config/skipcash.php for everything else.

Accepting a payment

use Samt\SkipCash\Facades\SkipCash;
use Samt\SkipCash\Requests\CreatePayment;

$payment = SkipCash::createPayment(
    CreatePayment::make(
        amount: $order->total,          // QAR — int, float or "99.50"
        firstName: $order->first_name,
        lastName: $order->last_name,
        phone: $order->phone,           // "55123456" or "+97455123456"
        email: $order->email,           // null → "{phone}@{fallback domain}"
        transactionId: $order->number,  // your reference; comes back on every webhook
    )
        ->withSubject("Order {$order->number}")
        ->withCustom(1, (string) $order->id),
    payable: $order,                    // optional morph relation
);

return redirect()->away($payment->payUrl);

createPayment() validates the request locally against the documented limits (name length, address total ≤ 50 chars, phone format, …) and throws InvalidRequestException before any HTTP call. API errors throw ApiException with errorCode, validationErrors and the raw body.

A row is written to skipcash_payments for every payment, keyed by SkipCash's payment_id and your transaction_id.

Reacting to payment events

Register listeners for the events you care about. Each receives the local SkipCashPayment record and, when triggered by a webhook, the WebhookPayload.

use Samt\SkipCash\Events\PaymentPaid;
use Samt\SkipCash\Events\PaymentFailed;
use Samt\SkipCash\Events\PaymentRefunded;

Event::listen(PaymentPaid::class, function (PaymentPaid $event) {
    $order = Order::where('number', $event->payment->transaction_id)->firstOrFail();
    $order->markPaid(reference: $event->payment->visa_id);
});
Event When
PaymentPaid Money captured (confirmed against the API by default).
PaymentFailed Status failed or rejected.
PaymentCanceled Shopper abandoned; SkipCash auto-cancels after 1 hour.
RefundPending, PaymentRefunded, RefundFailed Refund lifecycle.
PaymentStatusChanged Every accepted transition, with from and to.
PaymentAttemptFailed A retry on an existing link failed. The original payment is untouched and may still succeed.
UnknownPaymentReceived A webhook for a payment this app never created (e.g. a portal Payment Link). A record is created.
WebhookReceived Raw, before processing. For audit logs.

Events fire exactly once per transition, whether the transition came from a webhook or from reconciliation. Queue your listeners if they do real work.

The return URL

SkipCash redirects the shopper to your return URL with ?id={paymentId}. Do not trust the redirect alone — sync the payment:

Route::get('/checkout/return', function (Request $request) {
    $payment = SkipCash::sync($request->query('id'));   // GET + apply status + fire events

    return $payment->isPaid()
        ? view('checkout.thanks')
        : view('checkout.pending');
});

sync() is idempotent; calling it after the webhook already processed the payment is a no-op.

Webhooks in depth

A route is registered at POST /skipcash/webhook (configurable). The pipeline:

  1. Signature — VerifyWebhookSignature middleware recomputes HMAC-SHA256 over PaymentId, Amount, StatusId, TransactionId, Custom1, VisaId with your webhook key and compares in constant time. Mismatch → 401, nothing is stored.
  2. Inbox — every delivery is recorded in skipcash_webhook_events. A redelivery that would not change the record (same status again, or a status the record can no longer move to) is acknowledged with 200 and skipped. The same status arriving twice legitimately, such as pending refund for a second partial refund, is processed.
  3. State machine — paid never regresses to failed/canceled, however late those arrive. A late paid after failed wins.
  4. Verification — for paid, the payment is re-read from the API and status + amount must match (webhooks.verify_paid_via_api). On mismatch the delivery is left unprocessed and a 500 is returned so SkipCash retries.
  5. Commit, then notify — the transition is committed under a row lock first, then your listeners run, then the delivery is marked processed. Listeners therefore always see the committed status, queued or not. If a listener throws, the status stays (the money did move) but the delivery stays unprocessed, and SkipCash's retry (1 h, then 1 d) replays the same events so the listener gets another chance. Listeners doing real work should be queued with their own retries.

The route has no middleware other than the signature check by default. Do not put it behind the api group: its rate limiter can answer SkipCash's retries with 429. Add your own via webhooks.middleware.

Queue it (recommended)

SkipCash waits 10 s and retries only three times. With SKIPCASH_WEBHOOK_QUEUE=true the controller acknowledges immediately and ProcessWebhook runs on your queue with its own backoff (30 s → 2 m → 10 m → 1 h, 5 attempts).

SKIPCASH_WEBHOOK_QUEUE=true
SKIPCASH_WEBHOOK_QUEUE_NAME=payments

Registering the route yourself

Set webhooks.register_route to false and add:

Route::post('/hooks/skipcash', \Samt\SkipCash\Http\Controllers\WebhookController::class)
    ->middleware(\Samt\SkipCash\Http\Middleware\VerifyWebhookSignature::class)
    ->name('skipcash.webhook');

Keep the route name so the webhook URL can be attached to outgoing payments.

Reconciliation

Webhooks fail: deploys, network partitions, a wrong URL in the portal. The reconcile command re-reads every payment that is still new/pending locally and applies the real status through the same state machine and events:

// routes/console.php
Schedule::command('skipcash:reconcile')->everyFifteenMinutes();
php artisan skipcash:reconcile                    # stale open payments
php artisan skipcash:reconcile --payment=<id>     # one payment, any age
php artisan skipcash:reconcile --dry-run          # report only

Refunds

$refund = SkipCash::refund($payment->payment_id);          // full
$refund = SkipCash::refund($payment->payment_id, 25.50);   // partial

Refunds settle asynchronously (usually within a day). You will receive RefundPending → PaymentRefunded or RefundFailed via webhooks; reconciliation also picks them up from refundStatusId.

Saved cards (tokenisation)

The shopper ticks Save card on the hosted page. After a successful payment, token_id is stored on the local record (also on PaymentPaid → $event->payment->token_id).

// Show the shopper their cards
$cards = SkipCash::savedCards($user->skipcash_tokens);   // list<SavedCard>: brand, lastFour, expiry

// Charge a saved card — shopper still confirms with OTP on the hosted page
$payment = SkipCash::createPayment(
    CreatePayment::make(...)->withToken($card->tokenId),
);

Recurring payments

use Samt\SkipCash\Enums\Interval;
use Samt\SkipCash\Requests\RecurringPlan;

$plan = RecurringPlan::make(
    planName: 'Gold monthly',
    interval: Interval::Month,
    startDate: now(),
    endDate: now()->addYear(),
    frequency: 1,                 // every 1 month
    allowedFailedAttempts: 3,
    firstPaymentAmount: 1,        // optional: different first charge
);

$payment = SkipCash::createPayment(CreatePayment::make(...)->recurring($plan));

Each instalment arrives as a webhook carrying RecurringSubscriptionId. The subscription id is stored on the payment record.

SkipCash::cancelSubscription($subscriptionId);

// Card expired? Shopper pays QAR 5 at the returned payUrl to verify the new card.
$verification = SkipCash::updateSubscriptionCard($subscriptionId, $first, $last, $phone, $email);

Debit cards only (QPAY)

Send the shopper straight to the Qatari debit-card (NAPS) flow:

CreatePayment::make(...)->debitCardOnly();

Mobile apps

Create the payment on your Laravel backend and return payUrl to the app; open it in a WebView. When the WebView closes, call your backend, which calls SkipCash::sync($id). Never trust the app's view of the outcome — the webhook and sync() are the source of truth.

Route::post('/api/checkout', function (Request $request) {
    $payment = SkipCash::createPayment(CreatePayment::make(...));

    return ['payment_id' => $payment->id, 'pay_url' => $payment->payUrl];
});

Testing your application

use Samt\SkipCash\Facades\SkipCash;

public function test_checkout_marks_order_paid(): void
{
    $fake = SkipCash::fake();                       // no HTTP, still writes skipcash_payments

    $this->post('/checkout', [...])->assertRedirect();

    $fake->assertPaymentCreated(fn ($request) => $request->amount === '250.00');

    $fake->markPaid($fake->createdPayments()[0]->id);   // fires PaymentPaid through the real pipeline

    $this->assertTrue(Order::first()->isPaid());
}

Available: assertPaymentCreated(?Closure), assertNothingCreated(), assertPaymentCount(), assertRefunded($id, $amount?), assertSubscriptionCancelled($id), markPaid/markFailed/markCanceled/markRefunded($id), createdPayments().

SkipCash::fake() also turns off API confirmation of paid webhooks, so your webhook tests never call SkipCash.

To test your webhook endpoint end to end, sign a body with the Signer:

$body = ['PaymentId' => $id, 'Amount' => '250.00', 'StatusId' => 2, 'TransactionId' => 'ORD-1', 'VisaId' => 'x'];
$signature = (new \Samt\SkipCash\Signing\Signer)->sign(\Samt\SkipCash\Signing\SignatureFields::WEBHOOK, $body, config('skipcash.credentials.webhook_key'));

$this->postJson(route('skipcash.webhook'), $body, ['Authorization' => $signature])->assertOk();

Multi-tenant credentials

Each tenant owns a SkipCash merchant account? Bind your own resolver:

use Samt\SkipCash\Credentials\{Credentials, CredentialsResolver};
use Samt\SkipCash\Enums\Environment;

final class TenantCredentials implements CredentialsResolver
{
    public function credentials(): Credentials
    {
        $t = tenant();
        return new Credentials($t->skipcash_client_id, $t->skipcash_key_id, $t->skipcash_key_secret, $t->skipcash_webhook_key);
    }

    public function environment(): Environment
    {
        return tenant()->skipcash_live ? Environment::Production : Environment::Sandbox;
    }
}

// AppServiceProvider::register()
$this->app->bind(CredentialsResolver::class, TenantCredentials::class);

The resolver is called per request, so tenant identification from the current request/route works. For webhooks, put the tenant in the webhook path or in Custom2 and resolve it in middleware that runs before VerifyWebhookSignature.

Going live

  1. In the portal, Enable Production and generate a production key set.
  2. Set SKIPCASH_ENV=production and the four production credentials.
  3. Set SKIPCASH_RETURN_URL to your production return route.
  4. Schedule skipcash:reconcile.
  5. Make one real payment of QAR 1 and refund it.

Reference

Sandbox test cards

Card Number Expiry CVV Result
Mastercard 5200 0000 0000 2151 10/2028 237 success
Visa 4000 0000 0000 2503 10/2028 442 success
Mastercard 5200 0000 0000 2490 04/2028 256 failure
Visa 4000 0000 0000 2370 06/2028 256 failure
NAPS debit 4215 3755 0088 3243 06/2026 123 OTP 123456

Payment statuses

statusId Enum Meaning
0 PaymentStatus::New created
1 PaymentStatus::Pending awaiting shopper
2 PaymentStatus::Paid captured
3 PaymentStatus::Canceled abandoned / expired
4 PaymentStatus::Failed declined
5 PaymentStatus::Rejected rejected by risk
6 PaymentStatus::Refunded refund settled
7 PaymentStatus::PendingRefund refund in progress
8 PaymentStatus::RefundFailed refund failed
12 PaymentStatus::InProgress shopper is paying (3-D Secure); resets to 0 after 2 min

Signature algorithm

Both outgoing requests and incoming webhooks use the same scheme: join the non-empty fields, in documented order, as Key=Value pairs separated by commas; HMAC-SHA256 with the secret; Base64 of the raw digest; send in the Authorization header. The exact field lists live in Samt\SkipCash\Signing\SignatureFields.

Exceptions

All extend Samt\SkipCash\Exceptions\SkipCashException:

ConfigurationException · InvalidRequestException · ApiException (httpStatus, returnCode, errorCode, validationErrors, body) · InvalidSignatureException · WebhookVerificationException.

Development

composer install
composer check      # pint + phpstan + phpunit

Security

Report vulnerabilities privately to security@samt.qa. Do not open public issues for security reports.

License

MIT © Samt


SkipCash لـ Laravel

حزمة Laravel احترافية لبوابة الدفع القطرية سكيب كاش.

غير رسمية. هذه الحزمة من تطوير وصيانة سَمْت، وليست تابعة لشركة SkipCash Solutions ذ.م.م. وهي مبنية على توثيق المطورين العام لـ SkipCash.

  • صفحة دفع مستضافة: تنشئ الدفعة، وتحوّل العميل إلى payUrl، وانتهى.
  • إشعارات (Webhooks) موثّقة: تحقق من توقيع HMAC، وعدم تكرار المعالجة، وحماية من وصول الإشعارات بترتيب خاطئ، وتأكيد اختياري عبر الـ API قبل اعتماد أي دفعة.
  • الاسترداد، وحفظ البطاقات، والاشتراكات المتكررة، والدفع ببطاقات الخصم القطرية فقط (QPAY).
  • أمر مطابقة يلتقط كل دفعة لم يصل إشعارها.
  • أنواع صارمة في كل مكان: Enums، وكائنات طلب واستجابة غير قابلة للتعديل، واستثناءات مسمّاة.
  • نسخة وهمية للاختبار: SkipCash::fake()‎ مع دوال تحقق، بلا أي اتصال حقيقي في اختباراتك.
  • Laravel 12 و13 · PHP 8.2 فأحدث · رخصة MIT.

المحتويات

  1. التثبيت
  2. الإعداد
  3. استقبال دفعة
  4. التفاعل مع أحداث الدفع
  5. رابط العودة
  6. الإشعارات بالتفصيل
  7. المطابقة
  8. الاسترداد
  9. البطاقات المحفوظة
  10. الدفعات المتكررة
  11. بطاقات الخصم فقط (QPAY)
  12. تطبيقات الجوال
  13. اختبار تطبيقك
  14. تعدد المستأجرين
  15. الانتقال للإنتاج
  16. مرجع

التثبيت

composer require samt/skipcash-laravel
php artisan vendor:publish --tag=skipcash-config
php artisan migrate

جداول قاعدة البيانات تُحمَّل تلقائيًا. انشرها فقط إن أردت تغيير أسماء الجداول:

php artisan vendor:publish --tag=skipcash-migrations

الإعداد

ولّد المفاتيح من بوابة التاجر في SkipCash ← Online Payments. لكل من Sandbox وProduction مفاتيحه الخاصة.

SKIPCASH_ENV=sandbox                # sandbox | production
SKIPCASH_CLIENT_ID=
SKIPCASH_KEY_ID=
SKIPCASH_KEY_SECRET=
SKIPCASH_WEBHOOK_KEY=
SKIPCASH_RETURN_URL=https://example.com/checkout/return

حسابات الاختبار: إن كان حساب التاجر نفسه حساب اختبار، فولّد المفاتيح من تبويب Production. مفاتيح Sandbox على حساب اختبار تُرجع الخطأ Missing or invalid key.

رابط الإشعارات يُرسل تلقائيًا مع كل دفعة (route('skipcash.webhook')‎)، فلا تعتمد على ضبطه في البوابة. باقي الإعدادات في config/skipcash.php.

استقبال دفعة

use Samt\SkipCash\Facades\SkipCash;
use Samt\SkipCash\Requests\CreatePayment;

$payment = SkipCash::createPayment(
    CreatePayment::make(
        amount: $order->total,          // QAR — int, float or "99.50"
        firstName: $order->first_name,
        lastName: $order->last_name,
        phone: $order->phone,           // "55123456" or "+97455123456"
        email: $order->email,           // null → "{phone}@{fallback domain}"
        transactionId: $order->number,  // your reference; comes back on every webhook
    )
        ->withSubject("Order {$order->number}")
        ->withCustom(1, (string) $order->id),
    payable: $order,                    // optional morph relation
);

return redirect()->away($payment->payUrl);

تتحقق createPayment()‎ من الطلب محليًا وفق حدود التوثيق (طول الاسم، ومجموع العنوان ≤ 50 حرفًا، وصيغة الجوال...)، وترمي InvalidRequestException قبل أي اتصال. أخطاء الـ API ترمي ApiException ومعها errorCode وvalidationErrors والاستجابة الخام.

يُكتب سجل في جدول skipcash_payments لكل دفعة، مفهرس برقم الدفعة في SkipCash (payment_id) وبمرجعك (transaction_id).

التفاعل مع أحداث الدفع

سجّل مستمعين للأحداث التي تهمك. كل حدث يحمل سجل الدفعة المحلي SkipCashPayment، ومعه WebhookPayload إن كان مصدره إشعارًا.

use Samt\SkipCash\Events\PaymentPaid;

Event::listen(PaymentPaid::class, function (PaymentPaid $event) {
    $order = Order::where('number', $event->payment->transaction_id)->firstOrFail();
    $order->markPaid(reference: $event->payment->visa_id);
});
الحدث متى يُطلق
PaymentPaid تم تحصيل المبلغ (مؤكَّد عبر الـ API افتراضيًا).
PaymentFailed الحالة failed أو rejected.
PaymentCanceled تخلّى العميل عن الدفع؛ يلغيها SkipCash تلقائيًا بعد ساعة.
RefundPending وPaymentRefunded وRefundFailed مراحل الاسترداد.
PaymentStatusChanged كل انتقال مقبول، مع الحالة السابقة واللاحقة.
PaymentAttemptFailed فشلت محاولة على رابط قائم. الدفعة الأصلية لم تُمسّ وقد تنجح لاحقًا.
UnknownPaymentReceived إشعار لدفعة لم ينشئها تطبيقك (مثل رابط دفع من البوابة). يُنشأ لها سجل.
WebhookReceived الإشعار الخام قبل المعالجة، لسجلات التدقيق.

تُطلق الأحداث مرة واحدة فقط لكل انتقال، سواء جاء من إشعار أو من المطابقة. اجعل المستمعين في طابور (Queue) إن كانوا يؤدون عملًا ثقيلًا.

رابط العودة

يعيد SkipCash العميل إلى رابط العودة ومعه ?id={paymentId}. لا تعتمد على التحويل وحده، بل زامِن الدفعة:

Route::get('/checkout/return', function (Request $request) {
    $payment = SkipCash::sync($request->query('id'));   // GET + apply status + fire events

    return $payment->isPaid()
        ? view('checkout.thanks')
        : view('checkout.pending');
});

استدعاء sync()‎ آمن عند تكراره؛ لو سبقه الإشعار ومعالجته فلن يحدث شيء.

الإشعارات بالتفصيل

يُسجَّل مسار على POST /skipcash/webhook (قابل للتغيير). مراحل المعالجة:

  1. التوقيع: يعيد الوسيط VerifyWebhookSignature حساب HMAC-SHA256 على الحقول PaymentId, Amount, StatusId, TransactionId, Custom1, VisaId بمفتاح الإشعارات، ويقارن بزمن ثابت. عند عدم التطابق يرد 401 ولا يُخزَّن شيء.
  2. صندوق الوارد: تُسجَّل كل رسالة في جدول skipcash_webhook_events. الرسالة المعادة التي لن تغيّر السجل (نفس الحالة مرة أخرى، أو حالة لم يعد السجل يقبلها) يُرد عليها بـ 200 وتُتجاهل. أما تكرار الحالة نفسها بشكل مشروع، مثل pending refund لاسترداد جزئي ثانٍ، فيُعالج.
  3. آلة الحالات: لا تتراجع الدفعة من paid إلى failed أو canceled مهما تأخر وصولهما. أما paid المتأخرة بعد failed فتُعتمد.
  4. التأكيد: عند paid يُعاد الاستعلام عن الدفعة من الـ API، ويجب أن تتطابق الحالة والمبلغ (webhooks.verify_paid_via_api). عند الاختلاف تبقى الرسالة غير معالجة ويُرد 500 ليعيد SkipCash الإرسال.
  5. الاعتماد ثم الإشعار: يُعتمد الانتقال أولاً تحت قفل على الصف، ثم يعمل المستمعون، ثم تُسجَّل الرسالة كمعالجة. فيرى المستمعون دائماً الحالة المعتمدة، سواء كانوا في الطابور أم لا. لو فشل أحد المستمعين تبقى الحالة (فالمال تحرك فعلاً) وتبقى الرسالة غير معالجة، وإعادة الإرسال من SkipCash (بعد ساعة ثم بعد يوم) تعيد إطلاق الأحداث نفسها ليحصل المستمع على فرصة أخرى. المستمعون الذين ينفذون عملاً حقيقياً يُفضَّل وضعهم في الطابور مع إعادة محاولات خاصة بهم.

ليس على المسار أي وسيط افتراضيًا غير فحص التوقيع. لا تضعه ضمن مجموعة api، فمحدّد المعدّل فيها قد يرد على إعادات SkipCash بـ 429. أضف ما تريده عبر webhooks.middleware.

استخدم الطابور (موصى به)

ينتظر SkipCash عشر ثوانٍ فقط، ويعيد المحاولة ثلاث مرات فقط. مع SKIPCASH_WEBHOOK_QUEUE=true يرد المسار فورًا، وتعمل المهمة ProcessWebhook في طابورك بمحاولاتها الخاصة (30 ث ← 2 د ← 10 د ← ساعة، خمس محاولات).

SKIPCASH_WEBHOOK_QUEUE=true
SKIPCASH_WEBHOOK_QUEUE_NAME=payments

تسجيل المسار بنفسك

اجعل webhooks.register_route تساوي false وأضف:

Route::post('/hooks/skipcash', \Samt\SkipCash\Http\Controllers\WebhookController::class)
    ->middleware(\Samt\SkipCash\Http\Middleware\VerifyWebhookSignature::class)
    ->name('skipcash.webhook');

احتفظ باسم المسار نفسه حتى يُرفق رابط الإشعارات مع الدفعات.

المطابقة

الإشعارات قد تفشل: أثناء النشر، أو انقطاع الشبكة، أو رابط خاطئ في البوابة. أمر المطابقة يعيد الاستعلام عن كل دفعة ما زالت new أو pending محليًا، ويطبّق حالتها الحقيقية عبر آلة الحالات والأحداث نفسها:

// routes/console.php
Schedule::command('skipcash:reconcile')->everyFifteenMinutes();
php artisan skipcash:reconcile                    # stale open payments
php artisan skipcash:reconcile --payment=<id>     # one payment, any age
php artisan skipcash:reconcile --dry-run          # report only

الاسترداد

$refund = SkipCash::refund($payment->payment_id);          // full
$refund = SkipCash::refund($payment->payment_id, 25.50);   // partial

الاسترداد غير فوري (عادةً خلال يوم). ستصلك الأحداث RefundPending ثم PaymentRefunded أو RefundFailed عبر الإشعارات، وتلتقطها المطابقة أيضًا من refundStatusId.

البطاقات المحفوظة

يضع العميل علامة على Save card في صفحة الدفع. بعد نجاح الدفع يُحفظ token_id في السجل المحلي (ومتاح أيضًا في PaymentPaid عبر $event->payment->token_id).

// Show the shopper their cards
$cards = SkipCash::savedCards($user->skipcash_tokens);   // list<SavedCard>: brand, lastFour, expiry

// Charge a saved card — shopper still confirms with OTP on the hosted page
$payment = SkipCash::createPayment(
    CreatePayment::make(...)->withToken($card->tokenId),
);

الدفعات المتكررة

use Samt\SkipCash\Enums\Interval;
use Samt\SkipCash\Requests\RecurringPlan;

$plan = RecurringPlan::make(
    planName: 'Gold monthly',
    interval: Interval::Month,
    startDate: now(),
    endDate: now()->addYear(),
    frequency: 1,                 // every 1 month
    allowedFailedAttempts: 3,
    firstPaymentAmount: 1,        // optional: different first charge
);

$payment = SkipCash::createPayment(CreatePayment::make(...)->recurring($plan));

يصل كل قسط في إشعار يحمل RecurringSubscriptionId، ويُحفظ رقم الاشتراك في سجل الدفعة.

SkipCash::cancelSubscription($subscriptionId);

// Card expired? Shopper pays QAR 5 at the returned payUrl to verify the new card.
$verification = SkipCash::updateSubscriptionCard($subscriptionId, $first, $last, $phone, $email);

بطاقات الخصم فقط (QPAY)

لتحويل العميل مباشرة إلى بطاقات الخصم القطرية (NAPS):

CreatePayment::make(...)->debitCardOnly();

تطبيقات الجوال

أنشئ الدفعة في خادم Laravel وأرجع payUrl للتطبيق، ثم افتحه في WebView. عند إغلاقه يستدعي التطبيق خادمك، والخادم يستدعي SkipCash::sync($id)‎. لا تثق بما يراه التطبيق عن النتيجة؛ المرجع هو الإشعار وsync()‎.

Route::post('/api/checkout', function (Request $request) {
    $payment = SkipCash::createPayment(CreatePayment::make(...));

    return ['payment_id' => $payment->id, 'pay_url' => $payment->payUrl];
});

اختبار تطبيقك

use Samt\SkipCash\Facades\SkipCash;

public function test_checkout_marks_order_paid(): void
{
    $fake = SkipCash::fake();                       // no HTTP, still writes skipcash_payments

    $this->post('/checkout', [...])->assertRedirect();

    $fake->assertPaymentCreated(fn ($request) => $request->amount === '250.00');

    $fake->markPaid($fake->createdPayments()[0]->id);   // fires PaymentPaid through the real pipeline

    $this->assertTrue(Order::first()->isPaid());
}

المتاح: assertPaymentCreated(?Closure)‎ وassertNothingCreated()‎ وassertPaymentCount()‎ وassertRefunded($id, $amount?)‎ وassertSubscriptionCancelled($id)‎ وmarkPaid/markFailed/markCanceled/markRefunded($id)‎ وcreatedPayments()‎.

تُعطّل SkipCash::fake()‎ أيضًا تأكيد الدفعات عبر الـ API، فلا تتصل اختبارات الإشعارات بـ SkipCash أبدًا.

لاختبار مسار الإشعارات من البداية للنهاية، وقّع الرسالة باستخدام Signer:

$body = ['PaymentId' => $id, 'Amount' => '250.00', 'StatusId' => 2, 'TransactionId' => 'ORD-1', 'VisaId' => 'x'];
$signature = (new \Samt\SkipCash\Signing\Signer)->sign(\Samt\SkipCash\Signing\SignatureFields::WEBHOOK, $body, config('skipcash.credentials.webhook_key'));

$this->postJson(route('skipcash.webhook'), $body, ['Authorization' => $signature])->assertOk();

تعدد المستأجرين

إن كان لكل مستأجر حساب تاجر خاص في SkipCash، فاربط محلّل مفاتيح خاصًا بك:

use Samt\SkipCash\Credentials\{Credentials, CredentialsResolver};
use Samt\SkipCash\Enums\Environment;

final class TenantCredentials implements CredentialsResolver
{
    public function credentials(): Credentials
    {
        $t = tenant();
        return new Credentials($t->skipcash_client_id, $t->skipcash_key_id, $t->skipcash_key_secret, $t->skipcash_webhook_key);
    }

    public function environment(): Environment
    {
        return tenant()->skipcash_live ? Environment::Production : Environment::Sandbox;
    }
}

// AppServiceProvider::register()
$this->app->bind(CredentialsResolver::class, TenantCredentials::class);

يُستدعى المحلّل مع كل طلب، فتحديد المستأجر من الطلب أو المسار الحالي يعمل. وللإشعارات ضع المستأجر في مسار الإشعار أو في Custom2، وحدّده في وسيط يعمل قبل VerifyWebhookSignature.

الانتقال للإنتاج

  1. في البوابة فعّل Enable Production وولّد مفاتيح الإنتاج.
  2. اجعل SKIPCASH_ENV=production وضع مفاتيح الإنتاج الأربعة.
  3. اجعل SKIPCASH_RETURN_URL يشير إلى مسار العودة في الإنتاج.
  4. جدول الأمر skipcash:reconcile.
  5. نفّذ دفعة حقيقية بريال واحد ثم استردّها.

مرجع

بطاقات الاختبار (Sandbox)

البطاقة الرقم الانتهاء CVV النتيجة
Mastercard 5200 0000 0000 2151 10/2028 237 نجاح
Visa 4000 0000 0000 2503 10/2028 442 نجاح
Mastercard 5200 0000 0000 2490 04/2028 256 فشل
Visa 4000 0000 0000 2370 06/2028 256 فشل
خصم NAPS 4215 3755 0088 3243 06/2026 123 OTP 123456

حالات الدفع

statusId القيمة المعنى
0 PaymentStatus::New أُنشئت
1 PaymentStatus::Pending بانتظار العميل
2 PaymentStatus::Paid تم التحصيل
3 PaymentStatus::Canceled متروكة أو منتهية
4 PaymentStatus::Failed مرفوضة من البنك
5 PaymentStatus::Rejected مرفوضة لأسباب المخاطر
6 PaymentStatus::Refunded تم الاسترداد
7 PaymentStatus::PendingRefund الاسترداد جارٍ
8 PaymentStatus::RefundFailed فشل الاسترداد
12 PaymentStatus::InProgress العميل يدفع الآن (3-D Secure)؛ ترجع إلى 0 بعد دقيقتين

خوارزمية التوقيع

الطلبات الصادرة والإشعارات الواردة تستخدم الطريقة نفسها: تُجمع الحقول غير الفارغة بالترتيب الموثّق على صيغة Key=Value مفصولة بفواصل، ثم HMAC-SHA256 بالمفتاح السري، ثم Base64 للناتج الخام، ويُرسل في ترويسة Authorization. قوائم الحقول بالضبط موجودة في Samt\SkipCash\Signing\SignatureFields.

الاستثناءات

كلها ترث من Samt\SkipCash\Exceptions\SkipCashException:

ConfigurationException · InvalidRequestException · ApiException (httpStatus وreturnCode وerrorCode وvalidationErrors وbody) · InvalidSignatureException · WebhookVerificationException.

التطوير

composer install
composer check      # pint + phpstan + phpunit

الأمان

أبلغ عن الثغرات بشكل خاص على security@samt.qa، ولا تفتح Issues عامة للبلاغات الأمنية.

الرخصة

MIT © سَمْت