digitaltunnel/jeelpay

JeelPay (Study Now Pay Later) SDK for Laravel — Items + Schooling checkout, refunds, OAuth token caching, signed webhooks.

Maintainers

Package info

github.com/digital-tunnel/jeelpay

pkg:composer/digitaltunnel/jeelpay

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-05-28 22:49 UTC

This package is auto-updated.

Last update: 2026-05-28 23:04:57 UTC


README

PHP 8.2+ Laravel 11–13 Tests Pest PHPStan level 6 License MIT

A typed, testable SDK for JeelPay — Saudi Arabia's "Study Now Pay Later" platform — with first-class Laravel integration. Supports both Items (universities, courses, training centers) and Schooling (K-12) checkout flows out of the box.

Built against the official JeelPay developer docs. Endpoints, payloads, statuses and webhook signatures are verified to match the documented API.

  • OAuth 2.1 client credentials with mandatory token caching (Laravel Cache, configurable store)
  • Fluent request builders for Items + Schooling checkouts with built-in Saudi-specific validation
  • Readonly DTOs (CheckoutResult, CheckoutStatusResult, RefundResult, WebhookPayload)
  • Auto-generated idempotency keys (UUID v4) on every checkout POST — reuse on retries to dedupe
  • tx_id captured from response headers on every call for support debugging
  • Auto-registered webhook endpoint outside the web middleware group — no CSRF dance
  • HMAC-SHA256 signature verification on incoming webhooks
  • Idempotent webhook de-duplication via fingerprint hash
  • Optional jeelpay_checkouts, jeelpay_refunds, jeelpay_webhook_events tables — auto-mirrored
  • Test helper JeelPayFake with realistic fixtures + signed-webhook builder

Requirements

  • PHP 8.2+
  • Laravel 11, 12, or 13

Installation

composer require digitaltunnel/jeelpay

Publish the config:

php artisan vendor:publish --tag=jeelpay-config

Publish & run the migrations (opt-in):

php artisan vendor:publish --tag=jeelpay-migrations
php artisan migrate

Configure your .env:

JEELPAY_ENV=sandbox                  # or "production"
JEELPAY_CLIENT_ID=...
JEELPAY_CLIENT_SECRET=...

# Optional — defaults are sandbox/production base URLs derived from JEELPAY_ENV
# JEELPAY_API_URL=https://api.sandbox.jeel.co
# JEELPAY_AUTH_URL=https://auth.sandbox.jeel.co

# Optional — used as request fallbacks when the builder doesn't set them
JEELPAY_REDIRECT_URL=https://yourapp.com/checkout/result
JEELPAY_NOTIFICATION_URL=https://yourapp.com/webhooks/jeelpay

Verify the webhook route is live:

php artisan route:list --name=jeelpay.webhook
# POST  webhooks/jeelpay ... VerifyWebhookSignature

Quickstart

Items checkout (universities, institutes, training centers)

use DigitalTunnel\JeelPay\Facades\JeelPay;
use DigitalTunnel\JeelPay\Requests\ItemsCheckoutRequest;
use DigitalTunnel\JeelPay\ValueObjects\Buyer;
use DigitalTunnel\JeelPay\ValueObjects\Item;
use DigitalTunnel\JeelPay\ValueObjects\Urls;

$result = JeelPay::checkouts()->createItems(
    ItemsCheckoutRequest::make()
        ->buyer(Buyer::make(
            firstName: 'Essa',
            lastName: 'Alshammari',
            mobileNumber: '512345678',          // Saudi format, 9 digits, starts with 5
            email: 'essa@example.com',
            nationalId: '1234567890',            // 10 digits, starts with 1 or 2
        ))
        ->addItem(Item::make(
            name: 'Data Science Diploma',
            quantity: 1,
            totalCost: 3500.00,
            referenceId: 'course_456',
            unitPrice: 3500.00,
            entityId: 'uuid-of-entity',          // required for multi-entity groups
        ))
        ->urls(Urls::make(
            redirectUrl: route('checkout.return'),
            notificationUrl: route('jeelpay.webhook'),
        ))
        ->referenceId('order_1234')
        ->metadata(['student_id' => 'STU-001']),
);

// $result->idempotencyKey is auto-generated. Save it before redirecting if you
// want safe retries.
return redirect($result->redirectUrl);

Schooling checkout (K-12)

use DigitalTunnel\JeelPay\Requests\SchoolingCheckoutRequest;
use DigitalTunnel\JeelPay\ValueObjects\Student;

// Get the active educational year IDs (auth handled for you)
$years = JeelPay::public()->educationalYears();

$result = JeelPay::checkouts()->createSchooling(
    SchoolingCheckoutRequest::make()
        ->buyer(Buyer::make('Zayed', 'Al-Abbad', '512345678'))
        ->addStudent(Student::make(
            name: 'Nasser Al-jbreen',
            nationalId: '2098765432',
            entityId: 'uuid-of-school-entity',
            educationalYearId: $years[0]->id,
            cost: 8500.00,
            referenceId: 'student_grade5_001',
        ))
        ->urls(Urls::make(
            redirectUrl: route('checkout.return'),
            notificationUrl: route('jeelpay.webhook'),
        ))
        ->referenceId('enrollment_2026_0012')
        ->metadata(['grade' => '5', 'academic_year' => '2026-2027']),
);

return redirect($result->redirectUrl);

Idempotent retries

If your network call timed out, retry with the same idempotency key:

$key = (string) Str::uuid();
// save $key to your order record FIRST so retries can find it

try {
    $result = JeelPay::checkouts()->createItems($request, idempotencyKey: $key);
} catch (\DigitalTunnel\JeelPay\Exceptions\ConnectionException) {
    // Retry with the same key — JeelPay returns the cached response, no duplicate.
    $result = JeelPay::checkouts()->createItems($request, idempotencyKey: $key);
}

Status & refunds

use DigitalTunnel\JeelPay\Enums\RefundStatus;

$status = JeelPay::checkouts()->find('checkout-uuid');

if ($status->isPaid()) {
    // SUCCEEDED — buyer paid down payment, installment plan created
}

// Submit a refund (plan withdrawal). Only SUCCEEDED checkouts can be refunded.
// amount + reason are required by JeelPay; referenceId is an optional business ref.
$refund = JeelPay::refunds()->submit(
    installmentRequestId: 'checkout-uuid',
    amount: 3500.00,
    reason: 'Customer cancelled enrollment',
    referenceId: 'refund_order_1234',
);

if ($refund->isDone()) {
    // DONE — processed immediately
} elseif ($refund->status === RefundStatus::Pending) {
    // PENDING — under review; poll later
}

// Poll status later by the withdrawal id ($refund->id)
$refund = JeelPay::refunds()->find($refund->id);

if ($refund->isRejected()) {
    Log::warning('Refund rejected', ['reason' => $refund->rejectionReason]);
}

Webhooks

The package auto-registers POST {config.webhook.path} (default webhooks/jeelpay) outside the web middleware group, so CSRF is never applied.

Listen for typed events:

use DigitalTunnel\JeelPay\Events\CheckoutSucceeded;
use DigitalTunnel\JeelPay\Events\CheckoutRejected;
use DigitalTunnel\JeelPay\Events\CheckoutExpired;

Event::listen(CheckoutSucceeded::class, function (CheckoutSucceeded $event) {
    // $event->payload is a DigitalTunnel\JeelPay\DTOs\WebhookPayload
    $order = Order::firstWhere('jeelpay_checkout_id', $event->payload->checkoutId);
    $order->markPaid();
});

Available events:

  • CheckoutPending · CheckoutSucceeded · CheckoutRejected · CheckoutExpired
  • WebhookReceived (catch-all, fires for every webhook)

Signature verification

The middleware computes base64(HMAC-SHA256(client_secret, raw_body)) over the raw request body and compares it (timing-safe) against the X-Jeel-Signature header. The raw body is preserved through $request->getContent() — never re-serialise the JSON before checking.

Idempotent de-duplication

When jeelpay.persistence.webhook_events.enabled is true (default), the controller stores every webhook keyed by a fingerprint of (checkout_id|status|raw_body). If JeelPay retries the exact same event, no duplicate event is dispatched.

Disabling auto-registration

JEELPAY_WEBHOOK_ENABLED=false

Then register your own route:

Route::post('my-custom-path', \DigitalTunnel\JeelPay\Http\Controllers\WebhookController::class)
    ->middleware([\DigitalTunnel\JeelPay\Http\Middleware\VerifyWebhookSignature::class])
    ->name('my.webhook');

Persistence (opt-in)

Three tables ship via --tag=jeelpay-migrations. Each layer has an independent enable flag.

jeelpay_webhook_events

Fingerprint-based audit + idempotency log. Auto-written by the controller when JEELPAY_LOG_WEBHOOK_EVENTS=true.

jeelpay_checkouts

Local mirror of every checkout (created by createItems()/createSchooling()/find() and updated by webhooks). Enable with JEELPAY_PERSIST_CHECKOUTS=true. Polymorphic payable_* columns let you link a checkout to any of your domain models.

use DigitalTunnel\JeelPay\Models\JeelPayCheckout;

$checkout = JeelPayCheckout::query()->where('checkout_id', $id)->first();
$checkout->payable()->associate($order)->save();
$checkout->statusEnum();        // CheckoutStatus::Succeeded
$checkout->isPaid();

jeelpay_refunds

Mirror of every refund call. Enable with JEELPAY_PERSIST_REFUNDS=true.

Token caching

The OAuth endpoint is rate-limited. The package caches tokens in your default Laravel cache store (override with JEELPAY_CACHE_STORE) under the key jeelpay.access_token, refreshing 30 seconds before expiry. Tokens are also memoised per request to avoid re-deserialising on every call.

JeelPay::auth()->token();      // returns DTOs\AccessToken
JeelPay::auth()->refresh();    // force re-mint (after a 401)
JeelPay::auth()->forget();     // clear cache + memoised

Exception handling

use DigitalTunnel\JeelPay\Exceptions\ValidationException;
use DigitalTunnel\JeelPay\Exceptions\AuthenticationException;
use DigitalTunnel\JeelPay\Exceptions\IdempotencyConflictException;
use DigitalTunnel\JeelPay\Exceptions\ConnectionException;

try {
    JeelPay::checkouts()->createItems($request);
} catch (ValidationException $e) {
    // 400 — JeelPay-native errors:  $e->errors()  /  $e->firstErrorMessage()
    Log::warning('JeelPay validation', [
        'tx_id' => $e->txId(),
        'errors' => $e->errors(),
    ]);
} catch (IdempotencyConflictException $e) {
    // IDEMPOTENCY-001 — concurrent duplicate; wait and retry
} catch (AuthenticationException $e) {
    // 401 — credentials issue
} catch (ConnectionException $e) {
    // network/timeout — safe to retry with the same idempotency key
}

The full hierarchy:

JeelPayException                       (base — RuntimeException)
├── ApiException                       (any non-2xx — carries statusCode, txId, errors)
│   ├── AuthenticationException        (401)
│   ├── ValidationException            (400/422)
│   ├── NotFoundException              (404)
│   ├── ServerException                (5xx)
│   └── IdempotencyConflictException   (IDEMPOTENCY-001)
├── ConnectionException                (network/timeout)
└── InvalidWebhookSignatureException   (signature mismatch)

Every ApiException exposes txId() — include it in your support tickets.

Configuration reference

Every option in config/jeelpay.php is environment-driven. The full list:

Env var Default Purpose
JEELPAY_ENV sandbox sandbox or production — selects the base URLs below.
JEELPAY_CLIENT_ID OAuth client id (required).
JEELPAY_CLIENT_SECRET OAuth client secret (required). Also used to verify webhook signatures.
JEELPAY_API_URL per-env Override the API host (e.g. a local proxy/mock).
JEELPAY_AUTH_URL per-env Override the OAuth host.
JEELPAY_TIMEOUT 30 HTTP timeout in seconds (auth + API).
JEELPAY_RETRY_TIMES 0 Automatic retries on transient HTTP failures.
JEELPAY_RETRY_SLEEP_MS 200 Delay between retries (ms).
JEELPAY_CACHE_STORE default store Cache store for the access token (redis recommended in prod).
JEELPAY_CACHE_KEY jeelpay.access_token Token cache key.
JEELPAY_TOKEN_REFRESH_BUFFER 30 Seconds before expiry to proactively refresh.
JEELPAY_REDIRECT_URL Default redirect_url fallback for checkouts.
JEELPAY_NOTIFICATION_URL Default notification_url fallback for checkouts.
JEELPAY_WEBHOOK_ENABLED true Auto-register the webhook route.
JEELPAY_WEBHOOK_PATH webhooks/jeelpay Webhook route path.
JEELPAY_WEBHOOK_NAME jeelpay.webhook Webhook route name.
JEELPAY_LOG_WEBHOOK_EVENTS true Persist + de-dupe webhooks in jeelpay_webhook_events.
JEELPAY_PERSIST_CHECKOUTS false Mirror checkouts into jeelpay_checkouts.
JEELPAY_PERSIST_REFUNDS false Mirror refunds into jeelpay_refunds.

The base URLs (sandbox → production):

API host Auth host
Sandbox https://api.sandbox.jeel.co https://auth.sandbox.jeel.co
Production https://api.jeel.co https://auth.jeel.co

Statuses, enums & DTOs

CheckoutStatus (->find()->status, webhook $payload->status):

Case Value Meaning
Pending PENDING Created, awaiting buyer action.
Succeeded SUCCEEDED Down payment paid, installment plan created.
Rejected REJECTED Rejected / cancelled / not eligible.
Expired EXPIRED No action for 2 hours.

Helpers: $status->isTerminal(), $status->isPaid().

RefundStatus ($refund->status):

Case Value Meaning
Pending PENDING Under review.
Done DONE Successfully processed.
Rejected REJECTED Rejected (see $refund->rejectionReason).

Helpers: $status->isTerminal(), $status->isDone(), $status->isRejected().

Result DTOs (all readonly):

  • CheckoutResultcheckoutId, redirectUrl, type, idempotencyKey, referenceId, txId, metadata, raw
  • CheckoutStatusResultcheckoutId, status, type, referenceId, txId, metadata, raw + isPaid()/isPending()/isExpired()/isRejected()
  • RefundResultid (withdrawalRequestId), status, rejectionReason, referenceId, checkoutId, txId, raw + isDone()/isRejected()
  • WebhookPayloadcheckoutId, status, type, referenceId, metadata, rawBody, raw

Testing

The package ships JeelPayFake to stub every JeelPay HTTP call (auth, checkouts, refunds) and to build signed webhooks — no network access needed.

use DigitalTunnel\JeelPay\Testing\JeelPayFake;

it('creates an items checkout', function () {
    JeelPayFake::fakeAuth();
    JeelPayFake::fakeItemsCheckoutCreated();

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

    JeelPayFake::assertSentTo('https://api.sandbox.jeel.co/v3/checkout');
});

it('processes a refund', function () {
    JeelPayFake::fakeAuth();
    JeelPayFake::fakeRefundSubmitted(status: 'DONE');

    $refund = JeelPay::refunds()->submit('chk_x', 3500.00, 'Cancelled');

    expect($refund->isDone())->toBeTrue();
});

it('handles a SUCCEEDED webhook', function () {
    $signed = JeelPayFake::signedWebhook(
        secret: config('jeelpay.client_secret'),
        checkoutId: 'chk_x',
        status: 'SUCCEEDED',
    );

    $this->call(
        method: 'POST',
        uri: route('jeelpay.webhook'),
        server: $this->transformHeadersToServerVars($signed['headers']),
        content: $signed['body'],
    )->assertNoContent();
});

Available JeelPayFake helpers: fakeAuth(), fakeItemsCheckoutCreated(), fakeSchoolingCheckoutCreated(), fakeRefundSubmitted(), fakeRefundStatus(), fakeValidationError(), sign(), signedWebhook(), assertSentTo(), assertNothingSent(), assertSentCount().

Contributing & quality gate

composer install

composer test        # run the Pest suite
composer lint        # auto-fix code style (Laravel Pint)
composer lint:test   # check style without writing
composer analyse     # PHPStan / Larastan, level 6
composer check       # lint:test + analyse + test (CI gate)

The suite runs against an in-memory SQLite database via Orchestra Testbench — no setup required.

Sandbox info

  • API: https://api.sandbox.jeel.co
  • Auth: https://auth.sandbox.jeel.co
  • Test card: 4111 1111 1111 1111, exp 05/30, CVV 123, 3DS code Checkout1!
  • Test buyer phone: any valid Saudi mobile (e.g. 512345678), OTP 3333, passcode 100000

License

MIT.