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. + +[![CI](https://github.com/sudiptpa/esewa/actions/workflows/ci.yml/badge.svg)](https://github.com/sudiptpa/esewa/actions/workflows/ci.yml) +[![Latest Version](https://img.shields.io/packagist/v/sudiptpa/esewa-payment.svg)](https://packagist.org/packages/sudiptpa/esewa-payment) +[![Total Downloads](https://img.shields.io/packagist/dt/sudiptpa/esewa-payment.svg)](https://packagist.org/packages/sudiptpa/esewa-payment/stats) +[![PHP Version](https://img.shields.io/packagist/php-v/sudiptpa/esewa-payment.svg)](https://packagist.org/packages/sudiptpa/esewa-payment) +[![License](https://img.shields.io/packagist/l/sudiptpa/esewa-payment.svg)](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 +``` -[![StyleCI](https://github.styleci.io/repos/75586885/shield?branch=master&format=plastic)](https://github.styleci.io/repos/75586885) -[![Latest Stable Version](https://poser.pugx.org/sudiptpa/omnipay-esewa/v/stable)](https://packagist.org/packages/sudiptpa/omnipay-esewa) -[![Total Downloads](https://poser.pugx.org/sudiptpa/omnipay-esewa/downloads)](https://packagist.org/packages/sudiptpa/omnipay-esewa) -[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](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 '
'; + +foreach ($form['fields'] as $name => $value) { + echo ''; +} + +echo ''; +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 +
+ @foreach ($fields as $name => $value) + + @endforeach +
+ + +``` -### 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. - -[![StyleCI](https://github.styleci.io/repos/75586885/shield?branch=master&format=plastic)](https://github.styleci.io/repos/75586885) -[![Latest Stable Version](https://poser.pugx.org/sudiptpa/omnipay-esewa/v/stable)](https://packagist.org/packages/sudiptpa/omnipay-esewa) -[![Total Downloads](https://poser.pugx.org/sudiptpa/omnipay-esewa/downloads)](https://packagist.org/packages/sudiptpa/omnipay-esewa) -[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](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], + ]; + } +}