diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..5b76aba
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,64 @@
+name: CI
+
+on:
+ push:
+ branches: ["main", "master"]
+ pull_request:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ['8.1', '8.2', '8.3', '8.4', '8.5']
+ dependency-version: ['prefer-stable', 'prefer-lowest']
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ coverage: none
+ tools: composer:v2
+ extensions: mbstring, json
+
+ - name: Validate composer.json
+ run: composer validate --strict
+
+ - name: Get Composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
+
+ - name: Cache Composer dependencies
+ uses: actions/cache@v4
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ matrix.dependency-version }}-${{ hashFiles('**/composer.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-composer-${{ matrix.php }}-
+
+ - name: Install dependencies
+ run: |
+ composer update --${{ matrix.dependency-version }} \
+ --no-interaction \
+ --prefer-dist \
+ --no-progress
+
+ - name: Run PHPUnit
+ run: composer test
+
+ - name: Run PHPStan
+ if: matrix.dependency-version == 'prefer-stable'
+ run: composer stan
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
deleted file mode 100644
index f34f686..0000000
--- a/.github/workflows/tests.yml
+++ /dev/null
@@ -1,36 +0,0 @@
-name: Tests
-
-on:
- pull_request:
- types: [labeled, ready_for_review]
-
-permissions:
- contents: read
-
-jobs:
- tests:
- if: contains(github.event.pull_request.labels.*.name, 'ready for review') || github.event.action == 'ready_for_review'
- runs-on: ubuntu-latest
- strategy:
- fail-fast: true
- matrix:
- php: [5.6, 7.4, 8.3]
-
- name: PHP ${{ matrix.php }}
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Setup PHP
- uses: shivammathur/setup-php@v2
- with:
- php-version: ${{ matrix.php }}
- extensions: json, dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite
- coverage: none
-
- - name: Install Composer dependencies
- run: composer install --prefer-dist --no-interaction --no-progress
-
- - name: Run Tests
- run: composer test
diff --git a/.gitignore b/.gitignore
index 0f28c30..e0b9a47 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,9 @@
-/vendor
+/vendor/
+/bin/
composer.lock
composer.phar
-phpunit.xml
-php-cs-fixer.phar
-phpcbf.phar
-phpcs.phar
-.phpunit.result.cache
\ No newline at end of file
+.phpunit.cache/
+.phpunit.result.cache
+coverage/
+*.cache
+.DS_Store
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 5a4efdf..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,34 +0,0 @@
-language: php
-
-php:
- - 5.6
- - 7.0
- - 7.1
- - 7.2
- - 7.3
- - 7.4
- - 8.0
- - 8.1
- - 8.2
- - 8.3
- - 8.4
-
-env:
- global:
- - setup=basic
-
-matrix:
- include:
- - php: 5.6
- env: setup=lowest
-
-sudo: false
-
-before_install:
- - travis_retry composer self-update
-
-install:
- - if [[ $setup = 'basic' ]]; then travis_retry composer install --no-interaction --prefer-dist; fi
- - if [[ $setup = 'lowest' ]]; then travis_retry composer update --prefer-dist --no-interaction --prefer-lowest --prefer-stable; fi
-
-script: vendor/bin/phpcs --exclude=Generic.Files.LineLength src/ --standard=PSR2 src && vendor/bin/phpunit --coverage-text
diff --git a/README.md b/README.md
index a86fa45..7c5bfe5 100644
--- a/README.md
+++ b/README.md
@@ -1,127 +1,536 @@
-# Omnipay: eSewa
+# EsewaPayment PHP SDK
+
+Framework-agnostic eSewa ePay v2 SDK for modern PHP applications.
+
+[](https://github.com/sudiptpa/esewa/actions/workflows/ci.yml)
+[](https://packagist.org/packages/sudiptpa/esewa-payment)
+[](https://packagist.org/packages/sudiptpa/esewa-payment/stats)
+[](https://packagist.org/packages/sudiptpa/esewa-payment)
+[](LICENSE)
+
+## Highlights
+
+- ePay v2 checkout intent generation (`/v2/form`)
+- HMAC-SHA256 + base64 signature generation and verification
+- Callback verification with anti-fraud field consistency checks
+- Status check API support with typed status mapping
+- Model-first request/payload/result objects
+- PSR-18 transport integration
+- PHP `8.1` to `8.5` support
+- PHPUnit + PHPStan + CI matrix
+
+## Table of Contents
+
+- [Installation](#installation)
+- [Quick Start](#quick-start)
+- [Core API Shape](#core-api-shape)
+- [Checkout Flow](#checkout-flow)
+- [Callback Verification Flow](#callback-verification-flow)
+- [Transaction Status Flow](#transaction-status-flow)
+- [Configuration Patterns](#configuration-patterns)
+- [Production Hardening](#production-hardening)
+- [Laravel Integration (Secure)](#laravel-integration-secure)
+- [Custom Transport and Testing](#custom-transport-and-testing)
+- [Error Handling](#error-handling)
+- [Development](#development)
-**eSewa driver for the Omnipay PHP payment processing library**
+## Installation
-[Omnipay](https://github.com/thephpleague/omnipay) is a framework agnostic, multi-gateway payment
-processing library for PHP. This package implements eSewa support for Omnipay.
+```bash
+composer require sudiptpa/esewa-payment
+```
-[](https://github.styleci.io/repos/75586885)
-[](https://packagist.org/packages/sudiptpa/omnipay-esewa)
-[](https://packagist.org/packages/sudiptpa/omnipay-esewa)
-[](https://raw.githubusercontent.com/sudiptpa/esewa/master/LICENSE)
+For PSR-18 usage examples:
-## Installation
+```bash
+composer require symfony/http-client nyholm/psr7
+```
-Omnipay is installed via [Composer](http://getcomposer.org/). To install, simply require `league/omnipay` and `sudiptpa/omnipay-esewa` with Composer:
+## Quick Start
+```php
+checkout()`
+- `$client->callbacks()`
+- `$client->transactions()`
+
+Primary model objects:
-## Basic Usage
+- `CheckoutRequest`
+- `CheckoutIntent`
+- `CallbackPayload`
+- `VerificationExpectation`
+- `CallbackVerification`
+- `TransactionStatusRequest`
+- `TransactionStatus`
-### Purchase
+Static convenience entry point:
```php
- use Omnipay\Omnipay;
- use Exception;
+use EsewaPayment\EsewaPayment;
- $gateway = Omnipay::create('Esewa_Secure');
+$client = EsewaPayment::make(
+ merchantCode: 'EPAYTEST',
+ secretKey: 'secret',
+ transport: $transport,
+);
+```
- $gateway->setMerchantCode('epay_payment');
- $gateway->setSecretKey('secret_key_provided_by_esewa');
- $gateway->setTestMode(true);
+## Checkout Flow
- try {
- $response = $gateway->purchase([
- 'amount' => 100,
- 'deliveryCharge' => 0,
- 'serviceCharge' => 0,
- 'taxAmount' => 0,
- 'totalAmount' => 100,
- 'productCode' => 'ABAC2098',
- 'returnUrl' => 'https://merchant.com/payment/1/complete',
- 'failedUrl' => 'https://merchant.com/payment/1/failed',
- ])->send();
+### 1) Build a checkout intent
- if ($response->isRedirect()) {
- $response->redirect();
- }
- } catch (Exception $e) {
- return $e->getMessage();
- }
+```php
+use EsewaPayment\Domain\Checkout\CheckoutRequest;
+
+$intent = $client->checkout()->createIntent(new CheckoutRequest(
+ amount: '100',
+ taxAmount: '0',
+ serviceCharge: '0',
+ deliveryCharge: '0',
+ transactionUuid: 'TXN-1001',
+ productCode: 'EPAYTEST',
+ successUrl: 'https://merchant.example.com/esewa/success',
+ failureUrl: 'https://merchant.example.com/esewa/failure',
+));
+```
+
+### 2) Render form fields in plain PHP
+
+```php
+$form = $intent->form();
+
+echo '
';
+```
+
+### 3) Get fields directly as array
+
+```php
+$fields = $intent->fields(); // array
+```
+
+## Callback Verification Flow
+
+Never trust redirect success alone. Always verify callback payload and signature.
+
+### 1) Build payload from callback request
+
+```php
+use EsewaPayment\Domain\Verification\CallbackPayload;
+
+$payload = CallbackPayload::fromArray([
+ 'data' => $_GET['data'] ?? '',
+ 'signature' => $_GET['signature'] ?? '',
+]);
+```
+
+### 2) Verify with anti-fraud expectation context
+
+```php
+use EsewaPayment\Domain\Verification\VerificationExpectation;
+
+$verification = $client->callbacks()->verifyCallback(
+ $payload,
+ new VerificationExpectation(
+ totalAmount: '100.00',
+ transactionUuid: 'TXN-1001',
+ productCode: 'EPAYTEST',
+ referenceId: null, // optional
+ )
+);
+
+if (!$verification->valid || !$verification->isSuccessful()) {
+ // reject
+}
```
-After successful payment and redirect back to merchant site, you need to verify the payment response.
+### 3) Verify without context (signature only)
-### Verify Payment
+```php
+$verification = $client->callbacks()->verifyCallback($payload);
+```
+
+## Transaction Status Flow
```php
- $gateway = Omnipay::create('Esewa_Secure');
+use EsewaPayment\Domain\Transaction\TransactionStatusRequest;
- $gateway->setMerchantCode('epay_payment');
- $gateway->setSecretKey('secret_key_provided_by_esewa');
- $gateway->setTestMode(true);
+$status = $client->transactions()->fetchStatus(new TransactionStatusRequest(
+ transactionUuid: 'TXN-1001',
+ totalAmount: '100.00',
+ productCode: 'EPAYTEST',
+));
- $payload = json_decode(base64_decode($_GET['data']), true);
+if ($status->isSuccessful()) {
+ // COMPLETE
+}
- $signature = $gateway->generateSignature(generateSignature($payload));
- if ($signature === $payload['signature']) {
- // Verified
- } else {
- // Unverified
+echo $status->status->value; // PENDING|COMPLETE|FULL_REFUND|PARTIAL_REFUND|AMBIGUOUS|NOT_FOUND|CANCELED|UNKNOWN
+```
+
+## Configuration Patterns
+
+### Environment aliases
+
+- UAT: `uat`, `test`, `sandbox`
+- Production: `production`, `prod`, `live`
+
+### Endpoint overrides
+
+Useful if eSewa documentation/endpoints differ by account region or rollout:
+
+```php
+$config = GatewayConfig::make(
+ merchantCode: 'EPAYTEST',
+ secretKey: 'secret',
+ environment: 'uat',
+ checkoutFormUrl: 'https://custom-esewa.example/api/epay/main/v2/form',
+ statusCheckUrl: 'https://custom-esewa.example/api/epay/transaction/status/',
+);
+```
+
+## Production Hardening
+
+### Retry policy for status checks
+
+`fetchStatus()` retries `TransportException` failures based on `ClientOptions`:
+
+```php
+new ClientOptions(
+ maxStatusRetries: 2, // total additional retry attempts
+ statusRetryDelayMs: 150,
+);
+```
+
+### Callback replay protection (idempotency)
+
+Enable replay protection with an idempotency store:
+
+```php
+use EsewaPayment\Config\ClientOptions;
+use EsewaPayment\Infrastructure\Idempotency\InMemoryIdempotencyStore;
+
+$options = new ClientOptions(
+ preventCallbackReplay: true,
+ idempotencyStore: new InMemoryIdempotencyStore(),
+);
+```
+
+For production, implement `IdempotencyStoreInterface` with shared storage (Redis, DB, etc.) instead of in-memory storage.
+
+### Logging hooks
+
+Provide any PSR-3 logger in `ClientOptions`:
+
+```php
+use Psr\Log\LoggerInterface;
+
+$options = new ClientOptions(logger: $logger); // $logger is LoggerInterface
+```
+
+Emitted event keys (via log context):
+
+- `esewa.status.started`
+- `esewa.status.retry`
+- `esewa.status.completed`
+- `esewa.status.failed`
+- `esewa.callback.invalid_signature`
+- `esewa.callback.replay_detected`
+- `esewa.callback.verified`
+
+## Laravel Integration (Secure)
+
+### Supported Laravel Versions
+
+This package is framework-agnostic and supports PHP `8.1` to `8.5`.
+
+Laravel support is therefore:
+
+- Laravel `10.x`, `11.x`, `12.x` when your app runtime is PHP `8.1` to `8.5`
+- Other Laravel versions may work if they run on supported PHP and PSR dependencies, but are not the primary target matrix
+
+### 1) Service container binding (single client, production options)
+
+Create a provider (for example `app/Providers/EsewaServiceProvider.php`):
+
+```php
+use EsewaPayment\Client\EsewaClient;
+use EsewaPayment\Config\ClientOptions;
+use EsewaPayment\Config\GatewayConfig;
+use EsewaPayment\Contracts\IdempotencyStoreInterface;
+use EsewaPayment\Infrastructure\Idempotency\InMemoryIdempotencyStore;
+use EsewaPayment\Infrastructure\Transport\Psr18Transport;
+use Nyholm\Psr7\Factory\Psr17Factory;
+use Symfony\Component\HttpClient\Psr18Client;
+
+$this->app->singleton(IdempotencyStoreInterface::class, function () {
+ // Replace with Redis/DB-backed implementation for multi-server production.
+ return new InMemoryIdempotencyStore();
+});
+
+$this->app->singleton(EsewaClient::class, function ($app) {
+ return new EsewaClient(
+ GatewayConfig::make(
+ merchantCode: config('services.esewa.merchant_code'),
+ secretKey: config('services.esewa.secret_key'),
+ environment: config('services.esewa.environment', 'uat'),
+ ),
+ new Psr18Transport(new Psr18Client(), new Psr17Factory()),
+ new ClientOptions(
+ maxStatusRetries: 2,
+ statusRetryDelayMs: 150,
+ preventCallbackReplay: true,
+ idempotencyStore: $app->make(IdempotencyStoreInterface::class),
+ logger: $app->make(\Psr\Log\LoggerInterface::class),
+ ),
+ );
+});
+```
+
+### 2) Route design (do not trust success redirect)
+
+Use separate callback verification endpoint and finalize order only after verification:
+
+```php
+use App\Http\Controllers\EsewaCallbackController;
+use App\Http\Controllers\EsewaCheckoutController;
+use Illuminate\Support\Facades\Route;
+
+Route::post('/payments/esewa/checkout', [EsewaCheckoutController::class, 'store'])
+ ->name('payments.esewa.checkout');
+
+Route::get('/payments/esewa/success', [EsewaCheckoutController::class, 'success'])
+ ->name('payments.esewa.success');
+
+Route::get('/payments/esewa/failure', [EsewaCheckoutController::class, 'failure'])
+ ->name('payments.esewa.failure');
+
+Route::post('/payments/esewa/callback', [EsewaCallbackController::class, 'handle'])
+ ->name('payments.esewa.callback');
+```
+
+### 3) Checkout controller (server-side source of truth)
+
+```php
+use EsewaPayment\Client\EsewaClient;
+use EsewaPayment\Domain\Checkout\CheckoutRequest;
+use Illuminate\Http\Request;
+
+final class EsewaCheckoutController
+{
+ public function store(Request $request, EsewaClient $esewa)
+ {
+ $order = /* create order in DB and generate transaction UUID */;
+
+ $intent = $esewa->checkout()->createIntent(new CheckoutRequest(
+ amount: (string) $order->amount,
+ taxAmount: '0',
+ serviceCharge: '0',
+ deliveryCharge: '0',
+ transactionUuid: $order->transaction_uuid,
+ productCode: config('services.esewa.merchant_code'),
+ successUrl: route('payments.esewa.success'),
+ failureUrl: route('payments.esewa.failure'),
+ ));
+
+ return view('payments.esewa.redirect', [
+ 'action' => $intent->actionUrl,
+ 'fields' => $intent->fields(),
+ ]);
}
+}
```
-You can also check the status of payment if no any response is received when redirected back to merchant's site.
+`resources/views/payments/esewa/redirect.blade.php`:
+
+```blade
+
+
+
+```
-### Check Status
+### 4) Callback controller with strict verification
```php
- $gateway = Omnipay::create('Esewa_Secure');
+use EsewaPayment\Client\EsewaClient;
+use EsewaPayment\Domain\Verification\CallbackPayload;
+use EsewaPayment\Domain\Verification\VerificationExpectation;
+use Illuminate\Http\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+final class EsewaCallbackController
+{
+ public function handle(Request $request, EsewaClient $esewa): Response
+ {
+ $payload = CallbackPayload::fromArray([
+ 'data' => (string) $request->input('data', ''),
+ 'signature' => (string) $request->input('signature', ''),
+ ]);
+
+ $order = /* lookup order using decoded transaction UUID */;
+ $expectation = new VerificationExpectation(
+ totalAmount: number_format((float) $order->payable_amount, 2, '.', ''),
+ transactionUuid: $order->transaction_uuid,
+ productCode: config('services.esewa.merchant_code'),
+ referenceId: null,
+ );
+
+ $result = $esewa->callbacks()->verifyCallback($payload, $expectation);
+
+ if (!$result->isSuccessful()) {
+ return response('invalid', 400);
+ }
- $gateway->setMerchantCode('epay_payment');
- $gateway->setSecretKey('secret_key_provided_by_esewa');
- $gateway->setTestMode(true);
+ // Optional: double-check status endpoint before marking paid.
+ // $status = $esewa->transactions()->fetchStatus(...);
- $payload = [
- 'totalAmount' => 100,
- 'productCode' => 'ABAC2098',
- ];
+ // Mark paid exactly once (idempotent DB update).
+ // dispatch(new FulfillOrderJob($order->id));
- $response = $gateway->checkStatus($payload)->send();
- if ($response->isSuccessful()) {
- // Success
+ return response('ok', 200);
}
+}
```
-## Working Example
+### 5) Security checklist (Laravel)
-Want to see working examples before integrating them into your project? View the examples **[here](https://github.com/pralhadstha/payment-gateways-examples)**
+- Keep `merchant_code` and `secret_key` only in `.env`
+- Never trust only `success` redirect for payment finalization
+- Verify callback signature and anti-fraud fields every time
+- Enforce idempotent order updates in DB and callback processing
+- Log verification failures and replay attempts
+- Queue downstream fulfillment after verified payment
-## Laravel Integration
+### Framework usage outside Laravel
-Please follow the [eSewa Online Payment Gateway Integration](https://sujipthapa.com/blog/esewa-payment-gateway-integration-with-laravel) and follow step by step guidlines.
+- Register `GatewayConfig` as a service parameter object
+- Inject `EsewaClient` into controllers/services
+- Use `checkout`, `callbacks`, and `transactions` modules in your application service layer
-## Official Doc
+## Custom Transport and Testing
-Please follow the [Official Doc](https://developer.esewa.com.np) to understand about the parameters and their descriptions.
+You can use any custom transport implementing `TransportInterface`.
-## Contributing
+```php
+use EsewaPayment\Contracts\TransportInterface;
-Contributions are **welcome** and will be fully **credited**.
+final class FakeTransport implements TransportInterface
+{
+ public function get(string $url, array $query = [], array $headers = []): array
+ {
+ return ['status' => 'COMPLETE', 'ref_id' => 'REF-123'];
+ }
+}
+```
-Contributions can be made via a Pull Request on [Github](https://github.com/sudiptpa/esewa).
+Inject it:
-## Support
+```php
+$client = new EsewaClient($config, new FakeTransport());
+```
-If you are having general issues with Omnipay Esewa, drop an email to sudiptpa@gmail.com for quick support.
+## Error Handling
-If you believe you have found a bug, please report it using the [GitHub issue tracker](https://github.com/sudiptpa/esewa/issues),
-or better yet, fork the library and submit a pull request.
+Key exceptions:
-## License
+- `InvalidPayloadException`
+- `FraudValidationException`
+- `TransportException`
+- `ApiErrorException`
+- Base: `EsewaException`
-This package is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
+Typical handling:
+
+```php
+use EsewaPayment\Exception\ApiErrorException;
+use EsewaPayment\Exception\EsewaException;
+use EsewaPayment\Exception\FraudValidationException;
+use EsewaPayment\Exception\InvalidPayloadException;
+use EsewaPayment\Exception\TransportException;
+
+try {
+ $verification = $client->callbacks()->verifyCallback($payload, $expectation);
+} catch (FraudValidationException|InvalidPayloadException $e) {
+ // fail closed
+} catch (TransportException|ApiErrorException $e) {
+ // retry/report policy
+} catch (EsewaException $e) {
+ // domain fallback policy
+}
+```
+
+## Development
+
+```bash
+composer test
+composer stan
+composer rector:check
+```
diff --git a/composer.json b/composer.json
index 8b4005b..78ca3cf 100644
--- a/composer.json
+++ b/composer.json
@@ -1,60 +1,56 @@
{
- "name": "sudiptpa/omnipay-esewa",
+ "name": "sudiptpa/esewa-payment",
"type": "library",
- "description": "eSewa API driver for the Omnipay payment processing library.",
+ "description": "Framework-agnostic eSewa ePay v2 payment SDK for PHP.",
"keywords": [
- "gateway",
- "merchant",
- "omnipay",
- "pay",
+ "esewa",
+ "epay",
"payment",
- "esewa"
+ "gateway",
+ "nepal"
],
- "homepage": "https://github.com/sudiptpa/esewa",
"license": "MIT",
"authors": [
{
"name": "Sujip Thapa",
"email": "sudiptpa@gmail.com"
- },
- {
- "name": "Pralhad Kumar Shrestha",
- "email": "pralhad.shrestha05@gmail.com"
}
],
- "autoload": {
- "psr-4": {
- "Omnipay\\Esewa\\": "src/"
- },
- "files": [
- "src/helpers.php"
- ]
- },
"require": {
- "omnipay/common": "^3",
- "symfony/http-client": "^7.2"
+ "php": ">=8.1 <8.6",
+ "psr/http-client": "^1.0",
+ "psr/http-factory": "^1.1",
+ "psr/http-message": "^2.0",
+ "psr/log": "^1.1 || ^2.0 || ^3.0"
},
"require-dev": {
- "omnipay/tests": "^4",
- "squizlabs/php_codesniffer": "^3",
- "phpro/grumphp": "^2.10.0",
- "http-interop/http-factory-guzzle": "^1.2"
+ "phpunit/phpunit": "^10.5 || ^11.0",
+ "phpstan/phpstan": "^1.11",
+ "rector/rector": "^1.2",
+ "symfony/http-client": "^6.4 || ^7.0 || ^8.0",
+ "nyholm/psr7": "^1.8"
+ },
+ "autoload": {
+ "psr-4": {
+ "EsewaPayment\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "EsewaPayment\\Tests\\": "tests/"
+ }
},
"extra": {
"branch-alias": {
- "dev-master": "1.0.x-dev"
+ "dev-master": "2.0.x-dev"
}
},
"scripts": {
- "test": "vendor/bin/phpunit",
- "check-style": "phpcs -p --standard=PSR2 src/",
- "fix-style": "phpcbf -p --standard=PSR2 src/"
+ "test": "phpunit",
+ "stan": "phpstan analyse -c phpstan.neon --debug",
+ "rector": "@php vendor/bin/rector process --config=rector.php",
+ "rector:check": "@php vendor/bin/rector process --config=rector.php --dry-run"
},
- "prefer-stable": true,
- "config": {
- "allow-plugins": {
- "php-http/discovery": true,
- "phpro/grumphp": true
- }
- }
+ "minimum-stability": "stable",
+ "prefer-stable": true
}
diff --git a/demo.php b/demo.php
deleted file mode 100644
index 62839a7..0000000
--- a/demo.php
+++ /dev/null
@@ -1,39 +0,0 @@
-setMerchantCode('epay_payment');
-$gateway->setTestMode(true);
-
-$response = $gateway->purchase([
- 'amount' => 100,
- 'deliveryCharge' => 0,
- 'serviceCharge' => 0,
- 'taxAmount' => 0,
- 'totalAmount' => 100,
- 'productCode' => 'ABAC2098',
- 'returnUrl' => 'https://merchant.com/payment/1/complete',
- 'failedUrl' => 'https://merchant.com/payment/1/failed',
-])->send();
-
-if ($response->isRedirect()) {
- $response->redirect();
-}
-
-// Verify Payment
-
-$response = $gateway->verifyPayment([
- 'amount' => 100,
- 'referenceNumber' => 'GDFG89',
- 'productCode' => 'ABAC2098',
-])->send();
-
-if ($response->isSuccessful()) {
- // Success
-}
-
-// Failed
diff --git a/docs/README-v1.md b/docs/README-v1.md
deleted file mode 100644
index 43100f0..0000000
--- a/docs/README-v1.md
+++ /dev/null
@@ -1,100 +0,0 @@
-# Omnipay: eSewa
-
-**eSewa driver for the Omnipay PHP payment processing library**
-
-[Omnipay](https://github.com/thephpleague/omnipay) is a framework agnostic, multi-gateway payment
-processing library for PHP. This package implements eSewa support for Omnipay.
-
-[](https://github.styleci.io/repos/75586885)
-[](https://packagist.org/packages/sudiptpa/omnipay-esewa)
-[](https://packagist.org/packages/sudiptpa/omnipay-esewa)
-[](https://raw.githubusercontent.com/sudiptpa/esewa/master/LICENSE)
-
-## Installation
-
-Omnipay is installed via [Composer](http://getcomposer.org/). To install, simply require `league/omnipay` and `sudiptpa/omnipay-esewa` with Composer:
-
-```
-composer require league/omnipay sudiptpa/omnipay-esewa
-```
-
-## Basic Usage
-
-### Purchase
-
-```php
- use Omnipay\Omnipay;
- use Exception;
-
- $gateway = Omnipay::create('Esewa_Secure');
-
- $gateway->setMerchantCode('epay_payment');
- $gateway->setTestMode(true);
-
- try {
- $response = $gateway->purchase([
- 'amount' => 100,
- 'deliveryCharge' => 0,
- 'serviceCharge' => 0,
- 'taxAmount' => 0,
- 'totalAmount' => 100,
- 'productCode' => 'ABAC2098',
- 'returnUrl' => 'https://merchant.com/payment/1/complete',
- 'failedUrl' => 'https://merchant.com/payment/1/failed',
- ])->send();
-
- if ($response->isRedirect()) {
- $response->redirect();
- }
- } catch (Exception $e) {
- return $e->getMessage();
- }
-```
-
-After successful payment and redirect back to merchant site, you need to now verify the payment with another API request.
-
-### Verify Payment
-
-```php
- $gateway = Omnipay::create('Esewa_Secure');
-
- $gateway->setMerchantCode('epay_payment');
- $gateway->setTestMode(true);
-
- $response = $gateway->verifyPayment([
- 'amount' => 100,
- 'referenceNumber' => 'GDFG89',
- 'productCode' => 'gadfg-gadf',
- ])->send();
-
- if ($response->isSuccessful()) {
- // Success
- }
-
- // Failed
-```
-
-## Laravel Integration
-
-Please follow the [eSewa Online Payment Gateway Integration](https://sujipthapa.com/blog/esewa-online-payment-gateway-integration-with-php) and follow step by step guidlines.
-
-## Official Doc
-
-Please follow the [Official Doc](https://developer.esewa.com.np) to understand about the parameters and their descriptions.
-
-## Contributing
-
-Contributions are **welcome** and will be fully **credited**.
-
-Contributions can be made via a Pull Request on [Github](https://github.com/sudiptpa/esewa).
-
-## Support
-
-If you are having general issues with Omnipay Esewa, drop an email to sudiptpa@gmail.com for quick support.
-
-If you believe you have found a bug, please report it using the [GitHub issue tracker](https://github.com/sudiptpa/esewa/issues),
-or better yet, fork the library and submit a pull request.
-
-## License
-
-This package is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
diff --git a/grumphp.yml b/grumphp.yml
deleted file mode 100644
index bf81fa5..0000000
--- a/grumphp.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-parameters:
- git_dir: .
- bin_dir: vendor/bin
- tasks:
- phpunit:
- config_file: ~
- testsuite: ~
- group: []
- always_execute: false
- phpcs:
- standard: PSR2
- warning_severity: ~
- ignore_patterns:
- - tests/
- triggered_by: [php]
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..d3be392
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,7 @@
+parameters:
+ level: 7
+ paths:
+ - src
+ - tests
+ parallel:
+ maximumNumberOfProcesses: 1
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index a5bafa3..e8ed43a 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -1,21 +1,8 @@
-
-
-
- ./tests/
-
-
-
-
- ./src
-
-
+
+
+
+ tests
+
+
diff --git a/rector.php b/rector.php
new file mode 100644
index 0000000..0fe84fe
--- /dev/null
+++ b/rector.php
@@ -0,0 +1,21 @@
+withPaths([
+ __DIR__.'/src',
+ __DIR__.'/tests',
+ ])
+ ->withPhpVersion(PhpVersion::PHP_83)
+ ->withSets([
+ SetList::CODE_QUALITY,
+ SetList::TYPE_DECLARATION,
+ SetList::DEAD_CODE,
+ LevelSetList::UP_TO_PHP_83,
+ ]);
diff --git a/src/Client/CallbackService.php b/src/Client/CallbackService.php
new file mode 100644
index 0000000..2073d78
--- /dev/null
+++ b/src/Client/CallbackService.php
@@ -0,0 +1,22 @@
+verifier->verify($payload, $context);
+ }
+}
diff --git a/src/Client/CheckoutService.php b/src/Client/CheckoutService.php
new file mode 100644
index 0000000..1fa1311
--- /dev/null
+++ b/src/Client/CheckoutService.php
@@ -0,0 +1,49 @@
+totalAmount();
+ $signature = $this->signatures->generate(
+ $totalAmount,
+ $request->transactionUuid,
+ $request->productCode,
+ $request->signedFieldNames,
+ );
+
+ $payload = new CheckoutPayload(
+ amount: number_format((float) $request->amount, 2, '.', ''),
+ taxAmount: number_format((float) $request->taxAmount, 2, '.', ''),
+ serviceCharge: number_format((float) $request->serviceCharge, 2, '.', ''),
+ deliveryCharge: number_format((float) $request->deliveryCharge, 2, '.', ''),
+ totalAmount: $totalAmount,
+ transactionUuid: $request->transactionUuid,
+ productCode: $request->productCode,
+ successUrl: $request->successUrl,
+ failureUrl: $request->failureUrl,
+ signedFieldNames: $request->signedFieldNames,
+ signature: $signature,
+ );
+
+ return new CheckoutIntent($this->endpoints->checkoutFormUrl($this->config), $payload);
+ }
+}
diff --git a/src/Client/EsewaClient.php b/src/Client/EsewaClient.php
new file mode 100644
index 0000000..b47ece3
--- /dev/null
+++ b/src/Client/EsewaClient.php
@@ -0,0 +1,49 @@
+secretKey);
+
+ $this->checkout = new CheckoutService($config, $endpoints, $signatures);
+ $this->callback = new CallbackService(new CallbackVerifier($signatures, $options));
+ $this->transactions = new TransactionService($config, $endpoints, $transport, $options);
+ }
+
+ public function checkout(): CheckoutService
+ {
+ return $this->checkout;
+ }
+
+ public function callbacks(): CallbackService
+ {
+ return $this->callback;
+ }
+
+ public function transactions(): TransactionService
+ {
+ return $this->transactions;
+ }
+}
diff --git a/src/Client/TransactionService.php b/src/Client/TransactionService.php
new file mode 100644
index 0000000..b332365
--- /dev/null
+++ b/src/Client/TransactionService.php
@@ -0,0 +1,83 @@
+options->logger->info('eSewa status check started.', [
+ 'event' => 'esewa.status.started',
+ 'transaction_uuid' => $query->transactionUuid,
+ ]);
+
+ $attempt = 0;
+
+ while (true) {
+ try {
+ $payload = $this->transport->get(
+ $this->endpoints->statusCheckUrl($this->config),
+ [
+ 'product_code' => $query->productCode,
+ 'total_amount' => $query->totalAmount,
+ 'transaction_uuid' => $query->transactionUuid,
+ ]
+ );
+
+ $result = TransactionStatusPayload::fromArray($payload)->toResult();
+
+ $this->options->logger->info('eSewa status check completed.', [
+ 'event' => 'esewa.status.completed',
+ 'transaction_uuid' => $query->transactionUuid,
+ 'status' => $result->status->value,
+ ]);
+
+ return $result;
+ } catch (TransportException $exception) {
+ if ($attempt >= $this->options->maxStatusRetries) {
+ $this->options->logger->error('eSewa status check failed after retries.', [
+ 'event' => 'esewa.status.failed',
+ 'transaction_uuid' => $query->transactionUuid,
+ 'attempt' => $attempt,
+ 'error' => $exception->getMessage(),
+ ]);
+
+ throw $exception;
+ }
+
+ ++$attempt;
+
+ $this->options->logger->warning('eSewa status check retry scheduled.', [
+ 'event' => 'esewa.status.retry',
+ 'transaction_uuid' => $query->transactionUuid,
+ 'attempt' => $attempt,
+ 'max_retries' => $this->options->maxStatusRetries,
+ 'error' => $exception->getMessage(),
+ ]);
+
+ if ($this->options->statusRetryDelayMs > 0) {
+ usleep($this->options->statusRetryDelayMs * 1000);
+ }
+ }
+ }
+ }
+}
diff --git a/src/Config/ClientOptions.php b/src/Config/ClientOptions.php
new file mode 100644
index 0000000..d618054
--- /dev/null
+++ b/src/Config/ClientOptions.php
@@ -0,0 +1,29 @@
+checkoutFormUrl !== null && $config->checkoutFormUrl !== '') {
+ return $config->checkoutFormUrl;
+ }
+
+ return match ($config->environment) {
+ Environment::UAT => 'https://rc-epay.esewa.com.np/api/epay/main/v2/form',
+ Environment::PRODUCTION => 'https://epay.esewa.com.np/api/epay/main/v2/form',
+ };
+ }
+
+ public function statusCheckUrl(GatewayConfig $config): string
+ {
+ if ($config->statusCheckUrl !== null && $config->statusCheckUrl !== '') {
+ return $config->statusCheckUrl;
+ }
+
+ return match ($config->environment) {
+ Environment::UAT => 'https://rc-epay.esewa.com.np/api/epay/transaction/status/',
+ Environment::PRODUCTION => 'https://epay.esewa.com.np/api/epay/transaction/status/',
+ };
+ }
+}
diff --git a/src/Config/Environment.php b/src/Config/Environment.php
new file mode 100644
index 0000000..b373d45
--- /dev/null
+++ b/src/Config/Environment.php
@@ -0,0 +1,20 @@
+ self::UAT,
+ 'production', 'prod', 'live' => self::PRODUCTION,
+ default => throw new \InvalidArgumentException("Unsupported environment: {$value}"),
+ };
+ }
+}
diff --git a/src/Config/GatewayConfig.php b/src/Config/GatewayConfig.php
new file mode 100644
index 0000000..82edcfb
--- /dev/null
+++ b/src/Config/GatewayConfig.php
@@ -0,0 +1,54 @@
+ $config */
+ public static function fromArray(array $config): self
+ {
+ return self::make(
+ merchantCode: (string) ($config['merchant_code'] ?? ''),
+ secretKey: (string) ($config['secret_key'] ?? ''),
+ environment: (string) ($config['environment'] ?? 'uat'),
+ checkoutFormUrl: $config['checkout_form_url'] ?? null,
+ statusCheckUrl: $config['status_check_url'] ?? null,
+ );
+ }
+}
diff --git a/src/Contracts/IdempotencyStoreInterface.php b/src/Contracts/IdempotencyStoreInterface.php
new file mode 100644
index 0000000..fd6842f
--- /dev/null
+++ b/src/Contracts/IdempotencyStoreInterface.php
@@ -0,0 +1,12 @@
+ $query
+ * @param array $headers
+ *
+ * @return array
+ */
+ public function get(string $url, array $query = [], array $headers = []): array;
+}
diff --git a/src/Domain/Checkout/CheckoutIntent.php b/src/Domain/Checkout/CheckoutIntent.php
new file mode 100644
index 0000000..7896ac0
--- /dev/null
+++ b/src/Domain/Checkout/CheckoutIntent.php
@@ -0,0 +1,31 @@
+ */
+ public function fields(): array
+ {
+ return $this->payload->toArray();
+ }
+
+ /**
+ * @return array{action_url:string,fields:array}
+ */
+ public function form(): array
+ {
+ return [
+ 'action_url' => $this->actionUrl,
+ 'fields' => $this->fields(),
+ ];
+ }
+}
diff --git a/src/Domain/Checkout/CheckoutPayload.php b/src/Domain/Checkout/CheckoutPayload.php
new file mode 100644
index 0000000..aad6b1c
--- /dev/null
+++ b/src/Domain/Checkout/CheckoutPayload.php
@@ -0,0 +1,41 @@
+ */
+ public function toArray(): array
+ {
+ return [
+ 'amount' => $this->amount,
+ 'tax_amount' => $this->taxAmount,
+ 'product_service_charge' => $this->serviceCharge,
+ 'product_delivery_charge' => $this->deliveryCharge,
+ 'total_amount' => $this->totalAmount,
+ 'transaction_uuid' => $this->transactionUuid,
+ 'product_code' => $this->productCode,
+ 'success_url' => $this->successUrl,
+ 'failure_url' => $this->failureUrl,
+ 'signed_field_names' => $this->signedFieldNames,
+ 'signature' => $this->signature,
+ ];
+ }
+}
diff --git a/src/Domain/Checkout/CheckoutRequest.php b/src/Domain/Checkout/CheckoutRequest.php
new file mode 100644
index 0000000..1558b1d
--- /dev/null
+++ b/src/Domain/Checkout/CheckoutRequest.php
@@ -0,0 +1,41 @@
+ $transactionUuid,
+ 'productCode' => $productCode,
+ 'successUrl' => $successUrl,
+ 'failureUrl' => $failureUrl,
+ ] as $field => $value) {
+ if ($value === '') {
+ throw new \InvalidArgumentException("{$field} is required.");
+ }
+ }
+ }
+
+ public function totalAmount(): string
+ {
+ $total = (float) $this->amount
+ + (float) $this->taxAmount
+ + (float) $this->serviceCharge
+ + (float) $this->deliveryCharge;
+
+ return number_format($total, 2, '.', '');
+ }
+}
diff --git a/src/Domain/Transaction/PaymentStatus.php b/src/Domain/Transaction/PaymentStatus.php
new file mode 100644
index 0000000..393b8c6
--- /dev/null
+++ b/src/Domain/Transaction/PaymentStatus.php
@@ -0,0 +1,22 @@
+ $raw
+ */
+ public function __construct(
+ public readonly PaymentStatus $status,
+ public readonly ?string $referenceId,
+ public readonly array $raw,
+ ) {
+ }
+
+ public function isSuccessful(): bool
+ {
+ return $this->status === PaymentStatus::COMPLETE;
+ }
+}
diff --git a/src/Domain/Transaction/TransactionStatusPayload.php b/src/Domain/Transaction/TransactionStatusPayload.php
new file mode 100644
index 0000000..112b99b
--- /dev/null
+++ b/src/Domain/Transaction/TransactionStatusPayload.php
@@ -0,0 +1,33 @@
+ $raw
+ */
+ public function __construct(
+ public readonly PaymentStatus $status,
+ public readonly ?string $referenceId,
+ public readonly array $raw,
+ ) {
+ }
+
+ /** @param array $raw */
+ public static function fromArray(array $raw): self
+ {
+ return new self(
+ status: PaymentStatus::fromValue(isset($raw['status']) ? (string) $raw['status'] : null),
+ referenceId: isset($raw['ref_id']) ? (string) $raw['ref_id'] : null,
+ raw: $raw,
+ );
+ }
+
+ public function toResult(): TransactionStatus
+ {
+ return new TransactionStatus($this->status, $this->referenceId, $this->raw);
+ }
+}
diff --git a/src/Domain/Transaction/TransactionStatusRequest.php b/src/Domain/Transaction/TransactionStatusRequest.php
new file mode 100644
index 0000000..c8f5fbe
--- /dev/null
+++ b/src/Domain/Transaction/TransactionStatusRequest.php
@@ -0,0 +1,18 @@
+ $raw
+ */
+ public function __construct(
+ public readonly string $totalAmount,
+ public readonly string $transactionUuid,
+ public readonly string $productCode,
+ public readonly string $signedFieldNames,
+ public readonly PaymentStatus $status,
+ public readonly ?string $transactionCode,
+ public readonly array $raw,
+ ) {
+ }
+
+ /** @param array $data */
+ public static function fromArray(array $data): self
+ {
+ $totalAmount = (string) ($data['total_amount'] ?? '');
+ $transactionUuid = (string) ($data['transaction_uuid'] ?? '');
+ $productCode = (string) ($data['product_code'] ?? '');
+
+ if ($totalAmount === '' || $transactionUuid === '' || $productCode === '') {
+ throw new InvalidPayloadException('Callback data is missing required fields.');
+ }
+
+ return new self(
+ totalAmount: $totalAmount,
+ transactionUuid: $transactionUuid,
+ productCode: $productCode,
+ signedFieldNames: (string) ($data['signed_field_names'] ?? 'total_amount,transaction_uuid,product_code'),
+ status: PaymentStatus::fromValue((string) ($data['status'] ?? null)),
+ transactionCode: isset($data['transaction_code']) ? (string) $data['transaction_code'] : null,
+ raw: $data,
+ );
+ }
+}
diff --git a/src/Domain/Verification/CallbackPayload.php b/src/Domain/Verification/CallbackPayload.php
new file mode 100644
index 0000000..240f731
--- /dev/null
+++ b/src/Domain/Verification/CallbackPayload.php
@@ -0,0 +1,43 @@
+ $payload */
+ public static function fromArray(array $payload): self
+ {
+ return new self(
+ data: (string) ($payload['data'] ?? ''),
+ signature: (string) ($payload['signature'] ?? ''),
+ );
+ }
+
+ public function decodedData(): CallbackData
+ {
+ $decoded = base64_decode($this->data, true);
+ if ($decoded === false) {
+ throw new InvalidPayloadException('Callback data is not valid base64.');
+ }
+
+ $json = json_decode($decoded, true);
+ if (!is_array($json)) {
+ throw new InvalidPayloadException('Callback data is not valid JSON.');
+ }
+
+ return CallbackData::fromArray($json);
+ }
+}
diff --git a/src/Domain/Verification/CallbackVerification.php b/src/Domain/Verification/CallbackVerification.php
new file mode 100644
index 0000000..be7c7f0
--- /dev/null
+++ b/src/Domain/Verification/CallbackVerification.php
@@ -0,0 +1,27 @@
+ $raw
+ */
+ public function __construct(
+ public readonly bool $valid,
+ public readonly PaymentStatus $status,
+ public readonly ?string $referenceId,
+ public readonly string $message,
+ public readonly array $raw,
+ ) {
+ }
+
+ public function isSuccessful(): bool
+ {
+ return $this->valid && $this->status === PaymentStatus::COMPLETE;
+ }
+}
diff --git a/src/Domain/Verification/VerificationExpectation.php b/src/Domain/Verification/VerificationExpectation.php
new file mode 100644
index 0000000..682ed23
--- /dev/null
+++ b/src/Domain/Verification/VerificationExpectation.php
@@ -0,0 +1,16 @@
+getParameter('secretKey');
- }
-
- /**
- * Generates the signature for the message.
- *
- * @param mixed $message
- *
- * @return string
- */
- public function generateSignature($message)
- {
- $signedMessage = hash_hmac('sha256', $message, $this->getSecretKey(), true);
-
- return base64_encode($signedMessage);
- }
-}
diff --git a/src/Infrastructure/Idempotency/InMemoryIdempotencyStore.php b/src/Infrastructure/Idempotency/InMemoryIdempotencyStore.php
new file mode 100644
index 0000000..8eff353
--- /dev/null
+++ b/src/Infrastructure/Idempotency/InMemoryIdempotencyStore.php
@@ -0,0 +1,23 @@
+ */
+ private array $keys = [];
+
+ public function has(string $key): bool
+ {
+ return isset($this->keys[$key]);
+ }
+
+ public function put(string $key): void
+ {
+ $this->keys[$key] = true;
+ }
+}
diff --git a/src/Infrastructure/Idempotency/NullIdempotencyStore.php b/src/Infrastructure/Idempotency/NullIdempotencyStore.php
new file mode 100644
index 0000000..a8782b3
--- /dev/null
+++ b/src/Infrastructure/Idempotency/NullIdempotencyStore.php
@@ -0,0 +1,19 @@
+ $query
+ * @param array $headers
+ *
+ * @return array
+ */
+ public function get(string $url, array $query = [], array $headers = []): array
+ {
+ $fullUrl = $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
+ $request = $this->requests->createRequest('GET', $fullUrl);
+
+ foreach ($headers as $name => $value) {
+ $request = $request->withHeader($name, $value);
+ }
+
+ try {
+ $response = $this->http->sendRequest($request);
+ } catch (\Throwable $e) {
+ throw new TransportException('HTTP request failed: '.$e->getMessage(), 0, $e);
+ }
+
+ if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) {
+ throw new TransportException('Unexpected HTTP status: '.$response->getStatusCode());
+ }
+
+ $decoded = json_decode((string) $response->getBody(), true);
+ if (!is_array($decoded)) {
+ throw new ApiErrorException('Invalid JSON response from eSewa status API.');
+ }
+
+ return $decoded;
+ }
+}
diff --git a/src/Message/AbstractRequest.php b/src/Message/AbstractRequest.php
deleted file mode 100644
index d64bf94..0000000
--- a/src/Message/AbstractRequest.php
+++ /dev/null
@@ -1,205 +0,0 @@
-getParameter('merchantCode');
- }
-
- /**
- * @param $value
- */
- public function setMerchantCode($value)
- {
- return $this->setParameter('merchantCode', $value);
- }
-
- /**
- * @param $value
- */
- public function setTaxAmount($value)
- {
- return $this->setParameter('taxAmount', $value);
- }
-
- /**
- * @return string
- */
- public function getTaxAmount()
- {
- return $this->getParameter('taxAmount');
- }
-
- /**
- * @param $value
- */
- public function setServiceCharge($value)
- {
- return $this->setParameter('serviceCharge', $value);
- }
-
- /**
- * @return string
- */
- public function getServiceCharge()
- {
- return $this->getParameter('serviceCharge');
- }
-
- /**
- * @param $value
- */
- public function setDeliveryCharge($value)
- {
- return $this->setParameter('deliveryCharge', $value);
- }
-
- /**
- * @return string
- */
- public function getDeliveryCharge()
- {
- return $this->getParameter('deliveryCharge');
- }
-
- /**
- * @param $value
- */
- public function setTotalAmount($value)
- {
- return $this->setParameter('totalAmount', $value);
- }
-
- /**
- * @return string
- */
- public function getTotalAmount()
- {
- return $this->getParameter('totalAmount');
- }
-
- /**
- * @param $value
- */
- public function setProductCode($value)
- {
- return $this->setParameter('productCode', $value);
- }
-
- /**
- * @return string
- */
- public function getProductCode()
- {
- return $this->getParameter('productCode');
- }
-
- /**
- * @return string
- */
- public function getFailedUrl()
- {
- return $this->getParameter('failedUrl');
- }
-
- /**
- * @param string $value
- *
- * @return $this
- */
- public function setFailedUrl($value)
- {
- return $this->setParameter('failedUrl', $value);
- }
-
- /**
- * @return string
- */
- public function getReferenceNumber()
- {
- return $this->getParameter('referenceNumber');
- }
-
- /**
- * @param string $value
- *
- * @return $this
- */
- public function setReferenceNumber($value)
- {
- return $this->setParameter('referenceNumber', $value);
- }
-
- /**
- * @param $value
- */
- public function setSignedFieldsName($value)
- {
- return $this->setParameter('signedFields', $value);
- }
-
- /**
- * @return string
- */
- public function getSignedFieldsName()
- {
- return $this->getParameter('signedFields') ?: 'total_amount,transaction_uuid,product_code';
- }
-
- /**
- * @param $value
- */
- public function setSignature($value)
- {
- return $this->setParameter('signature', $this->generateSignature($value));
- }
-
- /**
- * @return string
- */
- public function getSignature()
- {
- if ($signature = $this->getParameter('signature')) {
- return $signature;
- }
-
- $value = http_build_query([
- 'total_amount' => $this->getTotalAmount(),
- 'transaction_uuid' => $this->getProductCode(),
- 'product_code' => $this->getMerchantCode(),
- ], '', ',');
-
- return $this->generateSignature($value);
- }
-
- /**
- * @param $value
- */
- public function setSecretKey($value)
- {
- return $this->setParameter('secretKey', $value);
- }
-}
diff --git a/src/Message/CheckStatusRequest.php b/src/Message/CheckStatusRequest.php
deleted file mode 100644
index 6c0514a..0000000
--- a/src/Message/CheckStatusRequest.php
+++ /dev/null
@@ -1,59 +0,0 @@
-validate('totalAmount', 'productCode');
-
- return [
- 'product_code' => $this->getMerchantCode(),
- 'total_amount' => $this->getTotalAmount(),
- 'transaction_uuid' => $this->getProductCode(),
- ];
- }
-
- /**
- * @param $data
- *
- * @return \Omnipay\Esewa\Message\CheckStatusResponse
- */
- public function sendData($data)
- {
- $httpResponse = $this->httpClient->request(
- 'GET',
- "{$this->getEndpoint()}?".http_build_query($data),
- []
- );
-
- return $this->response = new CheckStatusResponse(
- $this,
- $httpResponse->getBody()->getContents()
- );
- }
-
- /**
- * @return string
- */
- protected function getEndpoint()
- {
- $endPoint = $this->getTestMode() ? $this->testEndpoint : $this->liveEndpoint;
-
- return "{$endPoint}{$this->statusEndPoint}";
- }
-}
diff --git a/src/Message/CheckStatusResponse.php b/src/Message/CheckStatusResponse.php
deleted file mode 100644
index f0846ae..0000000
--- a/src/Message/CheckStatusResponse.php
+++ /dev/null
@@ -1,70 +0,0 @@
-request = $request;
- $this->data = json_decode($data);
- }
-
- /**
- * @return string
- */
- public function getResponseText()
- {
- return (string) trim($this->data->status);
- }
-
- /**
- * @return bool
- */
- public function isSuccessful()
- {
- return $this->checkStatus('complete');
- }
-
- /**
- * @return bool
- */
- public function isPending()
- {
- return $this->checkStatus('pending');
- }
-
- /**
- * Is the transaction cancelled by the user?
- *
- * @return bool
- */
- public function isCancelled()
- {
- return $this->checkStatus('canceled');
- }
-
- /**
- * Extracts status from the response.
- *
- * @param mixed $type
- *
- * @return bool
- */
- public function checkStatus($type)
- {
- $string = strtolower($this->getResponseText());
-
- return in_array($string, [$type]);
- }
-}
diff --git a/src/Message/PurchaseRequest.php b/src/Message/PurchaseRequest.php
deleted file mode 100644
index b3051dd..0000000
--- a/src/Message/PurchaseRequest.php
+++ /dev/null
@@ -1,58 +0,0 @@
-validate('merchantCode', 'amount', 'totalAmount', 'productCode', 'failedUrl', 'returnUrl');
-
- return [
- 'amount' => $this->getAmount(),
- 'tax_amount' => $this->getTaxAmount() ?: 0,
- 'total_amount' => $this->getTotalAmount(),
- 'product_delivery_charge' => $this->getDeliveryCharge() ?: 0,
- 'product_service_charge' => $this->getServiceCharge() ?: 0,
- 'transaction_uuid' => $this->getProductCode(),
- 'product_code' => $this->getMerchantCode(),
- 'success_url' => $this->getReturnUrl(),
- 'failure_url' => $this->getFailedUrl(),
- 'signed_field_names' => $this->getSignedFieldsName(),
- 'signature' => $this->getSignature(),
- ];
- }
-
- /**
- * @param $data
- *
- * @return \Omnipay\Esewa\Message\PurchaseResponse
- */
- public function sendData($data)
- {
- return $this->response = new PurchaseResponse($this, $data, $this->getEndpoint());
- }
-
- /**
- * @return string
- */
- protected function getEndpoint()
- {
- $endPoint = $this->getTestMode() ? $this->testEndpoint : $this->liveEndpoint;
-
- return "{$endPoint}{$this->purchaseEndPoint}";
- }
-}
diff --git a/src/Message/PurchaseResponse.php b/src/Message/PurchaseResponse.php
deleted file mode 100644
index accd6b2..0000000
--- a/src/Message/PurchaseResponse.php
+++ /dev/null
@@ -1,70 +0,0 @@
-request = $request;
- $this->data = $data;
- $this->redirectUrl = $redirectUrl;
- }
-
- /**
- * @return bool
- */
- public function isSuccessful()
- {
- return false;
- }
-
- /**
- * @return bool
- */
- public function isRedirect()
- {
- return true;
- }
-
- /**
- * @return string
- */
- public function getRedirectUrl()
- {
- return $this->redirectUrl;
- }
-
- /**
- * @return string
- */
- public function getRedirectMethod()
- {
- return 'POST';
- }
-
- /**
- * @return array
- */
- public function getRedirectData()
- {
- return $this->getData();
- }
-}
diff --git a/src/Message/VerifyPaymentRequest.php b/src/Message/VerifyPaymentRequest.php
deleted file mode 100644
index 9ae3b07..0000000
--- a/src/Message/VerifyPaymentRequest.php
+++ /dev/null
@@ -1,80 +0,0 @@
- $this->getAmount(),
- 'rid' => $this->getReferenceNumber(),
- 'pid' => $this->getProductCode(),
- 'scd' => $this->getMerchantCode(),
- ];
- }
-
- /**
- * @return string
- */
- public function getUserAgent()
- {
- $userAgent = $this->userAgent;
-
- if (isset($_SERVER['HTTP_USER_AGENT'])) {
- $userAgent = $_SERVER['HTTP_USER_AGENT'];
- }
-
- return $userAgent;
- }
-
- /**
- * @param $data
- *
- * @return \Omnipay\Esewa\Message\VerifyPaymentResponse
- */
- public function sendData($data)
- {
- $endPoint = $this->getEndpoint();
-
- $headers = [
- 'User-Agent' => $this->getUserAgent(),
- 'Accept' => 'application/xml',
- 'Content-Type' => 'application/x-www-form-urlencoded; charset=utf-8',
- ];
-
- $httpResponse = $this->httpClient->request('POST', $endPoint, $headers, http_build_query($data));
-
- $content = new SimpleXMLElement($httpResponse->getBody()->getContents());
-
- return $this->response = new VerifyPaymentResponse($this, $content);
- }
-
- /**
- * @return string
- */
- protected function getEndpoint()
- {
- $endPoint = $this->getTestMode() ? $this->testEndpoint : $this->liveEndpoint;
-
- return $endPoint.$this->verifyEndPoint;
- }
-}
diff --git a/src/Message/VerifyPaymentResponse.php b/src/Message/VerifyPaymentResponse.php
deleted file mode 100644
index 5010c7c..0000000
--- a/src/Message/VerifyPaymentResponse.php
+++ /dev/null
@@ -1,40 +0,0 @@
-request = $request;
- $this->data = $data;
- }
-
- /**
- * @return string
- */
- public function getResponseText()
- {
- return (string) trim($this->data->response_code);
- }
-
- /**
- * @return bool
- */
- public function isSuccessful()
- {
- $string = strtolower($this->getResponseText());
-
- return in_array($string, ['success']);
- }
-}
diff --git a/src/SecureGateway.php b/src/SecureGateway.php
deleted file mode 100644
index 8c4a65e..0000000
--- a/src/SecureGateway.php
+++ /dev/null
@@ -1,205 +0,0 @@
- '',
- 'testMode' => false,
- 'secretKey' => '',
- ];
- }
-
- /**
- * @return string
- */
- public function getMerchantCode()
- {
- return $this->getParameter('merchantCode');
- }
-
- /**
- * @param $value
- */
- public function setMerchantCode($value)
- {
- return $this->setParameter('merchantCode', $value);
- }
-
- /**
- * @param $value
- */
- public function setTaxAmount($value)
- {
- return $this->setParameter('taxAmount', $value);
- }
-
- /**
- * @return string
- */
- public function getTaxAmount()
- {
- return $this->getParameter('taxAmount');
- }
-
- /**
- * @param $value
- */
- public function setServiceCharge($value)
- {
- return $this->setParameter('serviceCharge', $value);
- }
-
- /**
- * @return string
- */
- public function getServiceCharge()
- {
- return $this->getParameter('serviceCharge');
- }
-
- /**
- * @param $value
- */
- public function setDeliveryCharge($value)
- {
- return $this->setParameter('deliveryCharge', $value);
- }
-
- /**
- * @return string
- */
- public function getDeliveryCharge()
- {
- return $this->getParameter('deliveryCharge');
- }
-
- /**
- * @param $value
- */
- public function setTotalAmount($value)
- {
- return $this->setParameter('totalAmount', $value);
- }
-
- /**
- * @return string
- */
- public function getTotalAmount()
- {
- return $this->getParameter('totalAmount');
- }
-
- /**
- * @param $value
- */
- public function setProductCode($value)
- {
- return $this->setParameter('productCode', $value);
- }
-
- /**
- * @return string
- */
- public function getProductCode()
- {
- return $this->getParameter('productCode');
- }
-
- /**
- * @return string
- */
- public function getFailedUrl()
- {
- return $this->getParameter('failedUrl');
- }
-
- /**
- * @param string $value
- *
- * @return $this
- */
- public function setFailedUrl($value)
- {
- return $this->setParameter('failedUrl', $value);
- }
-
- /**
- * @return string
- */
- public function getReferenceNumber()
- {
- return $this->getParameter('referenceNumber');
- }
-
- /**
- * @param string $value
- *
- * @return $this
- */
- public function setReferenceNumber($value)
- {
- return $this->setParameter('referenceNumber', $value);
- }
-
- /**
- * @param string $value
- *
- * @return $this
- */
- public function setSecretKey($value)
- {
- return $this->setParameter('secretKey', $value);
- }
-
- /**
- * @param array $parameters
- *
- * @return \Omnipay\Esewa\Message\PurchaseRequest
- */
- public function purchase(array $parameters = [])
- {
- return $this->createRequest('\Omnipay\Esewa\Message\PurchaseRequest', $parameters);
- }
-
- /**
- * @param array $parameters
- *
- * @return \Omnipay\Esewa\Message\VerifyPaymentRequest
- */
- public function verifyPayment(array $parameters = [])
- {
- return $this->createRequest('\Omnipay\Esewa\Message\VerifyPaymentRequest', $parameters);
- }
-
- /**
- * @param array $parameters
- *
- * @return \Omnipay\Esewa\Message\CheckStatusRequest
- */
- public function checkStatus(array $parameters = [])
- {
- return $this->createRequest('\Omnipay\Esewa\Message\CheckStatusRequest', $parameters);
- }
-}
diff --git a/src/Service/CallbackVerifier.php b/src/Service/CallbackVerifier.php
new file mode 100644
index 0000000..fc00f60
--- /dev/null
+++ b/src/Service/CallbackVerifier.php
@@ -0,0 +1,123 @@
+decodedData();
+
+ $validSignature = $this->signatures->verify(
+ $payload->signature,
+ $data->totalAmount,
+ $data->transactionUuid,
+ $data->productCode,
+ $data->signedFieldNames
+ );
+
+ if (!$validSignature) {
+ $this->options->logger->warning('eSewa callback rejected due to invalid signature.', [
+ 'event' => 'esewa.callback.invalid_signature',
+ 'transaction_uuid' => $data->transactionUuid,
+ ]);
+
+ return new CallbackVerification(
+ false,
+ $data->status,
+ $data->transactionCode,
+ 'Invalid callback signature.',
+ $data->raw
+ );
+ }
+
+ if ($context !== null) {
+ $this->assertConsistent($context, $data->totalAmount, $data->transactionUuid, $data->productCode, $data->transactionCode);
+ }
+
+ if ($this->options->preventCallbackReplay) {
+ $idempotencyKey = $this->resolveIdempotencyKey($payload, $data->transactionCode);
+
+ if ($this->options->idempotencyStore->has($idempotencyKey)) {
+ $this->options->logger->warning('eSewa callback replay detected.', [
+ 'event' => 'esewa.callback.replay_detected',
+ 'transaction_uuid' => $data->transactionUuid,
+ 'reference_id' => $data->transactionCode,
+ ]);
+
+ return new CallbackVerification(
+ false,
+ $data->status,
+ $data->transactionCode,
+ 'Duplicate callback detected.',
+ $data->raw
+ );
+ }
+
+ $this->options->idempotencyStore->put($idempotencyKey);
+ }
+
+ $this->options->logger->info('eSewa callback verified.', [
+ 'event' => 'esewa.callback.verified',
+ 'transaction_uuid' => $data->transactionUuid,
+ 'status' => $data->status->value,
+ 'reference_id' => $data->transactionCode,
+ ]);
+
+ return new CallbackVerification(true, $data->status, $data->transactionCode, 'Callback verified.', $data->raw);
+ }
+
+ private function assertConsistent(
+ VerificationExpectation $context,
+ string $totalAmount,
+ string $transactionUuid,
+ string $productCode,
+ ?string $referenceId
+ ): void {
+ if ($context->totalAmount !== $totalAmount) {
+ throw new FraudValidationException('total_amount mismatch during callback verification.');
+ }
+
+ if ($context->transactionUuid !== $transactionUuid) {
+ throw new FraudValidationException('transaction_uuid mismatch during callback verification.');
+ }
+
+ if ($context->productCode !== $productCode) {
+ throw new FraudValidationException('product_code mismatch during callback verification.');
+ }
+
+ if ($context->referenceId !== null && $context->referenceId !== $referenceId) {
+ $this->options->logger->warning('eSewa callback fraud validation failed.', [
+ 'event' => 'esewa.callback.fraud_reference_mismatch',
+ 'expected_reference_id' => $context->referenceId,
+ 'actual_reference_id' => $referenceId,
+ ]);
+
+ throw new FraudValidationException('reference_id mismatch during callback verification.');
+ }
+ }
+
+ private function resolveIdempotencyKey(CallbackPayload $payload, ?string $referenceId): string
+ {
+ if ($referenceId !== null && $referenceId !== '') {
+ return 'ref:'.$referenceId;
+ }
+
+ return 'digest:'.hash('sha256', $payload->data.'|'.$payload->signature);
+ }
+}
diff --git a/src/Service/SignatureService.php b/src/Service/SignatureService.php
new file mode 100644
index 0000000..a86eafa
--- /dev/null
+++ b/src/Service/SignatureService.php
@@ -0,0 +1,54 @@
+ $totalAmount,
+ 'transaction_uuid' => $transactionUuid,
+ 'product_code' => $productCode,
+ ];
+
+ $parts = [];
+ foreach ($fields as $field) {
+ if (!array_key_exists($field, $values)) {
+ continue;
+ }
+
+ $parts[] = $field.'='.$values[$field];
+ }
+
+ $message = implode(',', $parts);
+
+ return base64_encode(hash_hmac('sha256', $message, $this->secretKey, true));
+ }
+
+ public function verify(
+ string $signature,
+ string $totalAmount,
+ string $transactionUuid,
+ string $productCode,
+ string $signedFieldNames = 'total_amount,transaction_uuid,product_code'
+ ): bool {
+ $generated = $this->generate($totalAmount, $transactionUuid, $productCode, $signedFieldNames);
+
+ return hash_equals($generated, $signature);
+ }
+}
diff --git a/src/helpers.php b/src/helpers.php
deleted file mode 100644
index 54738b2..0000000
--- a/src/helpers.php
+++ /dev/null
@@ -1,18 +0,0 @@
- "$key=".$filteredParameters[$key], array_keys($filteredParameters)));
- }
-}
diff --git a/tests/Fakes/ArrayLogger.php b/tests/Fakes/ArrayLogger.php
new file mode 100644
index 0000000..f6657d7
--- /dev/null
+++ b/tests/Fakes/ArrayLogger.php
@@ -0,0 +1,108 @@
+}> */
+ public array $records = [];
+
+ /**
+ * @param string|\Stringable $message
+ * @param array $context
+ */
+ public function emergency($message, array $context = []): void
+ {
+ $this->log(LogLevel::EMERGENCY, (string) $message, $context);
+ }
+
+ /**
+ * @param string|\Stringable $message
+ * @param array $context
+ */
+ public function alert($message, array $context = []): void
+ {
+ $this->log(LogLevel::ALERT, (string) $message, $context);
+ }
+
+ /**
+ * @param string|\Stringable $message
+ * @param array $context
+ */
+ public function critical($message, array $context = []): void
+ {
+ $this->log(LogLevel::CRITICAL, (string) $message, $context);
+ }
+
+ /**
+ * @param string|\Stringable $message
+ * @param array $context
+ */
+ public function error($message, array $context = []): void
+ {
+ $this->log(LogLevel::ERROR, (string) $message, $context);
+ }
+
+ /**
+ * @param string|\Stringable $message
+ * @param array $context
+ */
+ public function warning($message, array $context = []): void
+ {
+ $this->log(LogLevel::WARNING, (string) $message, $context);
+ }
+
+ /**
+ * @param string|\Stringable $message
+ * @param array $context
+ */
+ public function notice($message, array $context = []): void
+ {
+ $this->log(LogLevel::NOTICE, (string) $message, $context);
+ }
+
+ /**
+ * @param string|\Stringable $message
+ * @param array $context
+ */
+ public function info($message, array $context = []): void
+ {
+ $this->log(LogLevel::INFO, (string) $message, $context);
+ }
+
+ /**
+ * @param string|\Stringable $message
+ * @param array $context
+ */
+ public function debug($message, array $context = []): void
+ {
+ $this->log(LogLevel::DEBUG, (string) $message, $context);
+ }
+
+ /**
+ * @param string $level
+ * @param string|\Stringable $message
+ * @param array $context
+ */
+ public function log($level, $message, array $context = []): void
+ {
+ $normalizedContext = [];
+
+ foreach ($context as $key => $value) {
+ if (is_string($key)) {
+ $normalizedContext[$key] = $value;
+ }
+ }
+
+ $this->records[] = [
+ 'level' => (string) $level,
+ 'message' => (string) $message,
+ 'context' => $normalizedContext,
+ ];
+ }
+}
diff --git a/tests/Fakes/FakeTransport.php b/tests/Fakes/FakeTransport.php
new file mode 100644
index 0000000..47a0233
--- /dev/null
+++ b/tests/Fakes/FakeTransport.php
@@ -0,0 +1,38 @@
+ */
+ private array $next;
+
+ /** @var array */
+ public array $lastQuery = [];
+
+ public string $lastUrl = '';
+
+ /** @param array $next */
+ public function __construct(array $next)
+ {
+ $this->next = $next;
+ }
+
+ /**
+ * @param array $query
+ * @param array $headers
+ *
+ * @return array
+ */
+ public function get(string $url, array $query = [], array $headers = []): array
+ {
+ $this->lastUrl = $url;
+ $this->lastQuery = $query;
+
+ return $this->next;
+ }
+}
diff --git a/tests/Fakes/FlakyTransport.php b/tests/Fakes/FlakyTransport.php
new file mode 100644
index 0000000..f280dda
--- /dev/null
+++ b/tests/Fakes/FlakyTransport.php
@@ -0,0 +1,45 @@
+|\Throwable> */
+ private array $responses;
+
+ public int $attempts = 0;
+
+ /**
+ * @param array|\Throwable> $responses
+ */
+ public function __construct(array $responses)
+ {
+ $this->responses = $responses;
+ }
+
+ /**
+ * @param array $query
+ * @param array $headers
+ *
+ * @return array
+ */
+ public function get(string $url, array $query = [], array $headers = []): array
+ {
+ ++$this->attempts;
+
+ $response = array_shift($this->responses);
+ if ($response instanceof \Throwable) {
+ throw $response;
+ }
+
+ if (is_array($response)) {
+ return $response;
+ }
+
+ return [];
+ }
+}
diff --git a/tests/Message/PurchaseRequestTest.php b/tests/Message/PurchaseRequestTest.php
deleted file mode 100644
index 4aa75c8..0000000
--- a/tests/Message/PurchaseRequestTest.php
+++ /dev/null
@@ -1,47 +0,0 @@
-request = new PurchaseRequest($this->getHttpClient(), $this->getHttpRequest());
-
- $this->request->initialize([
- 'merchantCode' => 'epay_payment',
- 'amount' => 100,
- 'deliveryCharge' => 0,
- 'serviceCharge' => 0,
- 'taxAmount' => 0,
- 'totalAmount' => 100,
- 'productCode' => 'ABAC2098',
- 'returnUrl' => 'https://merchant.com/payment/1/complete',
- 'failedUrl' => 'https://merchant.com/payment/1/failed',
- ]);
- }
-
- public function testSend()
- {
- $response = $this->request->send();
-
- $this->assertInstanceOf('Omnipay\Esewa\Message\PurchaseResponse', $response);
- $this->assertFalse($response->isSuccessful());
- $this->assertTrue($response->isRedirect());
-
- $this->assertSame('https://epay.esewa.com.np/api/epay/main/v2/form', $response->getRedirectUrl());
- $this->assertSame('POST', $response->getRedirectMethod());
-
- $data = $response->getData();
- $this->assertArrayHasKey('amount', $data);
- $this->assertSame('100.00', $data['amount']);
- }
-}
diff --git a/tests/Message/PurchaseResponseTest.php b/tests/Message/PurchaseResponseTest.php
deleted file mode 100644
index 9a73460..0000000
--- a/tests/Message/PurchaseResponseTest.php
+++ /dev/null
@@ -1,58 +0,0 @@
-request = new PurchaseRequest($this->getHttpClient(), $this->getHttpRequest());
-
- $this->request->initialize([
- 'merchantCode' => 'epay_payment',
- 'amount' => 100,
- 'deliveryCharge' => 0,
- 'serviceCharge' => 0,
- 'taxAmount' => 0,
- 'totalAmount' => 100,
- 'productCode' => 'ABAC2098',
- 'returnUrl' => 'https://merchant.com/payment/1/complete',
- 'failedUrl' => 'https://merchant.com/payment/1/failed',
- ]);
- }
-
- public function testRedirect()
- {
- $data = ['test' => '123'];
-
- $response = new PurchaseResponse($this->getMockRequest(), $data, 'https://example.com/');
-
- $this->assertFalse($response->isSuccessful());
- $this->assertTrue($response->isRedirect());
-
- $this->assertSame('https://example.com/', $response->getRedirectUrl());
- $this->assertSame('POST', $response->getRedirectMethod());
- $this->assertSame($data, $response->getRedirectData());
- }
-
- public function testSuccessResponse()
- {
- $this->setMockHttpResponse('PurchaseResponseSuccess.txt');
-
- $response = $this->request->send();
- $data = $response->getData();
-
- $this->assertInstanceOf('Omnipay\Esewa\Message\PurchaseResponse', $response);
-
- $this->assertFalse($response->isSuccessful());
- $this->assertTrue($response->isRedirect());
- }
-}
diff --git a/tests/Message/VerifyPaymentRequestTest.php b/tests/Message/VerifyPaymentRequestTest.php
deleted file mode 100644
index 96993da..0000000
--- a/tests/Message/VerifyPaymentRequestTest.php
+++ /dev/null
@@ -1,55 +0,0 @@
-request = new VerifyPaymentRequest($this->getHttpClient(), $this->getHttpRequest());
-
- $this->request->initialize([
- 'merchantCode' => 'epay_payment',
- 'testMode' => true,
- 'amount' => 100,
- 'referenceNumber' => 'GDFG89',
- 'productCode' => 'ABAC2098',
- ]);
- }
-
- public function testVerificationSuccess()
- {
- $this->setMockHttpResponse('VerifyPaymentRequestSuccess.txt');
-
- $response = $this->request->send();
- $data = $response->getData();
-
- $this->assertInstanceOf('Omnipay\Esewa\Message\VerifyPaymentResponse', $response);
-
- $this->assertTrue($response->isSuccessful());
- $this->assertFalse($response->isRedirect());
- $this->assertSame('Success', $response->getResponseText());
- }
-
- public function testVerificationFailure()
- {
- $this->setMockHttpResponse('VerifyPaymentRequestFailure.txt');
-
- $response = $this->request->send();
- $data = $response->getData();
-
- $this->assertInstanceOf('Omnipay\Esewa\Message\VerifyPaymentResponse', $response);
-
- $this->assertFalse($response->isSuccessful());
- $this->assertFalse($response->isRedirect());
- $this->assertSame('failure', $response->getResponseText());
- }
-}
diff --git a/tests/Mock/PurchaseResponseSuccess.txt b/tests/Mock/PurchaseResponseSuccess.txt
deleted file mode 100644
index f5540fd..0000000
--- a/tests/Mock/PurchaseResponseSuccess.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-HTTP/1.1 200 OK
-Content-Type: application/json
-Date: Tue, 27 Feb 2025 10:20:00 GMT
-Server: Apache/2.4.7 (Ubuntu)
-Connection: close
-
-data=eyJ0cmFuc2FjdGlvbl9jb2RlIjoiMExENUNFSCIsInN0YXR1cyI6IkNPTVBMRVRFIiwidG90YWxfYW1vdW50IjoiMSwwMDAuMCIsInRyYW5zYWN0aW9uX3V1aWQiOiIyNDA2MTMtMTM0MjMxIiwicHJvZHVjdF9jb2RlIjoiTlAtRVMtQUJISVNIRUstRVBBWSIsInNpZ25lZF9maWVsZF9uYW1lcyI6InRyYW5zYWN0aW9uX2NvZGUsc3RhdHVzLHRvdGFsX2Ftb3VudCx0cmFuc2FjdGlvbl91dWlkLHByb2R1Y3RfY29kZSxzaWduZWRfZmllbGRfbmFtZXMiLCJzaWduYXR1cmUiOiJNcHd5MFRGbEhxcEpqRlVER2ljKzIybWRvZW5JVFQrQ2N6MUxDNjFxTUFjPSJ9
\ No newline at end of file
diff --git a/tests/Mock/VerifyPaymentRequestFailure.txt b/tests/Mock/VerifyPaymentRequestFailure.txt
deleted file mode 100644
index 98781f4..0000000
--- a/tests/Mock/VerifyPaymentRequestFailure.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-HTTP/1.1 200 OK
-Server: Apache/2.4.29 (Ubuntu)
-Date: Sat, 16 Mar 2019 15:56:47 GMT
-Content-Type: text/html;charset=UTF-8
-
-
-failure
diff --git a/tests/Mock/VerifyPaymentRequestSuccess.txt b/tests/Mock/VerifyPaymentRequestSuccess.txt
deleted file mode 100644
index a04c785..0000000
--- a/tests/Mock/VerifyPaymentRequestSuccess.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-HTTP/1.1 200 OK
-Server: Apache/2.4.29 (Ubuntu)
-Date: Sat, 16 Mar 2019 15:56:47 GMT
-Content-Type: text/html;charset=UTF-8
-
-
-Success
diff --git a/tests/SecureGatewayTest.php b/tests/SecureGatewayTest.php
deleted file mode 100644
index c066c87..0000000
--- a/tests/SecureGatewayTest.php
+++ /dev/null
@@ -1,57 +0,0 @@
-gateway = new SecureGateway($this->getHttpClient(), $this->getHttpRequest());
- $this->gateway->setMerchantCode('epay_payment');
- }
-
- public function testPurchase()
- {
- $request = $this->gateway->purchase([
- 'amount' => 100,
- 'deliveryCharge' => 0,
- 'serviceCharge' => 0,
- 'taxAmount' => 0,
- 'totalAmount' => 100,
- 'productCode' => 'ABAC2098',
- 'returnUrl' => 'https://merchant.com/payment/1/complete',
- 'failedUrl' => 'https://merchant.com/payment/1/failed',
- ]);
-
- $this->assertInstanceOf('\Omnipay\Esewa\Message\PurchaseRequest', $request);
- $this->assertSame('100.00', $request->getAmount());
- $this->assertSame(0, $request->getDeliveryCharge());
- $this->assertSame(0, $request->getServiceCharge());
- $this->assertSame(0, $request->getTaxAmount());
- $this->assertSame(100, $request->getTotalAmount());
- $this->assertSame('ABAC2098', $request->getProductCode());
- $this->assertSame('https://merchant.com/payment/1/complete', $request->getReturnUrl());
- $this->assertSame('https://merchant.com/payment/1/failed', $request->getFailedUrl());
- }
-
- public function testVerifyPayment()
- {
- $request = $this->gateway->verifyPayment([
- 'amount' => 100,
- 'referenceNumber' => 'ESEWA1001',
- 'productCode' => 'ABAC2098',
- ]);
-
- $this->assertInstanceOf('\Omnipay\Esewa\Message\VerifyPaymentRequest', $request);
- $this->assertSame('100.00', $request->getAmount());
- $this->assertSame('ESEWA1001', $request->getReferenceNumber());
- $this->assertSame('ABAC2098', $request->getProductCode());
- }
-}
diff --git a/tests/Unit/CallbackPayloadTest.php b/tests/Unit/CallbackPayloadTest.php
new file mode 100644
index 0000000..48dbdf7
--- /dev/null
+++ b/tests/Unit/CallbackPayloadTest.php
@@ -0,0 +1,71 @@
+expectException(InvalidPayloadException::class);
+ $this->expectExceptionMessage('data and signature are required in callback payload.');
+
+ CallbackPayload::fromArray(['data' => '']);
+ }
+
+ public function testDecodedDataThrowsOnInvalidBase64(): void
+ {
+ $payload = new CallbackPayload('not-base64', 'signature');
+
+ $this->expectException(InvalidPayloadException::class);
+ $this->expectExceptionMessage('Callback data is not valid base64.');
+
+ $payload->decodedData();
+ }
+
+ public function testDecodedDataThrowsOnInvalidJson(): void
+ {
+ $payload = new CallbackPayload(base64_encode('not-json'), 'signature');
+
+ $this->expectException(InvalidPayloadException::class);
+ $this->expectExceptionMessage('Callback data is not valid JSON.');
+
+ $payload->decodedData();
+ }
+
+ public function testDecodedDataThrowsWhenRequiredFieldsMissing(): void
+ {
+ $payload = new CallbackPayload(
+ base64_encode((string) json_encode(['status' => 'COMPLETE'])),
+ 'signature'
+ );
+
+ $this->expectException(InvalidPayloadException::class);
+ $this->expectExceptionMessage('Callback data is missing required fields.');
+
+ $payload->decodedData();
+ }
+
+ public function testDecodedDataMapsUnknownStatusToUnknown(): void
+ {
+ $payload = new CallbackPayload(
+ base64_encode((string) json_encode([
+ 'status' => 'SOMETHING_NEW',
+ 'transaction_uuid' => 'TXN-1001',
+ 'total_amount' => '100.00',
+ 'product_code' => 'EPAYTEST',
+ ])),
+ 'signature'
+ );
+
+ $data = $payload->decodedData();
+
+ $this->assertSame(PaymentStatus::UNKNOWN, $data->status);
+ }
+}
diff --git a/tests/Unit/CallbackServiceTest.php b/tests/Unit/CallbackServiceTest.php
new file mode 100644
index 0000000..69970b8
--- /dev/null
+++ b/tests/Unit/CallbackServiceTest.php
@@ -0,0 +1,113 @@
+ 'COMPLETE',
+ 'transaction_uuid' => 'TXN-1001',
+ 'total_amount' => '100.00',
+ 'product_code' => 'EPAYTEST',
+ 'signed_field_names' => 'total_amount,transaction_uuid,product_code',
+ 'transaction_code' => 'R-1001',
+ ];
+
+ $signature = (new SignatureService('secret'))->generate(
+ '100.00',
+ 'TXN-1001',
+ 'EPAYTEST',
+ 'total_amount,transaction_uuid,product_code'
+ );
+
+ $payload = new CallbackPayload(base64_encode((string) json_encode($data)), $signature);
+
+ $result = $gateway->callbacks()->verifyCallback($payload, new VerificationExpectation(
+ totalAmount: '100.00',
+ transactionUuid: 'TXN-1001',
+ productCode: 'EPAYTEST',
+ referenceId: 'R-1001'
+ ));
+
+ $this->assertTrue($result->valid);
+ $this->assertTrue($result->isSuccessful());
+ }
+
+ public function testVerifyReturnsInvalidResultWhenSignatureIsWrong(): void
+ {
+ $config = GatewayConfig::make(
+ merchantCode: 'EPAYTEST',
+ secretKey: 'secret',
+ environment: 'uat',
+ );
+
+ $gateway = new EsewaClient($config, new FakeTransport([]));
+
+ $data = [
+ 'status' => 'COMPLETE',
+ 'transaction_uuid' => 'TXN-1001',
+ 'total_amount' => '100.00',
+ 'product_code' => 'EPAYTEST',
+ ];
+
+ $payload = new CallbackPayload(base64_encode((string) json_encode($data)), 'wrong-signature');
+ $result = $gateway->callbacks()->verifyCallback($payload);
+
+ $this->assertFalse($result->valid);
+ $this->assertSame(PaymentStatus::COMPLETE, $result->status);
+ $this->assertSame('Invalid callback signature.', $result->message);
+ $this->assertFalse($result->isSuccessful());
+ }
+
+ public function testVerifyThrowsOnFraudMismatch(): void
+ {
+ $config = GatewayConfig::make(
+ merchantCode: 'EPAYTEST',
+ secretKey: 'secret',
+ environment: 'uat',
+ );
+
+ $gateway = new EsewaClient($config, new FakeTransport([]));
+
+ $data = [
+ 'status' => 'COMPLETE',
+ 'transaction_uuid' => 'TXN-1001',
+ 'total_amount' => '100.00',
+ 'product_code' => 'EPAYTEST',
+ 'signed_field_names' => 'total_amount,transaction_uuid,product_code',
+ ];
+
+ $signature = (new SignatureService('secret'))->generate('100.00', 'TXN-1001', 'EPAYTEST');
+ $payload = new CallbackPayload(base64_encode((string) json_encode($data)), $signature);
+
+ $this->expectException(FraudValidationException::class);
+
+ $gateway->callbacks()->verifyCallback($payload, new VerificationExpectation(
+ totalAmount: '99.00',
+ transactionUuid: 'TXN-1001',
+ productCode: 'EPAYTEST'
+ ));
+ }
+}
diff --git a/tests/Unit/CheckoutServiceTest.php b/tests/Unit/CheckoutServiceTest.php
new file mode 100644
index 0000000..6099173
--- /dev/null
+++ b/tests/Unit/CheckoutServiceTest.php
@@ -0,0 +1,44 @@
+checkout()->createIntent(new CheckoutRequest(
+ amount: '100',
+ taxAmount: '0',
+ serviceCharge: '0',
+ deliveryCharge: '0',
+ transactionUuid: 'TXN-1001',
+ productCode: 'EPAYTEST',
+ successUrl: 'https://merchant.test/success',
+ failureUrl: 'https://merchant.test/failure',
+ ));
+
+ $form = $intent->form();
+
+ $this->assertStringContainsString('/v2/form', $form['action_url']);
+ $this->assertSame('100.00', $form['fields']['total_amount']);
+ $this->assertSame('TXN-1001', $form['fields']['transaction_uuid']);
+ $this->assertNotSame('', $form['fields']['signature']);
+ }
+}
diff --git a/tests/Unit/DomainValidationTest.php b/tests/Unit/DomainValidationTest.php
new file mode 100644
index 0000000..082715f
--- /dev/null
+++ b/tests/Unit/DomainValidationTest.php
@@ -0,0 +1,57 @@
+assertSame('120.00', $request->totalAmount());
+ }
+
+ public function testCheckoutRequestThrowsWhenRequiredFieldMissing(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('transactionUuid is required.');
+
+ new CheckoutRequest(
+ amount: '100',
+ taxAmount: '0',
+ serviceCharge: '0',
+ deliveryCharge: '0',
+ transactionUuid: '',
+ productCode: 'EPAYTEST',
+ successUrl: 'https://merchant.test/success',
+ failureUrl: 'https://merchant.test/failure',
+ );
+ }
+
+ public function testTransactionStatusRequestThrowsWhenRequiredFieldMissing(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('transactionUuid, totalAmount and productCode are required.');
+
+ new TransactionStatusRequest(
+ transactionUuid: '',
+ totalAmount: '100.00',
+ productCode: 'EPAYTEST',
+ );
+ }
+}
diff --git a/tests/Unit/EndpointResolverTest.php b/tests/Unit/EndpointResolverTest.php
new file mode 100644
index 0000000..4032c42
--- /dev/null
+++ b/tests/Unit/EndpointResolverTest.php
@@ -0,0 +1,68 @@
+assertSame(
+ 'https://rc-epay.esewa.com.np/api/epay/main/v2/form',
+ $resolver->checkoutFormUrl($config)
+ );
+ $this->assertSame(
+ 'https://rc-epay.esewa.com.np/api/epay/transaction/status/',
+ $resolver->statusCheckUrl($config)
+ );
+ }
+
+ public function testUsesDefaultProductionEndpoints(): void
+ {
+ $config = GatewayConfig::make(
+ merchantCode: 'EPAYTEST',
+ secretKey: 'secret',
+ environment: 'production',
+ );
+
+ $resolver = new EndpointResolver();
+
+ $this->assertSame(
+ 'https://epay.esewa.com.np/api/epay/main/v2/form',
+ $resolver->checkoutFormUrl($config)
+ );
+ $this->assertSame(
+ 'https://epay.esewa.com.np/api/epay/transaction/status/',
+ $resolver->statusCheckUrl($config)
+ );
+ }
+
+ public function testUsesOverridesWhenProvided(): void
+ {
+ $config = GatewayConfig::make(
+ merchantCode: 'EPAYTEST',
+ secretKey: 'secret',
+ environment: 'uat',
+ checkoutFormUrl: 'https://custom.test/form',
+ statusCheckUrl: 'https://custom.test/status',
+ );
+
+ $resolver = new EndpointResolver();
+
+ $this->assertSame('https://custom.test/form', $resolver->checkoutFormUrl($config));
+ $this->assertSame('https://custom.test/status', $resolver->statusCheckUrl($config));
+ }
+}
diff --git a/tests/Unit/EsewaPaymentTest.php b/tests/Unit/EsewaPaymentTest.php
new file mode 100644
index 0000000..98dc063
--- /dev/null
+++ b/tests/Unit/EsewaPaymentTest.php
@@ -0,0 +1,28 @@
+assertInstanceOf(EsewaClient::class, $client);
+ $this->assertNotNull($client->checkout());
+ $this->assertNotNull($client->callbacks());
+ $this->assertNotNull($client->transactions());
+ }
+}
diff --git a/tests/Unit/GatewayConfigTest.php b/tests/Unit/GatewayConfigTest.php
new file mode 100644
index 0000000..03583ef
--- /dev/null
+++ b/tests/Unit/GatewayConfigTest.php
@@ -0,0 +1,57 @@
+assertSame('EPAYTEST', $config->merchantCode);
+ $this->assertSame('secret', $config->secretKey);
+ $this->assertSame(Environment::PRODUCTION, $config->environment);
+ $this->assertSame('https://checkout.test/form', $config->checkoutFormUrl);
+ $this->assertSame('https://checkout.test/status', $config->statusCheckUrl);
+ }
+
+ public function testThrowsOnEmptyMerchantCode(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('merchantCode is required.');
+
+ new GatewayConfig('', 'secret');
+ }
+
+ public function testThrowsOnEmptySecretKey(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('secretKey is required.');
+
+ new GatewayConfig('EPAYTEST', '');
+ }
+
+ public function testThrowsOnUnsupportedEnvironment(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Unsupported environment: qa');
+
+ GatewayConfig::make(
+ merchantCode: 'EPAYTEST',
+ secretKey: 'secret',
+ environment: 'qa',
+ );
+ }
+}
diff --git a/tests/Unit/ProductionHardeningTest.php b/tests/Unit/ProductionHardeningTest.php
new file mode 100644
index 0000000..6cf0857
--- /dev/null
+++ b/tests/Unit/ProductionHardeningTest.php
@@ -0,0 +1,118 @@
+ 'COMPLETE',
+ 'transaction_uuid' => 'TXN-2001',
+ 'total_amount' => '100.00',
+ 'product_code' => 'EPAYTEST',
+ 'transaction_code' => 'REF-2001',
+ 'signed_field_names' => 'total_amount,transaction_uuid,product_code',
+ ];
+
+ $signature = (new SignatureService('secret'))->generate('100.00', 'TXN-2001', 'EPAYTEST');
+ $payload = new CallbackPayload(base64_encode((string) json_encode($data)), $signature);
+
+ $first = $gateway->callbacks()->verifyCallback($payload);
+ $second = $gateway->callbacks()->verifyCallback($payload);
+
+ $this->assertTrue($first->valid);
+ $this->assertFalse($second->valid);
+ $this->assertSame('Duplicate callback detected.', $second->message);
+ $this->assertTrue($this->hasEvent($logger, 'esewa.callback.replay_detected'));
+ }
+
+ public function testStatusCheckRetriesOnTransportErrorsAndEventuallySucceeds(): void
+ {
+ $logger = new ArrayLogger();
+ $transport = new FlakyTransport([
+ new TransportException('temporary timeout'),
+ new TransportException('temporary 502'),
+ ['status' => 'COMPLETE', 'ref_id' => 'REF-OK'],
+ ]);
+
+ $gateway = new EsewaClient(
+ GatewayConfig::make('EPAYTEST', 'secret', 'uat'),
+ $transport,
+ new ClientOptions(maxStatusRetries: 2, statusRetryDelayMs: 0, logger: $logger)
+ );
+
+ $result = $gateway->transactions()->fetchStatus(new TransactionStatusRequest(
+ transactionUuid: 'TXN-3001',
+ totalAmount: '500.00',
+ productCode: 'EPAYTEST'
+ ));
+
+ $this->assertSame(3, $transport->attempts);
+ $this->assertSame(PaymentStatus::COMPLETE, $result->status);
+ $this->assertTrue($this->hasEvent($logger, 'esewa.status.retry'));
+ }
+
+ public function testStatusCheckThrowsWhenRetryLimitExceeded(): void
+ {
+ $transport = new FlakyTransport([
+ new TransportException('temporary timeout'),
+ new TransportException('still down'),
+ ]);
+
+ $gateway = new EsewaClient(
+ GatewayConfig::make('EPAYTEST', 'secret', 'uat'),
+ $transport,
+ new ClientOptions(maxStatusRetries: 1, statusRetryDelayMs: 0)
+ );
+
+ $this->expectException(TransportException::class);
+
+ $gateway->transactions()->fetchStatus(new TransactionStatusRequest(
+ transactionUuid: 'TXN-3002',
+ totalAmount: '300.00',
+ productCode: 'EPAYTEST'
+ ));
+ }
+
+ private function hasEvent(ArrayLogger $logger, string $event): bool
+ {
+ foreach ($logger->records as $record) {
+ if (($record['context']['event'] ?? null) === $event) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/tests/Unit/SignatureServiceTest.php b/tests/Unit/SignatureServiceTest.php
new file mode 100644
index 0000000..4189ddb
--- /dev/null
+++ b/tests/Unit/SignatureServiceTest.php
@@ -0,0 +1,22 @@
+generate('100.00', 'TXN-1001', 'EPAYTEST');
+
+ $this->assertNotSame('', $signature);
+ $this->assertTrue($service->verify($signature, '100.00', 'TXN-1001', 'EPAYTEST'));
+ $this->assertFalse($service->verify($signature, '99.00', 'TXN-1001', 'EPAYTEST'));
+ }
+}
diff --git a/tests/Unit/TransactionServiceTest.php b/tests/Unit/TransactionServiceTest.php
new file mode 100644
index 0000000..3942c86
--- /dev/null
+++ b/tests/Unit/TransactionServiceTest.php
@@ -0,0 +1,84 @@
+ 'COMPLETE',
+ 'ref_id' => 'REF-123',
+ ]);
+
+ $gateway = new EsewaClient(
+ GatewayConfig::make(
+ merchantCode: 'EPAYTEST',
+ secretKey: 'secret',
+ environment: 'uat',
+ ),
+ $fake
+ );
+
+ $result = $gateway->transactions()->fetchStatus(new TransactionStatusRequest(
+ transactionUuid: 'TXN-1001',
+ totalAmount: '100.00',
+ productCode: 'EPAYTEST',
+ ));
+
+ $this->assertSame(PaymentStatus::COMPLETE, $result->status);
+ $this->assertTrue($result->isSuccessful());
+ $this->assertSame('TXN-1001', $fake->lastQuery['transaction_uuid']);
+ }
+
+ #[DataProvider('statusProvider')]
+ public function testStatusMapsKnownStatuses(string $apiStatus, PaymentStatus $expectedStatus): void
+ {
+ $gateway = new EsewaClient(
+ GatewayConfig::make(
+ merchantCode: 'EPAYTEST',
+ secretKey: 'secret',
+ environment: 'uat',
+ ),
+ new FakeTransport([
+ 'status' => $apiStatus,
+ 'ref_id' => 'REF-XYZ',
+ ])
+ );
+
+ $result = $gateway->transactions()->fetchStatus(new TransactionStatusRequest(
+ transactionUuid: 'TXN-1001',
+ totalAmount: '100.00',
+ productCode: 'EPAYTEST',
+ ));
+
+ $this->assertSame($expectedStatus, $result->status);
+ }
+
+ /**
+ * @return array
+ */
+ public static function statusProvider(): array
+ {
+ return [
+ ['PENDING', PaymentStatus::PENDING],
+ ['COMPLETE', PaymentStatus::COMPLETE],
+ ['FULL_REFUND', PaymentStatus::FULL_REFUND],
+ ['PARTIAL_REFUND', PaymentStatus::PARTIAL_REFUND],
+ ['AMBIGUOUS', PaymentStatus::AMBIGUOUS],
+ ['NOT_FOUND', PaymentStatus::NOT_FOUND],
+ ['CANCELED', PaymentStatus::CANCELED],
+ ['NEW_FUTURE_STATUS', PaymentStatus::UNKNOWN],
+ ];
+ }
+}